diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index 5b3c61db8fb..6e7dc84415d 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -11,8 +11,20 @@ on: - 'optional-skills/**' - '.github/workflows/deploy-site.yml' workflow_dispatch: + inputs: + skills_index_run_id: + description: 'Optional Build Skills Index run ID whose skills-index artifact should be deployed' + required: false + type: string + rebuild_skills_index: + description: 'Force a fresh multi-source crawl instead of reusing the latest healthy index' + required: false + default: false + type: boolean permissions: + contents: read + actions: read pages: write id-token: write @@ -55,26 +67,81 @@ jobs: - name: Install PyYAML for skill extraction run: pip install pyyaml==6.0.2 httpx==0.28.1 - - name: Build skills index (unified multi-source catalog) + - name: Prepare skills index (unified multi-source catalog) env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ github.token }} + GITHUB_TOKEN: ${{ github.token }} + SKILLS_INDEX_RUN_ID: ${{ github.event.inputs.skills_index_run_id || '' }} + REBUILD_SKILLS_INDEX: ${{ github.event.inputs.rebuild_skills_index || 'false' }} run: | - # Rebuild the unified catalog. The file is gitignored, so a fresh - # checkout starts without it and we want the freshest crawl in - # every deploy. + # The unified external catalog is expensive to crawl and can burn + # through the repository installation's GitHub API quota when several + # docs deploys land close together. Normal docs deploys therefore + # reuse the latest healthy catalog: first the artifact from a + # scheduled skills-index run, then the currently live index. Only a + # manual force rebuild does a fresh crawl here. # - # This MUST be fatal. build_skills_index.py runs a health check and - # exits non-zero WITHOUT writing the output file when a source - # collapses (e.g. a GitHub API rate limit zeroes the github / - # claude-marketplace / well-known taps all at once). Letting the - # deploy continue would either (a) ship a degenerate index missing - # whole hubs — the June 2026 regression where OpenAI/Anthropic/ - # HuggingFace/NVIDIA tabs vanished — or (b) fall through to a - # local-only catalog. Failing here keeps the last good deployment - # live (GitHub Pages serves the previous build) instead of - # publishing a broken catalog. Re-run the workflow once the - # transient rate limit clears. + # If we do crawl, the build remains fatal. build_skills_index.py runs + # the health check BEFORE writing and exits non-zero on source + # collapse, keeping the last good Pages deployment live instead of + # publishing a degenerate catalog. + set -euo pipefail + INDEX_PATH="website/static/api/skills-index.json" + mkdir -p "$(dirname "$INDEX_PATH")" + + validate_index() { + python3 - "$INDEX_PATH" <<'PY' + import json + import sys + from pathlib import Path + + path = Path(sys.argv[1]) + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception as exc: + print(f"invalid skills index JSON: {exc}", file=sys.stderr) + sys.exit(1) + skills = data.get("skills") + if not isinstance(skills, list) or len(skills) < 1500: + count = len(skills) if isinstance(skills, list) else "missing" + print(f"skills index too small: {count}", file=sys.stderr) + sys.exit(1) + print(f"skills index ready: {len(skills)} skills") + PY + } + + if [ "$REBUILD_SKILLS_INDEX" = "true" ]; then + python3 scripts/build_skills_index.py + validate_index + exit 0 + fi + + if [ -n "$SKILLS_INDEX_RUN_ID" ]; then + tmpdir="$(mktemp -d)" + echo "Downloading skills-index artifact from run $SKILLS_INDEX_RUN_ID" + if gh run download "$SKILLS_INDEX_RUN_ID" --name skills-index --dir "$tmpdir"; then + candidate="$(find "$tmpdir" -name skills-index.json -type f | head -n 1 || true)" + if [ -n "$candidate" ]; then + cp "$candidate" "$INDEX_PATH" + if validate_index; then + exit 0 + fi + fi + fi + echo "::warning::Could not use skills-index artifact from run $SKILLS_INDEX_RUN_ID; trying live index" + fi + + echo "Downloading currently live skills index" + if curl -fsSL --retry 3 --retry-delay 5 \ + "https://hermes-agent.nousresearch.com/docs/api/skills-index.json" \ + -o "$INDEX_PATH" && validate_index; then + exit 0 + fi + + echo "::warning::Live skills index unavailable or unhealthy; falling back to a fresh crawl" + rm -f "$INDEX_PATH" python3 scripts/build_skills_index.py + validate_index - name: Extract skill metadata for dashboard run: python3 website/scripts/extract-skills.py diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 2e972cb11c3..c12ad772fa6 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -90,7 +90,7 @@ jobs: # (see `_SKIP_PARTS` in scripts/run_tests_parallel.py) because each # shard would otherwise reach the session-scoped ``built_image`` # fixture in ``tests/docker/conftest.py`` and start a 3-7min - # ``docker build`` under a 180s pytest-timeout cap — guaranteed to + # ``docker build`` — guaranteed to # die in fixture setup. # # Piggybacking here avoids a second image build: the smoke test @@ -114,7 +114,7 @@ jobs: run: | uv venv .venv --python 3.11 source .venv/bin/activate - # ``dev`` extra pulls in pytest, pytest-asyncio, pytest-timeout — + # ``dev`` extra pulls in pytest, pytest-asyncio — # everything tests/docker/ needs. We deliberately avoid ``all`` # here because the docker tests only drive the container via # subprocess and don't import hermes_agent's optional deps. diff --git a/.github/workflows/skills-index.yml b/.github/workflows/skills-index.yml index 72f252b26eb..c6caf098133 100644 --- a/.github/workflows/skills-index.yml +++ b/.github/workflows/skills-index.yml @@ -53,4 +53,4 @@ jobs: - name: Trigger Deploy Site workflow env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh workflow run deploy-site.yml --repo ${{ github.repository }} + run: gh workflow run deploy-site.yml --repo ${{ github.repository }} -f skills_index_run_id=${{ github.run_id }} diff --git a/.github/workflows/supply-chain-audit.yml b/.github/workflows/supply-chain-audit.yml index 3309de78dae..4bee46a95cd 100644 --- a/.github/workflows/supply-chain-audit.yml +++ b/.github/workflows/supply-chain-audit.yml @@ -29,6 +29,8 @@ jobs: scan: ${{ steps.filter.outputs.scan }} # True when pyproject.toml changed in this PR deps: ${{ steps.filter.outputs.deps }} + # True when the curated MCP catalog / bundled MCP manifests changed. + mcp_catalog: ${{ steps.filter.outputs.mcp_catalog }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -54,6 +56,14 @@ jobs: else echo "deps=false" >> "$GITHUB_OUTPUT" fi + MCP_CATALOG_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- \ + 'optional-mcps/**' \ + 'hermes_cli/mcp_catalog.py' || true) + if [ -n "$MCP_CATALOG_FILES" ]; then + echo "mcp_catalog=true" >> "$GITHUB_OUTPUT" + else + echo "mcp_catalog=false" >> "$GITHUB_OUTPUT" + fi scan: name: Scan PR for critical supply chain risks @@ -268,3 +278,50 @@ jobs: runs-on: ubuntu-latest steps: - run: echo "No pyproject.toml changes, skipping dependency bounds check." + + mcp-catalog-review: + name: MCP catalog security review + needs: changes + if: needs.changes.outputs.mcp_catalog == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Require explicit MCP catalog review label + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + PR="${{ github.event.pull_request.number }}" + LABELS=$(gh pr view "$PR" --json labels --jq '.labels[].name' || true) + if echo "$LABELS" | grep -Fxq 'mcp-catalog-reviewed'; then + echo "MCP catalog review label present." + exit 0 + fi + + BODY="## ⚠️ MCP catalog security review required + + This PR changes the bundled MCP catalog or MCP catalog installer code. MCP entries can define local commands that users later install into \`mcp_servers\`, so this needs explicit maintainer review before merge. + + A maintainer should verify: + - any new/changed \`optional-mcps/**/manifest.yaml\` command and args are expected, + - stdio transports do not use shell+egress/exfiltration payloads, + - git install refs are pinned and bootstrap commands are minimal, + - requested env vars/secrets match the upstream MCP's documented needs. + + After review, add the \`mcp-catalog-reviewed\` label and re-run this check." + + gh pr comment "$PR" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs)" + echo "::error::MCP catalog changes require the mcp-catalog-reviewed label." + exit 1 + + mcp-catalog-review-gate: + name: MCP catalog security review + needs: changes + if: always() && needs.changes.outputs.mcp_catalog != 'true' + runs-on: ubuntu-latest + steps: + - run: echo "No MCP catalog changes, skipping MCP catalog security review." diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cc7d099fd93..a6e7738fa40 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,13 +4,13 @@ on: push: branches: [main] paths-ignore: - - '**/*.md' - - 'docs/**' + - "**/*.md" + - "docs/**" pull_request: branches: [main] paths-ignore: - - '**/*.md' - - 'docs/**' + - "**/*.md" + - "docs/**" permissions: contents: read @@ -30,13 +30,17 @@ jobs: slice: [1, 2, 3, 4, 5, 6] steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Restore duration cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: test_durations.json - # Single stable key. main always overwrites, PRs always find it. + # main always writes a new suffix, but jobs pick the latest one with the same prefix + # quote from https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching#cache-hits-and-misses + # If you provide restore-keys, the cache action sequentially searches for any caches that match the list of restore-keys. + # If there are no exact matches, the action searches for partial matches of the restore keys. + # When the action finds a partial match, the most recent cache is restored to the path directory. key: test-durations - name: Install ripgrep (prebuilt binary) @@ -54,7 +58,7 @@ jobs: rg --version - name: Install uv - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 with: # Persist uv's download/wheel cache (~/.cache/uv) across runs. # Keyed on the dependency manifests, so the cache is reused until @@ -115,7 +119,7 @@ jobs: NOUS_API_KEY: "" - name: Upload per-slice durations - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: test-durations-slice-${{ matrix.slice }} path: test_durations.json @@ -125,11 +129,11 @@ jobs: # (including PRs) get balanced slicing. save-durations: needs: test - if: always() && github.ref == 'refs/heads/main' + if: needs.test.result == 'success' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest steps: - name: Download all slice durations - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: test-durations-slice-* path: durations @@ -149,17 +153,17 @@ jobs: " - name: Save merged duration cache - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: test_durations.json - key: test-durations + key: test-durations-${{ github.run_id }} e2e: runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install ripgrep (prebuilt binary) run: | @@ -176,7 +180,7 @@ jobs: rg --version - name: Install uv - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 with: # Persist uv's download/wheel cache (~/.cache/uv) across runs. # Keyed on the dependency manifests, so the cache is reused until diff --git a/.gitignore b/.gitignore index 2935832db3b..6d87318e35e 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,7 @@ scripts/out/ # stores the published notes. They are not a build artifact and must never be # committed to the repo root. See the hermes-release skill. RELEASE_v*.md + +# Desktop demo-run scratch output (hermes writes demo/*.txt during recorded +# walkthroughs). Throwaway artifacts, never part of the app. +apps/desktop/demo/ diff --git a/acp_adapter/server.py b/acp_adapter/server.py index 6901fe28e88..a51db91d4e8 100644 --- a/acp_adapter/server.py +++ b/acp_adapter/server.py @@ -824,6 +824,7 @@ class HermesACPAgent(acp.Agent): try: from model_tools import get_tool_definitions + from agent.memory_manager import inject_memory_provider_tools enabled_toolsets = _expand_acp_enabled_toolsets( getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"], @@ -839,6 +840,7 @@ class HermesACPAgent(acp.Agent): state.agent.valid_tool_names = { tool["function"]["name"] for tool in state.agent.tools or [] } + inject_memory_provider_tools(state.agent) invalidate = getattr(state.agent, "_invalidate_system_prompt", None) if callable(invalidate): invalidate() @@ -1779,10 +1781,25 @@ class HermesACPAgent(acp.Agent): def _cmd_tools(self, args: str, state: SessionState) -> str: try: from model_tools import get_tool_definitions + from types import SimpleNamespace + from agent.memory_manager import inject_memory_provider_tools + toolsets = _expand_acp_enabled_toolsets( getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"] ) tools = get_tool_definitions(enabled_toolsets=toolsets, quiet_mode=True) + tool_view = SimpleNamespace( + tools=list(tools or []), + valid_tool_names={ + tool.get("function", {}).get("name") + for tool in tools or [] + if isinstance(tool, dict) + }, + enabled_toolsets=toolsets, + _memory_manager=getattr(state.agent, "_memory_manager", None), + ) + inject_memory_provider_tools(tool_view) + tools = tool_view.tools if not tools: return "No tools available." lines = [f"Available tools ({len(tools)}):"] diff --git a/agent/account_usage.py b/agent/account_usage.py index b22ae522cd5..18dc2291c9c 100644 --- a/agent/account_usage.py +++ b/agent/account_usage.py @@ -145,7 +145,7 @@ def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]: account info to show (fail-open: caller just shows nothing). """ try: - from hermes_cli.nous_account import nous_portal_billing_url + from hermes_cli.nous_account import nous_portal_topup_url if account_info is None or not getattr(account_info, "logged_in", False): return None @@ -213,7 +213,8 @@ def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]: if not windows and not details: return None - details.append(f"Manage / top up: {nous_portal_billing_url(account_info)}") + details.append(f"Top up: {nous_portal_topup_url(account_info)}") + details.append("(or run /credits)") plan = getattr(sub, "plan", None) if sub is not None else None return AccountUsageSnapshot( @@ -370,6 +371,93 @@ def _snapshot_from_credits_state(state) -> Optional[AccountUsageSnapshot]: return None +@dataclass(frozen=True) +class CreditsView: + """Surface-agnostic data for the ``/credits`` command. + + One portal fetch, one parse — consumed identically by the CLI panel, the + gateway button, and any other money surface. Fail-open: when not logged in + or the portal is unreachable, ``logged_in`` is False / ``topup_url`` is None + and callers degrade gracefully. + """ + + logged_in: bool + balance_lines: tuple[str, ...] = () + identity_line: Optional[str] = None + topup_url: Optional[str] = None + depleted: bool = False + + +def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> CreditsView: + """Build the /credits view: balance block + identity line + top-up URL. + + Reuses the same account fetch + snapshot + URL builder as the /usage credits + block, so the numbers always match. The balance block is the rendered + snapshot MINUS its trailing top-up/command-hint lines (the /credits surface + supplies its own affordance). Fail-open → ``CreditsView(logged_in=False)``. + """ + not_logged_in = CreditsView(logged_in=False) + try: + from hermes_cli.auth import get_provider_auth_state + + tok = (get_provider_auth_state("nous") or {}).get("access_token") + if not (isinstance(tok, str) and tok.strip()): + return not_logged_in + except Exception: + return not_logged_in + + try: + import concurrent.futures + + from hermes_cli.nous_account import ( + get_nous_portal_account_info, + nous_portal_topup_url, + ) + + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + account = pool.submit(get_nous_portal_account_info, force_fresh=True).result( + timeout=timeout + ) + except Exception: + logger.debug("credits ▸ /credits portal fetch failed (fail-open)", exc_info=True) + return not_logged_in + + if account is None or not getattr(account, "logged_in", False): + return not_logged_in + + snapshot = build_nous_credits_snapshot(account) + # Balance lines = the snapshot block minus the two trailing affordance lines + # ("Top up: " + "(or run /credits)") that build_nous_credits_snapshot + # appends for the /usage surface. /credits renders its own button/panel. + balance_lines: list[str] = [] + if snapshot is not None: + rendered = render_account_usage_lines(snapshot, markdown=markdown) + balance_lines = [ + line + for line in rendered + if not line.lstrip().startswith("Top up:") + and not line.lstrip().startswith("(or run") + ] + + # Identity line — shown before any open (roadmap §4.4). + email = getattr(account, "email", None) + org_name = getattr(account, "org_name", None) + who: list[str] = [] + if email: + who.append(str(email)) + if org_name: + who.append(f"org {org_name}") + identity_line = ("Topping up as " + " / ".join(who)) if who else None + + return CreditsView( + logged_in=True, + balance_lines=tuple(balance_lines), + identity_line=identity_line, + topup_url=nous_portal_topup_url(account), + depleted=getattr(account, "paid_service_access", None) is False, + ) + + def _resolve_codex_usage_url(base_url: str) -> str: normalized = (base_url or "").strip().rstrip("/") if not normalized: diff --git a/agent/agent_init.py b/agent/agent_init.py index 69cf9c23f24..601183cd984 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -900,6 +900,9 @@ def init_agent( agent.api_key = client_kwargs.get("api_key", "") agent.base_url = client_kwargs.get("base_url", agent.base_url) try: + from agent.ssl_guard import verify_ca_bundle_with_fallback + + verify_ca_bundle_with_fallback() agent.client = agent._create_openai_client(client_kwargs, reason="agent_init", shared=True) if not agent.quiet_mode: print(f"🤖 AI Agent initialized with model: {agent.model}") @@ -1193,38 +1196,8 @@ def init_agent( _ra().logger.warning("Memory provider plugin init failed: %s", _mpe) agent._memory_manager = None - # Inject memory provider tool schemas into the tool surface. - # Skip tools whose names already exist (plugins may register the - # same tools via ctx.register_tool(), which lands in agent.tools - # through _ra().get_tool_definitions()). Duplicate function names cause - # 400 errors on providers that enforce unique names (e.g. Xiaomi - # MiMo via Nous Portal). - # - # Respect the platform's enabled_toolsets configuration (#5544): - # enabled_toolsets is None → no filter, inject (backward compat) - # "memory" in enabled_toolsets → user opted in, inject - # otherwise (incl. []) → user excluded memory, skip injection - # - # Without this gate, `platform_toolsets: telegram: []` still leaks memory - # provider tools (fact_store, etc.) into the tool surface — a 10x latency - # penalty on local models and a frequent trigger of tool-call loops. - if agent._memory_manager and agent.tools is not None and ( - agent.enabled_toolsets is None or "memory" in agent.enabled_toolsets - ): - _existing_tool_names = { - t.get("function", {}).get("name") - for t in agent.tools - if isinstance(t, dict) - } - for _schema in agent._memory_manager.get_all_tool_schemas(): - _tname = _schema.get("name", "") - if _tname and _tname in _existing_tool_names: - continue # already registered via plugin path - _wrapped = {"type": "function", "function": _schema} - agent.tools.append(_wrapped) - if _tname: - agent.valid_tool_names.add(_tname) - _existing_tool_names.add(_tname) + from agent.memory_manager import inject_memory_provider_tools as _inject_memory_provider_tools + _inject_memory_provider_tools(agent) # Skills config: nudge interval for skill creation reminders agent._skill_nudge_interval = 10 diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 742af145380..cae1a685a53 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -445,6 +445,45 @@ def repair_message_sequence(agent, messages: List[Dict]) -> int: return repairs +def repair_message_sequence_with_cursor(agent, messages: List[Dict]) -> int: + """Run :func:`repair_message_sequence` and keep the SessionDB flush + cursor consistent with the compacted list (#44837). + + ``repair_message_sequence`` merges/drops messages in place, shrinking + the list. ``_last_flushed_db_idx`` (the DB-write cursor) indexes into + that list, so after compaction it can point past the new end — the + turn-end flush would then skip the assistant/tool chain entirely — or + past unflushed messages shifted to lower indexes. + + Repair preserves object identity for surviving messages, so counting + the survivors from the previously-flushed prefix gives the exact new + cursor even when messages are dropped/merged at indexes *before* the + cursor — a plain ``min()`` clamp would silently skip that many + unflushed rows. Falls back to the clamp when no prefix snapshot is + available. + + Returns the number of repairs made (same as ``repair_message_sequence``). + """ + pre_repair_flushed_ids = None + flush_cursor = getattr(agent, "_last_flushed_db_idx", None) + if isinstance(flush_cursor, int) and flush_cursor > 0: + pre_repair_flushed_ids = {id(m) for m in messages[:flush_cursor]} + + repairs = repair_message_sequence(agent, messages) + + if repairs > 0 and hasattr(agent, "_last_flushed_db_idx"): + if pre_repair_flushed_ids is not None: + agent._last_flushed_db_idx = sum( + 1 for m in messages if id(m) in pre_repair_flushed_ids + ) + else: + agent._last_flushed_db_idx = min( + agent._last_flushed_db_idx, len(messages) + ) + + return repairs + + def strip_think_blocks(agent, content: str) -> str: """Remove reasoning/thinking blocks from content, returning only visible text. @@ -579,12 +618,33 @@ def recover_with_credential_pool( current_provider = (getattr(agent, "provider", "") or "").strip().lower() pool_provider = (getattr(pool, "provider", "") or "").strip().lower() if current_provider and pool_provider and current_provider != pool_provider: - _ra().logger.warning( - "Credential pool provider mismatch: pool=%s, agent=%s — " - "skipping pool mutation to avoid cross-provider contamination", - pool_provider, current_provider, - ) - return False, has_retried_429 + # Custom endpoints use two naming conventions for the SAME provider: + # the agent carries the generic ``custom`` label while the pool is + # keyed ``custom:`` (see CUSTOM_POOL_PREFIX). A literal string + # compare treats them as a mismatch and skips recovery for every + # custom-provider user — 401s/429s then burn the full retry cycle + # with no rotation or refresh. Accept the pair as matching only when + # the agent's CURRENT base_url actually resolves to this pool key, + # so a fallback provider (or a different custom endpoint) still + # triggers the guard. + _custom_match = False + if current_provider == "custom" and pool_provider.startswith("custom:"): + try: + from agent.credential_pool import get_custom_provider_pool_key + _agent_base = (getattr(agent, "base_url", "") or "").strip() + _custom_match = bool(_agent_base) and ( + (get_custom_provider_pool_key(_agent_base) or "").strip().lower() + == pool_provider + ) + except Exception: + _custom_match = False + if not _custom_match: + _ra().logger.warning( + "Credential pool provider mismatch: pool=%s, agent=%s — " + "skipping pool mutation to avoid cross-provider contamination", + pool_provider, current_provider, + ) + return False, has_retried_429 effective_reason = classified_reason if effective_reason is None: @@ -821,6 +881,8 @@ def try_recover_primary_transport( def drop_thinking_only_and_merge_users( messages: List[Dict[str, Any]], + *, + drop_codex_reasoning_items: bool = True, ) -> List[Dict[str, Any]]: """Drop thinking-only assistant turns; merge any adjacent user messages left behind. @@ -842,7 +904,13 @@ def drop_thinking_only_and_merge_users( return messages # Pass 1: drop thinking-only assistant turns. - kept = [m for m in messages if not _ra().AIAgent._is_thinking_only_assistant(m)] + kept = [ + m for m in messages + if not _ra().AIAgent._is_thinking_only_assistant( + m, + drop_codex_reasoning_items=drop_codex_reasoning_items, + ) + ] dropped = len(messages) - len(kept) if dropped == 0: return messages diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 8476ef67f57..3a2d3f68e17 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -751,6 +751,9 @@ def build_anthropic_client( from httpx import Timeout normalized_base_url = _normalize_base_url_text(base_url) + if normalized_base_url: + import re as _re + normalized_base_url = _re.sub(r"/v1/?$", "", normalized_base_url.rstrip("/")) _read_timeout = timeout if (isinstance(timeout, (int, float)) and timeout > 0) else 900.0 kwargs = { "timeout": Timeout(timeout=float(_read_timeout), connect=10.0), diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index c6e00340e7e..01ea45d7be2 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1144,7 +1144,8 @@ def _endpoint_speaks_anthropic_messages(base_url: str) -> bool: normalized = (base_url or "").strip().lower().rstrip("/") if not normalized: return False - if normalized.endswith("/anthropic"): + path = urlparse(normalized).path.rstrip("/") + if path.endswith("/anthropic") or path.endswith("/anthropic/v1"): return True hostname = base_url_hostname(normalized) if hostname == "api.anthropic.com": @@ -3190,7 +3191,7 @@ def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Option if (main_provider and main_model and main_provider not in {"auto", ""}): resolved_provider = main_provider - explicit_base_url = None + explicit_base_url = runtime_base_url or None explicit_api_key = None if runtime_base_url and (main_provider == "custom" or main_provider.startswith("custom:")): resolved_provider = "custom" @@ -5004,7 +5005,7 @@ def _build_call_kwargs( # Provider-specific extra_body merged_extra = dict(extra_body or {}) - if provider == "nous" or auxiliary_is_nous: + if provider == "nous": merged_extra.setdefault("tags", []).extend(_nous_portal_tags()) if merged_extra: kwargs["extra_body"] = merged_extra diff --git a/agent/bedrock_adapter.py b/agent/bedrock_adapter.py index e3abba8436f..a09e1bc5d82 100644 --- a/agent/bedrock_adapter.py +++ b/agent/bedrock_adapter.py @@ -935,11 +935,14 @@ def build_converse_kwargs( if system_prompt: kwargs["system"] = system_prompt - if temperature is not None: - kwargs["inferenceConfig"]["temperature"] = temperature + from agent.anthropic_adapter import _forbids_sampling_params - if top_p is not None: - kwargs["inferenceConfig"]["topP"] = top_p + if not _forbids_sampling_params(model): + if temperature is not None: + kwargs["inferenceConfig"]["temperature"] = temperature + + if top_p is not None: + kwargs["inferenceConfig"]["topP"] = top_p if stop_sequences: kwargs["inferenceConfig"]["stopSequences"] = stop_sequences diff --git a/agent/codex_responses_adapter.py b/agent/codex_responses_adapter.py index 943131f5592..b8479141db1 100644 --- a/agent/codex_responses_adapter.py +++ b/agent/codex_responses_adapter.py @@ -127,14 +127,21 @@ def _chat_content_to_responses_parts(content: Any, *, role: str = "user") -> Lis return converted -def _summarize_user_message_for_log(content: Any) -> str: - """Return a short text summary of a user message for logging/trajectory. +def _summarize_user_message_for_log(content: Any, *, sep: str = " ") -> str: + """Flatten message content to a plain-text summary. Multimodal messages arrive as a list of ``{type:"text"|"image_url", ...}`` - parts from the API server. Logging, spinner previews, and trajectory - files all want a plain string — this helper extracts the first chunk of - text and notes any attached images. Returns an empty string for empty - lists and ``str(content)`` for unexpected scalar types. + parts from the API server. Several consumers want a plain string: + + - Logging, spinner previews, and trajectory files (the default ``sep=" "``). + - External memory providers, which feed the text to regexes + (``sanitize_context``) and text APIs — a raw list crashes the sync with + ``expected string or bytes-like object, got 'list'`` (use ``sep="\\n"``). + + Text parts are joined with ``sep``; images become a ``[N image(s)]`` marker + so the turn isn't recorded as if the attachment never existed. Returns an + empty string for empty lists and ``str(content)`` for unexpected scalar + types. """ if content is None: return "" @@ -157,7 +164,7 @@ def _summarize_user_message_for_log(content: Any) -> str: text_bits.append(text) elif ptype in {"image_url", "input_image"}: image_count += 1 - summary = " ".join(text_bits).strip() + summary = sep.join(text_bits).strip() if image_count: note = f"[{image_count} image{'s' if image_count != 1 else ''}]" summary = f"{note} {summary}" if summary else note @@ -1074,6 +1081,7 @@ def _normalize_codex_response( message_items_raw: List[Dict[str, Any]] = [] tool_calls: List[Any] = [] has_incomplete_items = response_status in {"queued", "in_progress", "incomplete"} + saw_streaming_or_item_incomplete = response_status in {"queued", "in_progress"} saw_commentary_phase = False saw_final_answer_phase = False saw_reasoning_item = False @@ -1088,6 +1096,7 @@ def _normalize_codex_response( if item_status in {"queued", "in_progress", "incomplete"}: has_incomplete_items = True + saw_streaming_or_item_incomplete = True if item_type == "message": item_phase = getattr(item, "phase", None) @@ -1245,7 +1254,9 @@ def _normalize_codex_response( finish_reason = "tool_calls" elif leaked_tool_call_text: finish_reason = "incomplete" - elif has_incomplete_items or (saw_commentary_phase and not saw_final_answer_phase): + elif saw_streaming_or_item_incomplete: + finish_reason = "incomplete" + elif (has_incomplete_items or saw_commentary_phase) and not saw_final_answer_phase: finish_reason = "incomplete" elif (reasoning_items_raw or reasoning_parts or saw_reasoning_item) and not final_text: # Response contains only reasoning (encrypted thinking state and/or diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 2995bf92451..16db1bedc30 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -69,6 +69,31 @@ SUMMARY_PREFIX = ( ) LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:" +# Metadata key added to context compression summary messages so that frontends +# (CLI, Desktop, gateway, TUI) can distinguish them from real assistant/user +# messages and filter or render them appropriately without content-prefix +# heuristics. See https://github.com/NousResearch/hermes-agent/issues/38389 +# +# Underscore-prefixed ON PURPOSE: the wire sanitizers +# (agent/transports/chat_completions.py convert_messages and the summary-path +# mirror in agent/chat_completion_helpers.py) strip every top-level message +# key starting with "_" before the request leaves the process. Strict +# OpenAI-compatible gateways (Fireworks, Mistral, Moonshot/Kimi, opencode-go) +# reject payloads carrying unknown keys with "Extra inputs are not permitted", +# poisoning every subsequent request in the session — a bare key like +# "is_compressed_summary" would reach the wire and trip exactly that. +COMPRESSED_SUMMARY_METADATA_KEY = "_compressed_summary" + +# Appended to every standalone summary message (and to the merged-into-tail +# prefix) so the model has an unambiguous "summary ends here" boundary. +# Without it, weak models read the verbatim "## Active Task" quote as fresh +# user input (#11475, #14521) or regurgitate an assistant-role summary as +# their own output (#33256). +_SUMMARY_END_MARKER = ( + "--- END OF CONTEXT SUMMARY — " + "respond to the message below, not the summary above ---" +) + # Handoff prefixes that shipped in earlier releases. A summary persisted under # one of these can be inherited into a resumed lineage (#35344); when it is # re-normalized on re-compaction we must strip the OLD prefix too, otherwise the @@ -143,10 +168,23 @@ _SUMMARY_FAILURE_COOLDOWN_SECONDS = 600 # become another unbounded transcript copy after the LLM summarizer failed. _FALLBACK_SUMMARY_MAX_CHARS = 8_000 _FALLBACK_TURN_MAX_CHARS = 700 +_AUTO_FOCUS_MAX_TURNS = 3 +_AUTO_FOCUS_TURN_MAX_CHARS = 260 +_AUTO_FOCUS_MAX_CHARS = 700 +# Keep a short run of recent messages verbatim even when the token budget is +# already exhausted. The public ``protect_last_n`` default is intentionally +# high for small/light tails, but using all 20 as a hard floor here would bring +# back the old large-tool-output case where nothing can be compacted. +_MAX_TAIL_MESSAGE_FLOOR = 8 _PATH_MENTION_RE = re.compile(r"(?:/|~/?|[A-Za-z]:\\)[^\s`'\")\]}<>]+") +# MEDIA delivery directives must not reach the summarizer — if one leaks into +# the summary, the downstream model may re-emit it as an active directive on +# the next turn, triggering bogus attachment sends (#14665). +_MEDIA_DIRECTIVE_RE = re.compile(r"MEDIA:\S+") + def _dedupe_append(items: list[str], value: str, *, limit: int) -> None: value = value.strip() @@ -1007,6 +1045,7 @@ class ContextCompressor(ContextEngine): for msg in turns: role = msg.get("role", "unknown") content = redact_sensitive_text(msg.get("content") or "") + content = _MEDIA_DIRECTIVE_RE.sub("[media attachment]", content) # Tool results: keep enough content for the summarizer if role == "tool": @@ -1454,7 +1493,7 @@ Use this exact structure: prompt += f""" FOCUS TOPIC: "{focus_topic}" -The user has requested that this compaction PRIORITISE preserving all information related to the focus topic above. For content related to "{focus_topic}", include full detail — exact values, file paths, command outputs, error messages, and decisions. For content NOT related to the focus topic, summarise more aggressively (brief one-liners or omit if truly irrelevant). The focus topic sections should receive roughly 60-70% of the summary token budget. Even for the focus topic, NEVER preserve API keys, tokens, passwords, or credentials — use [REDACTED].""" +This compaction should PRIORITISE preserving all information related to the focus topic above. For content related to "{focus_topic}", include full detail — exact values, file paths, command outputs, error messages, and decisions. For content NOT related to the focus topic, summarise more aggressively (brief one-liners or omit if truly irrelevant). The focus topic sections should receive roughly 60-70% of the summary token budget. Even for the focus topic, NEVER preserve API keys, tokens, passwords, or credentials — use [REDACTED].""" try: call_kwargs = { @@ -1607,7 +1646,13 @@ The user has requested that this compaction PRIORITISE preserving all informatio text = (summary or "").strip() for prefix in (SUMMARY_PREFIX, LEGACY_SUMMARY_PREFIX, *_HISTORICAL_SUMMARY_PREFIXES): if text.startswith(prefix): - return text[len(prefix):].lstrip() + text = text[len(prefix):].lstrip() + break + # Strip the trailing end marker too — a rehydrated handoff body that + # keeps it would leak the boundary directive into the iterative-update + # summarizer prompt (and the marker is re-appended on insertion anyway). + if text.endswith(_SUMMARY_END_MARKER): + text = text[: -len(_SUMMARY_END_MARKER)].rstrip() return text @classmethod @@ -1623,6 +1668,52 @@ The user has requested that this compaction PRIORITISE preserving all informatio return True return any(text.startswith(p) for p in _HISTORICAL_SUMMARY_PREFIXES) + @staticmethod + def _has_compressed_summary_metadata(message: Any) -> bool: + """Return True if *message* carries the compressed-summary flag. + + Callers (frontends, CLI, gateway) can use this to distinguish context + compaction summaries from real assistant or user messages without + relying on content-prefix heuristics. The flag is in-process only — + the wire sanitizers strip underscore-prefixed keys before API calls. + """ + if not isinstance(message, dict): + return False + return bool(message.get(COMPRESSED_SUMMARY_METADATA_KEY)) + + @classmethod + def _derive_auto_focus_topic( + cls, + messages: List[Dict[str, Any]], + ) -> Optional[str]: + """Infer a compact focus hint from the most recent real user turns.""" + candidates: list[str] = [] + for idx in range(len(messages) - 1, -1, -1): + msg = messages[idx] + if msg.get("role") != "user": + continue + content = msg.get("content") + if cls._is_context_summary_content(content): + continue + text = redact_sensitive_text(_content_text_for_contains(content).strip()) + if not text: + continue + text = " ".join(text.split()) + if len(text) > _AUTO_FOCUS_TURN_MAX_CHARS: + text = text[: _AUTO_FOCUS_TURN_MAX_CHARS - 1].rstrip() + "…" + candidates.append(text) + if len(candidates) >= _AUTO_FOCUS_MAX_TURNS: + break + + if not candidates: + return None + + candidates.reverse() + focus = "Recent user focus:\n" + "\n".join(f"- {item}" for item in candidates) + if len(focus) > _AUTO_FOCUS_MAX_CHARS: + focus = focus[: _AUTO_FOCUS_MAX_CHARS - 1].rstrip() + "…" + return focus + @classmethod def _find_latest_context_summary( cls, @@ -1775,6 +1866,105 @@ The user has requested that this compaction PRIORITISE preserving all informatio return i return -1 + def _find_last_assistant_message_idx( + self, messages: List[Dict[str, Any]], head_end: int + ) -> int: + """Return the index of the last user-visible assistant reply at or + after *head_end*, or -1. + + A "user-visible reply" is an assistant message with non-empty + textual content — i.e. one that the WebUI / TUI / SessionsPage + rendered as a bubble the operator could read. We deliberately + skip assistant messages that contain only ``tool_calls`` (and + no text), because those render as small "calling tool X" + indicators and aren't what the reporter means by "the output + of the last message you sent" (#29824). + + Falling back to the most recent assistant message of ANY kind + only kicks in when no content-bearing assistant message exists + in the compressible region — typically a fresh session that + just started a multi-step tool sequence with no prior reply + to anchor. In that case the agent fix is a no-op and the + existing user-message anchor carries the load. + """ + last_any = -1 + for i in range(len(messages) - 1, head_end - 1, -1): + msg = messages[i] + if msg.get("role") != "assistant": + continue + if last_any < 0: + last_any = i + content = msg.get("content") + if isinstance(content, str) and content.strip(): + return i + if isinstance(content, list): + # Multimodal / Anthropic-style content: look for any + # text block with non-empty text. + for part in content: + if isinstance(part, dict): + text = part.get("text") or part.get("content") + if isinstance(text, str) and text.strip(): + return i + return last_any + + def _ensure_last_assistant_message_in_tail( + self, + messages: List[Dict[str, Any]], + cut_idx: int, + head_end: int, + ) -> int: + """Guarantee the most recent assistant message is in the protected tail. + + WebUI / TUI / SessionsPage bug (#29824). Without this anchor, + ``_find_tail_cut_by_tokens`` can leave the user's most recent + visible assistant response inside the compressed middle region — + especially when the conversation has a single oversized tool + result or a long stretch of tool-call/result pairs after the + last assistant reply. The summariser then rolls that reply up + into the single ``[CONTEXT COMPACTION — REFERENCE ONLY]`` block + persisted as ``role="user"`` or ``role="assistant"``. From the + operator's perspective the WebUI session viewer + (``web/src/pages/SessionsPage.tsx``) and the TUI chat panel + both suddenly show the opaque "Context compaction" block in the + slot where they were just reading the assistant's actual reply: + + User: "i cant see the output of the last message you + sent, i did see it previously, however now see + 'context compaction'" + + Mirror of ``_ensure_last_user_message_in_tail`` but anchors on + the last assistant-role message. Re-runs the tool-group + alignment so we don't split a ``tool_call`` / ``tool_result`` + group that immediately precedes the anchored message — orphaned + tool messages would otherwise be removed by + ``_sanitize_tool_pairs`` and trigger the same data-loss symptom + we're trying to prevent. + """ + last_asst_idx = self._find_last_assistant_message_idx(messages, head_end) + if last_asst_idx < 0: + # No assistant message in the compressible region — nothing + # to anchor (single-turn pre-reply state, etc.). + return cut_idx + if last_asst_idx >= cut_idx: + # Already in the tail — the token-budget walk did the right + # thing on its own. + return cut_idx + # Pull cut_idx back to the assistant message, then re-align so + # we don't split a tool group that immediately precedes it + # (e.g. an ``assistant(tool_calls)`` → ``tool(result)`` → + # ``assistant(final reply)`` sequence would otherwise leave the + # ``tool`` orphan when cut lands at the final reply). + new_cut = self._align_boundary_backward(messages, last_asst_idx) + if not self.quiet_mode: + logger.debug( + "Anchoring tail cut to last assistant message at index %d " + "(was %d, aligned to %d) to keep the previously-visible " + "reply out of the compaction summary (#29824)", + last_asst_idx, cut_idx, new_cut, + ) + # Safety: never go back into the head region. + return max(new_cut, head_end + 1) + def _ensure_last_user_message_in_tail( self, messages: List[Dict[str, Any]], @@ -1833,11 +2023,12 @@ The user has requested that this compaction PRIORITISE preserving all informatio derived from ``summary_target_ratio * context_length``, so it scales automatically with the model's context window. - Token budget is the primary criterion. A hard minimum of 3 messages - is always protected, but the budget is allowed to exceed by up to - 1.5x to avoid cutting inside an oversized message (tool output, file - read, etc.). If even the minimum 3 messages exceed 1.5x the budget - the cut is placed right after the head so compression still runs. + Token budget is the primary criterion. A bounded message-count floor + keeps a short run of recent turns verbatim even when the budget is + exhausted, but the budget is allowed to exceed by up to 1.5x to avoid + cutting inside an oversized message (tool output, file read, etc.). If + even that floor exceeds 1.5x the budget, the cut is placed right after + the head so compression still runs. Never cuts inside a tool_call/result group. Always ensures the most recent user message is in the tail (see ``_ensure_last_user_message_in_tail``). @@ -1845,8 +2036,19 @@ The user has requested that this compaction PRIORITISE preserving all informatio if token_budget is None: token_budget = self.tail_token_budget n = len(messages) - # Hard minimum: always keep at least 3 messages in the tail - min_tail = min(3, n - head_end - 1) if n - head_end > 1 else 0 + # Hard minimum: always keep a bounded recent-message floor in the tail. + # ``protect_last_n`` remains a minimum up to the cap; the cap avoids + # preserving a whole run of bulky tool outputs on every compaction. + available_tail = max(0, n - head_end - 1) + min_tail_floor = max(3, min(self.protect_last_n, _MAX_TAIL_MESSAGE_FLOOR)) + # Leave at least two non-head messages available to summarize on short + # transcripts; otherwise compression can replace a tiny middle with a + # summary and save no messages at all. + compressible_tail_cap = max(3, available_tail - 2) + min_tail = ( + min(min_tail_floor, compressible_tail_cap, available_tail) + if available_tail > 1 else 0 + ) soft_ceiling = int(token_budget * 1.5) accumulated = 0 cut_idx = n # start from beyond the end @@ -1918,6 +2120,13 @@ The user has requested that this compaction PRIORITISE preserving all informatio # active task is never lost to compression (fixes #10896). cut_idx = self._ensure_last_user_message_in_tail(messages, cut_idx, head_end) + # Ensure the most recent assistant message is always in the tail + # so the previously-visible reply isn't silently rolled into the + # ``[CONTEXT COMPACTION — REFERENCE ONLY]`` block (fixes #29824). + # Each anchor only walks ``cut_idx`` backward, so chaining them is + # monotonic — the tail can only grow, never shrink. + cut_idx = self._ensure_last_assistant_message_in_tail(messages, cut_idx, head_end) + return max(cut_idx, head_end + 1) # ------------------------------------------------------------------ @@ -2070,7 +2279,8 @@ The user has requested that this compaction PRIORITISE preserving all informatio ) # Phase 3: Generate structured summary - summary = self._generate_summary(turns_to_summarize, focus_topic=focus_topic) + summary_focus_topic = focus_topic or self._derive_auto_focus_topic(messages) + summary = self._generate_summary(turns_to_summarize, focus_topic=summary_focus_topic) # If summary generation failed, behavior splits on # ``abort_on_summary_failure`` (config: compression.abort_on_summary_failure): @@ -2150,32 +2360,33 @@ The user has requested that this compaction PRIORITISE preserving all informatio # When the summary lands as a standalone role="user" message, # weak models read the verbatim "## Active Task" quote of a past - # user request as fresh input (#11475, #14521). Append the explicit - # end marker — the same one used in the merge-into-tail path — so - # the model has a clear "summary above, not new input" signal. - if not _merge_summary_into_tail and summary_role == "user": - summary = ( - summary - + "\n\n--- END OF CONTEXT SUMMARY — " - "respond to the message below, not the summary above ---" - ) + # user request as fresh input (#11475, #14521). + # When it lands as role="assistant", models may regurgitate the + # summary text as their own output (#33256). In both cases, append + # the explicit end marker so the model has a clear "summary ends + # here, respond to the message below" signal. + if not _merge_summary_into_tail: + summary = summary + "\n\n" + _SUMMARY_END_MARKER if not _merge_summary_into_tail: - compressed.append({"role": summary_role, "content": summary}) + compressed.append({ + "role": summary_role, + "content": summary, + COMPRESSED_SUMMARY_METADATA_KEY: True, + }) for i in range(compress_end, n_messages): msg = messages[i].copy() if _merge_summary_into_tail and i == compress_end: - merged_prefix = ( - summary - + "\n\n--- END OF CONTEXT SUMMARY — " - "respond to the message below, not the summary above ---\n\n" - ) + merged_prefix = summary + "\n\n" + _SUMMARY_END_MARKER + "\n\n" msg["content"] = _append_text_to_content( msg.get("content"), merged_prefix, prepend=True, ) + # Mark the merged message so frontends can identify it as + # containing a compression summary prefix. + msg[COMPRESSED_SUMMARY_METADATA_KEY] = True _merge_summary_into_tail = False compressed.append(msg) diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index 913c0e25d91..d5469a1b344 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -40,6 +40,16 @@ from agent.model_metadata import estimate_request_tokens_rough logger = logging.getLogger(__name__) +# Stable marker the gateway matches on to re-tag the auto-compaction lifecycle +# status as ``kind="compacting"`` (tui_gateway/server.py::_status_update), so +# drivers like the desktop app can show an explicit "Summarizing…" indicator +# instead of the transcript appearing to silently reset. Keep the marker phrase +# intact if you reword COMPACTION_STATUS. +COMPACTION_STATUS_MARKER = "Compacting context" +COMPACTION_STATUS = ( + f"🗜️ {COMPACTION_STATUS_MARKER} — summarizing earlier conversation so I can continue..." +) + def _compression_lock_holder(agent: Any) -> str: """Build a unique holder id for the lock: pid:tid:agent-instance:uuid. @@ -324,9 +334,7 @@ def compress_context( f"{approx_tokens:,}" if approx_tokens else "unknown", agent.model, focus_topic, ) - agent._emit_status( - "🗜️ Compacting context — summarizing earlier conversation so I can continue..." - ) + agent._emit_status(COMPACTION_STATUS) # ── Compression lock ──────────────────────────────────────────────── # Atomic, state.db-backed lock per session_id. Without this, two @@ -631,7 +639,11 @@ def compress_context( return compressed, new_system_prompt -def try_shrink_image_parts_in_messages(api_messages: list) -> bool: +def try_shrink_image_parts_in_messages( + api_messages: list, + *, + max_dimension: int = 8000, +) -> bool: """Re-encode all native image parts at a smaller size to recover from image-too-large errors (Anthropic 5 MB, unknown other providers). @@ -642,7 +654,8 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool: Strategy: look for ``image_url`` / ``input_image`` parts carrying a ``data:image/...;base64,...`` payload. For each one whose encoded size exceeds 4 MB (a safe target that slides under Anthropic's 5 MB - ceiling with header overhead), write the base64 to a tempfile, call + ceiling with header overhead) or whose longest side exceeds + ``max_dimension``, write the base64 to a tempfile, call ``vision_tools._resize_image_for_vision`` to produce a smaller data URL, and substitute it in place. @@ -664,10 +677,9 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool: # after a confirmed provider rejection, so the alternative is failure. target_bytes = 4 * 1024 * 1024 # Anthropic enforces an 8000px per-side dimension cap independently of - # the 5 MB byte cap. A tall screenshot can be well under 5 MB yet far - # over 8000px (e.g. 1200×12000 at 0.06 MB). We check pixel dimensions - # even when the byte budget is fine. - max_dimension = 8000 + # the 5 MB byte cap. In many-image requests, the provider can report a + # lower cap (observed: 2000px). The caller passes that parsed ceiling + # when the rejection includes it. changed_count = 0 # Track parts that are over the target but could NOT be shrunk under it. # If any survive, retrying is pointless — the same oversized payload will @@ -684,9 +696,9 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool: # Check both byte size AND pixel dimensions. needs_shrink = len(url) > target_bytes # over byte budget if not needs_shrink: - # Even if bytes are fine, check pixel dimensions against - # Anthropic's 8000px cap. A tall image can be tiny in bytes - # yet huge in pixels. + # Even if bytes are fine, check pixel dimensions against the + # provider's reported per-side cap. A screenshot can be tiny in + # bytes yet too large in pixels. try: import base64 as _b64_dim header_d, _, data_d = url.partition(",") @@ -795,6 +807,8 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool: __all__ = [ + "COMPACTION_STATUS", + "COMPACTION_STATUS_MARKER", "check_compression_model_feasibility", "replay_compression_warning", "compress_context", diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index ec74a76b51d..54dd866aa8e 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -75,6 +75,35 @@ logger = logging.getLogger(__name__) INTERRUPT_WAITING_FOR_MODEL_PREFIX = "Operation interrupted: waiting for model response (" +def _image_error_max_dimension(error: Exception) -> Optional[int]: + """Extract a provider-reported image dimension ceiling, if present.""" + parts = [] + for value in ( + error, + getattr(error, "message", None), + getattr(error, "body", None), + ): + if value: + try: + parts.append(str(value)) + except Exception: + pass + text = " ".join(parts).lower() + if "image" not in text or "dimension" not in text or "max allowed size" not in text: + return None + + match = re.search(r"max allowed size(?:\s+for [^:]+)?:\s*(\d{3,5})\s*pixels?", text) + if not match: + return None + try: + max_dimension = int(match.group(1)) + except ValueError: + return None + if 512 <= max_dimension <= 8000: + return max_dimension + return None + + def _ollama_context_limit_error(agent: Any, request_tokens: int) -> Optional[str]: """Return a user-facing error when Ollama is loaded with too little context.""" if not getattr(agent, "tools", None): @@ -372,6 +401,42 @@ def _get_continuation_prompt(is_partial_stub: bool, dropped_tools: Optional[List ) +# Shared recovery hint appended to every content-policy refusal message. Both +# the HTTP-200 refusal path (``finish_reason=content_filter``) and the +# exception path (a provider moderation error classified as +# ``content_policy_blocked``) end with the same actionable next steps, so they +# share one trailer to keep the guidance from drifting between the two sites. +_CONTENT_POLICY_RECOVERY_HINT = ( + "Try rephrasing the request, narrowing the context, or " + "adding a fallback provider with `hermes fallback add`." +) + + +def _content_policy_blocked_result( + messages: List[Dict], + api_call_count: int, + *, + final_response: str, + error_detail: str, +) -> Dict[str, Any]: + """Build the terminal turn result for a content-policy block. + + A content-policy refusal is deterministic for the unchanged prompt, so the + turn ends here (no retry). Both the HTTP-200 refusal handler and the + exception-path handler return the identical shape — a failed, non-completed + turn carrying the user-facing message and a ``content_policy_blocked:`` + prefixed error — so they funnel through this one builder. + """ + return { + "final_response": final_response, + "messages": messages, + "api_calls": api_call_count, + "completed": False, + "failed": True, + "error": f"content_policy_blocked: {error_detail}", + } + + def run_conversation( agent, user_message: str, @@ -599,7 +664,11 @@ def run_conversation( # landed after an orphan tool result). Most providers return # empty content on malformed sequences, which would otherwise # retrigger the empty-retry loop indefinitely. - repaired_seq = agent._repair_message_sequence(messages) + # repair_message_sequence_with_cursor also recomputes the SessionDB + # flush cursor (_last_flushed_db_idx) when repair compacts the list, + # so the turn-end flush doesn't skip the assistant/tool chain (#44837). + from agent.agent_runtime_helpers import repair_message_sequence_with_cursor + repaired_seq = repair_message_sequence_with_cursor(agent, messages) if repaired_seq > 0: request_logger.info( "Repaired %s message-alternation violations before request (session=%s)", @@ -707,7 +776,10 @@ def run_conversation( # a thinking-only turn. Runs on the per-call copy only — the # stored conversation history keeps the reasoning block for the # UI transcript and session persistence. - api_messages = agent._drop_thinking_only_and_merge_users(api_messages) + api_messages = agent._drop_thinking_only_and_merge_users( + api_messages, + drop_codex_reasoning_items=agent.api_mode != "codex_responses", + ) # Normalize message whitespace and tool-call JSON for consistent # prefix matching. Ensures bit-perfect prefixes across turns, @@ -1316,6 +1388,106 @@ def run_conversation( ) finish_reason = "length" + # ── Content-policy refusal (HTTP 200) ────────────────── + # The model — or the provider's safety system — returned a + # *successful* response whose stop/finish reason is a refusal: + # Anthropic ``stop_reason="refusal"`` → ``content_filter``; + # OpenAI / portal ``finish_reason="content_filter"`` or a + # populated ``message.refusal`` (mapped in the chat_completions + # transport); Bedrock ``guardrail_intervened``. The content is + # typically empty, so without this branch the response falls + # through to the empty-response / invalid-response retry loops + # and is mis-surfaced as "rate limited" / "no content after + # retries" — burning paid attempts reproducing a deterministic + # refusal. Surface it clearly and stop. Mirrors the + # exception-based ``content_policy_blocked`` recovery: try a + # configured fallback once, otherwise return the refusal. + if finish_reason == "content_filter": + _refusal_transport = agent._get_transport() + if agent.api_mode == "anthropic_messages": + _refusal_result = _refusal_transport.normalize_response( + response, strip_tool_prefix=agent._is_anthropic_oauth + ) + else: + _refusal_result = _refusal_transport.normalize_response(response) + _refusal_text = (getattr(_refusal_result, "content", None) or "").strip() + # Some refusals carry the explanation only in the reasoning + # channel; fall back to it so the user sees *something*. + if not _refusal_text: + _refusal_text = (agent._extract_reasoning(_refusal_result) or "").strip() + + agent._invoke_api_request_error_hook( + task_id=effective_task_id, + turn_id=turn_id, + api_request_id=api_request_id, + api_call_count=api_call_count, + api_start_time=api_start_time, + api_kwargs=api_kwargs, + error_type="ContentPolicyBlocked", + error_message=_refusal_text or "model declined to respond (content_filter)", + status_code=None, + retry_count=retry_count, + max_retries=max_retries, + retryable=False, + reason=FailoverReason.content_policy_blocked.value, + ) + + if thinking_spinner: + thinking_spinner.stop("") + thinking_spinner = None + if agent.thinking_callback: + agent.thinking_callback("") + + # Deterministic for the unchanged prompt — never retry. + # Try a configured fallback once (a different model may not + # refuse); otherwise surface the refusal terminally. + if agent._has_pending_fallback(): + agent._buffer_status( + "⚠️ Model declined to respond (safety refusal) — trying fallback..." + ) + if agent._try_activate_fallback(): + retry_count = 0 + compression_attempts = 0 + _retry.primary_recovery_attempted = False + continue + + agent._flush_status_buffer() + _refusal_log = ( + _refusal_text[:500] + "..." + if len(_refusal_text) > 500 + else _refusal_text + ) + logger.warning( + "%sModel declined to respond (finish_reason=content_filter). " + "model=%s provider=%s refusal=%s", + agent.log_prefix, agent.model, agent.provider, + _refusal_log or "(no text)", + ) + agent._emit_status( + "⚠️ The model declined to respond to this request (safety refusal)." + ) + + _refusal_detail = ( + f"Model's explanation: {_refusal_text}" + if _refusal_text + else "The model returned no explanation." + ) + _refusal_response = ( + "⚠️ The model declined to respond to this request " + "(safety refusal — not a Hermes/gateway failure).\n\n" + f"{_refusal_detail}\n\n" + f"{_CONTENT_POLICY_RECOVERY_HINT}" + ) + + agent._cleanup_task_resources(effective_task_id) + agent._persist_session(messages, conversation_history) + return _content_policy_blocked_result( + messages, + api_call_count, + final_response=_refusal_response, + error_detail=_refusal_text or "model declined (content_filter)", + ) + if finish_reason == "length": if getattr(response, "id", "") == PARTIAL_STREAM_STUB_ID: agent._vprint( @@ -2104,7 +2276,11 @@ def run_conversation( and not _retry.image_shrink_retry_attempted ): _retry.image_shrink_retry_attempted = True - if agent._try_shrink_image_parts_in_messages(api_messages): + image_max_dimension = _image_error_max_dimension(api_error) or 8000 + if agent._try_shrink_image_parts_in_messages( + api_messages, + max_dimension=image_max_dimension, + ): agent._vprint( f"{agent.log_prefix}📐 Image(s) exceeded provider size limit — " f"shrank and retrying...", @@ -2672,10 +2848,13 @@ def run_conversation( except Exception: pass if _genuine_nous_rate_limit: - # Skip straight to max_retries -- the - # top-of-loop guard will handle fallback or - # bail cleanly. - retry_count = max_retries + # Re-enter the loop exactly once so the + # top-of-loop Nous guard handles fallback or + # bails cleanly. (Setting retry_count to + # max_retries would make the while condition + # false immediately and the guard would never + # run -- no fallback, generic exhaustion error.) + retry_count = max(0, max_retries - 1) continue # Upstream capacity 429: fall through to normal # retry logic. A different model (or the same @@ -3117,20 +3296,17 @@ def run_conversation( if classified.reason == FailoverReason.content_policy_blocked: _summary = agent._summarize_api_error(api_error) _policy_response = ( - f"⚠️ The model provider's safety filter blocked this request " - f"(not a Hermes/gateway failure).\n\n" + "⚠️ The model provider's safety filter blocked this request " + "(not a Hermes/gateway failure).\n\n" f"Provider message: {_summary}\n\n" - f"Try rephrasing the request, narrowing the context, or " - f"adding a fallback provider with `hermes fallback add`." + f"{_CONTENT_POLICY_RECOVERY_HINT}" + ) + return _content_policy_blocked_result( + messages, + api_call_count, + final_response=_policy_response, + error_detail=_summary, ) - return { - "final_response": _policy_response, - "messages": messages, - "api_calls": api_call_count, - "completed": False, - "failed": True, - "error": f"content_policy_blocked: {_summary}", - } return { "final_response": None, "messages": messages, diff --git a/agent/copilot_acp_client.py b/agent/copilot_acp_client.py index b24ddbef5da..e3c03938af4 100644 --- a/agent/copilot_acp_client.py +++ b/agent/copilot_acp_client.py @@ -70,16 +70,6 @@ def _resolve_args() -> list[str]: def _resolve_home_dir() -> str: """Return a stable HOME for child ACP processes.""" - - try: - from hermes_constants import get_subprocess_home - - profile_home = get_subprocess_home() - if profile_home: - return profile_home - except Exception: - pass - home = os.environ.get("HOME", "").strip() if home: return home @@ -105,7 +95,10 @@ def _resolve_home_dir() -> str: def _build_subprocess_env() -> dict[str, str]: env = os.environ.copy() - env["HOME"] = _resolve_home_dir() + home = _resolve_home_dir() + env["HOME"] = home + from hermes_constants import apply_subprocess_home_env + apply_subprocess_home_env(env) return env diff --git a/agent/credits_tracker.py b/agent/credits_tracker.py index f84bc9a7c0e..19e5b1582da 100644 --- a/agent/credits_tracker.py +++ b/agent/credits_tracker.py @@ -286,6 +286,16 @@ def evaluate_credits_notices( for band in CREDITS_USAGE_BANDS: # ascending → last match wins = highest if uf >= band[0]: current_band = band + # Top-up suppression: when the account holds purchased (top-up) credits, + # the subscription-cap gauge is the wrong denominator — warning "90% used" + # at a user sitting on $50 of top-up is noise (and it previously stuck + # PERMANENTLY alongside grant_spent at >=100%). Suppress the usage band + # entirely; the cap-reached case is covered by the grant_spent info notice + # below, which already names the remaining top-up balance. A top-up landing + # mid-session flips current_band → None and the clear path below removes + # any showing band line. + if state.purchased_micros > 0: + current_band = None grant_cond = ( state.denominator_kind == "subscription_cap" and uf is not None @@ -345,7 +355,7 @@ def evaluate_credits_notices( if show_depleted and "credits.depleted" not in active: to_show.append( AgentNotice( - text="✕ Credit access paused · run /usage for balance", + text="✕ Credit access paused · run /credits to top up", level="error", kind=CREDITS_NOTICE_KIND, key="credits.depleted", diff --git a/agent/display.py b/agent/display.py index 84c8509faed..8514279888e 100644 --- a/agent/display.py +++ b/agent/display.py @@ -858,20 +858,6 @@ def _detect_tool_failure(tool_name: str, result: str | None) -> tuple[bool, str] return False, "" -def _used_free_parallel(result: str | None) -> bool: - """True when a web result came from Parallel's free Search MCP. - - Only the keyless Parallel path tags its result with ``provider="parallel"``; - the paid REST path and every other provider omit it. Used to label the tool - line "Parallel search" / "Parallel fetch" exactly when the free MCP served - the call. - """ - if not isinstance(result, str) or '"provider"' not in result: - return False - data = safe_json_loads(result) - return isinstance(data, dict) and str(data.get("provider", "")).lower() == "parallel" - - def get_cute_tool_message( tool_name: str, args: dict, duration: float, result: str | None = None, ) -> str: @@ -909,17 +895,15 @@ def get_cute_tool_message( return f"{line}{failure_suffix}" if tool_name == "web_search": - verb = "Parallel search" if _used_free_parallel(result) else "search" - return _wrap(f"┊ 🔍 {verb:<9} {_trunc(args.get('query', ''), 42)} {dur}") + return _wrap(f"┊ 🔍 search {_trunc(args.get('query', ''), 42)} {dur}") if tool_name == "web_extract": - verb = "Parallel fetch" if _used_free_parallel(result) else "fetch" urls = args.get("urls", []) if urls: url = urls[0] if isinstance(urls, list) else str(urls) domain = url.replace("https://", "").replace("http://", "").split("/")[0] extra = f" +{len(urls)-1}" if len(urls) > 1 else "" - return _wrap(f"┊ 📄 {verb:<9} {_trunc(domain, 35)}{extra} {dur}") - return _wrap(f"┊ 📄 {verb:<9} pages {dur}") + return _wrap(f"┊ 📄 fetch {_trunc(domain, 35)}{extra} {dur}") + return _wrap(f"┊ 📄 fetch pages {dur}") if tool_name == "terminal": return _wrap(f"┊ 💻 $ {_trunc(args.get('command', ''), 42)} {dur}") if tool_name == "process": diff --git a/agent/errors.py b/agent/errors.py new file mode 100644 index 00000000000..abedd83d29f --- /dev/null +++ b/agent/errors.py @@ -0,0 +1,3 @@ +class SSLConfigurationError(Exception): + """Raised when SSL/TLS certificate bundle configuration fails.""" + pass diff --git a/agent/file_safety.py b/agent/file_safety.py index e9fa487e834..7a70f964125 100644 --- a/agent/file_safety.py +++ b/agent/file_safety.py @@ -46,11 +46,6 @@ def build_write_denied_paths(home: str) -> set[str]: # Top-level Anthropic PKCE credential store remains sensitive even # when a profile is active; default/non-profile sessions still read it. str(hermes_root / ".anthropic_oauth.json"), - os.path.join(home, ".bashrc"), - os.path.join(home, ".zshrc"), - os.path.join(home, ".profile"), - os.path.join(home, ".bash_profile"), - os.path.join(home, ".zprofile"), os.path.join(home, ".netrc"), os.path.join(home, ".pgpass"), os.path.join(home, ".npmrc"), @@ -104,12 +99,6 @@ def is_write_denied(path: str) -> bool: if resolved.startswith(prefix): return True - # Hermes control-plane files: block both the ACTIVE profile's view - # (hermes_home) AND the global root view. Without the root pass, a - # profile-mode session leaves /auth.json + /config.yaml - # writable — letting a prompt-injected write_file overwrite the global - # files that every profile inherits from (same shape as #15981). - control_file_names = ("auth.json", "config.yaml", "webhook_subscriptions.json") mcp_tokens_dir_name = "mcp-tokens" hermes_dirs = [] @@ -122,12 +111,6 @@ def is_write_denied(path: str) -> bool: continue for base_real in hermes_dirs: - for name in control_file_names: - try: - if resolved == os.path.realpath(os.path.join(base_real, name)): - return True - except Exception: - continue try: mcp_real = os.path.realpath(os.path.join(base_real, mcp_tokens_dir_name)) if resolved == mcp_real or resolved.startswith(mcp_real + os.sep): diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index a0f8e9df548..a79effebba4 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -41,6 +41,16 @@ DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta" GEMINI_DEFAULT_MAX_OUTPUT_TOKENS = 65535 +def bare_gemini_model_id(model: str) -> str: + """Strip Gemini's own provider prefix from an aggregator-style model id.""" + name = (model or "").strip() + lowered = name.lower() + for prefix in ("google/", "gemini/"): + if lowered.startswith(prefix): + return name[len(prefix):].strip() or name + return name + + def is_native_gemini_base_url(base_url: str) -> bool: """Return True when the endpoint speaks Gemini's native REST API.""" normalized = str(base_url or "").strip().rstrip("/").lower() @@ -330,7 +340,7 @@ def _build_gemini_contents(messages: List[Dict[str, Any]]) -> tuple[List[Dict[st system_instruction = None joined_system = "\n".join(part for part in system_text_parts if part).strip() if joined_system: - system_instruction = {"parts": [{"text": joined_system}]} + system_instruction = {"role": "system", "parts": [{"text": joined_system}]} return contents, system_instruction @@ -914,6 +924,7 @@ class GeminiNativeClient: thinking_config=thinking_config, ) + model = bare_gemini_model_id(model) if stream: return self._stream_completion(model=model, request=request, timeout=timeout) diff --git a/agent/memory_manager.py b/agent/memory_manager.py index 3cb3a734a8f..240595a4eb3 100644 --- a/agent/memory_manager.py +++ b/agent/memory_manager.py @@ -44,6 +44,66 @@ logger = logging.getLogger(__name__) _SYNC_DRAIN_TIMEOUT_S = 5.0 +def memory_provider_tools_enabled(enabled_toolsets: Optional[List[str]]) -> bool: + """Return whether external memory-provider tools should be exposed.""" + if enabled_toolsets is None: + return True + if not enabled_toolsets: + return False + if "memory" in enabled_toolsets: + return True + + try: + from toolsets import resolve_toolset + + return any("memory" in resolve_toolset(name) for name in enabled_toolsets) + except Exception: + logger.debug("Failed to resolve enabled toolsets for memory-provider tools", exc_info=True) + return False + + +def inject_memory_provider_tools(agent: Any) -> int: + """Append external memory-provider tool schemas to an agent tool surface.""" + memory_manager = getattr(agent, "_memory_manager", None) + tools = getattr(agent, "tools", None) + if not memory_manager or tools is None: + return 0 + + existing_tool_names = { + tool.get("function", {}).get("name") + for tool in tools + if isinstance(tool, dict) + } + if ( + "memory" not in existing_tool_names + and not memory_provider_tools_enabled(getattr(agent, "enabled_toolsets", None)) + ): + return 0 + + get_schemas = getattr(memory_manager, "get_all_tool_schemas", None) + if not callable(get_schemas): + return 0 + + valid_tool_names = getattr(agent, "valid_tool_names", None) + if valid_tool_names is None: + valid_tool_names = set() + agent.valid_tool_names = valid_tool_names + + added = 0 + for schema in get_schemas(): + if not isinstance(schema, dict): + continue + tool_name = schema.get("name", "") + if not tool_name or tool_name in existing_tool_names: + continue + tools.append({"type": "function", "function": schema}) + valid_tool_names.add(tool_name) + existing_tool_names.add(tool_name) + added += 1 + + return added + + # --------------------------------------------------------------------------- # Context fencing helpers # --------------------------------------------------------------------------- diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 3a71e974fdb..e31fcdea48d 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -5,6 +5,7 @@ and run_agent.py for pre-flight context checks. """ import ipaddress +import json import logging import os import re @@ -16,7 +17,7 @@ from urllib.parse import urlparse import requests import yaml -from utils import base_url_host_matches, base_url_hostname +from utils import atomic_json_write, base_url_host_matches, base_url_hostname from hermes_constants import OPENROUTER_MODELS_URL @@ -111,6 +112,57 @@ _endpoint_model_metadata_cache: Dict[str, Dict[str, Dict[str, Any]]] = {} _endpoint_model_metadata_cache_time: Dict[str, float] = {} _ENDPOINT_MODEL_CACHE_TTL = 300 + +def _get_model_metadata_cache_path() -> Path: + """Return path to the OpenRouter model metadata disk cache.""" + from hermes_constants import get_hermes_home + return get_hermes_home() / "cache" / "openrouter_model_metadata.json" + + +def _model_metadata_disk_cache_age_seconds() -> Optional[float]: + """Return disk-cache age in seconds, or None if freshness is unknown.""" + try: + cache_path = _get_model_metadata_cache_path() + if not cache_path.exists(): + return None + age = time.time() - cache_path.stat().st_mtime + if age < 0: + return None + return age + except Exception: + return None + + +def _load_model_metadata_disk_cache() -> Dict[str, Dict[str, Any]]: + """Load processed OpenRouter metadata cache from disk.""" + try: + cache_path = _get_model_metadata_cache_path() + with cache_path.open("r", encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, dict): + return {} + return { + str(key): value + for key, value in data.items() + if isinstance(value, dict) + } + except Exception as e: + logger.debug("Failed to load OpenRouter model metadata disk cache: %s", e) + return {} + + +def _save_model_metadata_disk_cache(data: Dict[str, Dict[str, Any]]) -> None: + """Save processed OpenRouter metadata cache to disk atomically.""" + try: + atomic_json_write( + _get_model_metadata_cache_path(), + data, + indent=0, + separators=(",", ":"), + ) + except Exception as e: + logger.debug("Failed to save OpenRouter model metadata disk cache: %s", e) + # Descending tiers for context length probing when the model is unknown. # We start at 256K (covers GPT-5.x, many current large-context models) and # step down on context-length errors until one works. Tier[0] is also the @@ -209,7 +261,13 @@ DEFAULT_CONTEXT_LENGTHS = { # https://platform.minimax.io/docs/api-reference/text-chat-openai "minimax-m3": 1000000, "minimax": 204800, - # GLM + # GLM — GLM-5.2 ships with a 1M context window (verified empirically: + # needle-in-a-haystack retrieval at 789K prompt tokens succeeded with + # zero errors on api.z.ai/api/coding/paas/v4). Older GLM models + # (5, 5.1, 5-turbo) are ~202K. Longest-key-first substring matching + # ensures "glm-5.2" resolves to 1M while older variants still hit the + # generic 202K fallback. + "glm-5.2": 1_048_576, "glm": 202752, # xAI Grok — xAI /v1/models does not return context_length metadata, # so these hardcoded fallbacks prevent Hermes from probing-down to @@ -627,6 +685,15 @@ def fetch_model_metadata(force_refresh: bool = False) -> Dict[str, Dict[str, Any if not force_refresh and _model_metadata_cache and (time.time() - _model_metadata_cache_time) < _MODEL_CACHE_TTL: return _model_metadata_cache + if not force_refresh: + disk_age = _model_metadata_disk_cache_age_seconds() + if disk_age is not None and disk_age < _MODEL_CACHE_TTL: + disk_cache = _load_model_metadata_disk_cache() + if disk_cache: + _model_metadata_cache = disk_cache + _model_metadata_cache_time = time.time() - disk_age + return _model_metadata_cache + try: response = requests.get(OPENROUTER_MODELS_URL, timeout=10, verify=_resolve_requests_verify()) response.raise_for_status() @@ -648,12 +715,24 @@ def fetch_model_metadata(force_refresh: bool = False) -> Dict[str, Dict[str, Any _model_metadata_cache = cache _model_metadata_cache_time = time.time() + _save_model_metadata_disk_cache(cache) logger.debug("Fetched metadata for %s models from OpenRouter", len(cache)) return cache except Exception as e: logger.warning(f"Failed to fetch model metadata from OpenRouter: {e}") - return _model_metadata_cache or {} + if _model_metadata_cache: + return _model_metadata_cache + disk_cache = _load_model_metadata_disk_cache() + if disk_cache: + _model_metadata_cache = disk_cache + disk_age = _model_metadata_disk_cache_age_seconds() + if disk_age is not None: + _model_metadata_cache_time = time.time() - min(disk_age, _MODEL_CACHE_TTL) + else: + _model_metadata_cache_time = time.time() - _MODEL_CACHE_TTL + 1 + return _model_metadata_cache + return {} def fetch_endpoint_model_metadata( diff --git a/agent/moonshot_schema.py b/agent/moonshot_schema.py index f22176f936e..206ccee1653 100644 --- a/agent/moonshot_schema.py +++ b/agent/moonshot_schema.py @@ -135,7 +135,14 @@ def _repair_schema(node: Any, is_schema: bool = True) -> Any: def _fill_missing_type(node: Dict[str, Any]) -> Dict[str, Any]: """Infer a reasonable ``type`` if this schema node has none.""" - if "type" in node and node["type"] not in {None, ""}: + node_type = node.get("type") + if isinstance(node_type, list): + concrete = next( + (t for t in node_type if isinstance(t, str) and t not in {"", "null"}), + "string", + ) + return {**node, "type": concrete} + if "type" in node and node_type not in {None, ""}: return node # Heuristic: presence of ``properties`` → object, ``items`` → array, ``enum`` diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index eeff4e6ce18..b11cade39bd 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -489,15 +489,41 @@ PLATFORM_HINTS = { "files arrive as downloadable documents. You can also include image " "URLs in markdown format ![alt](url) and they will be sent as photos." ), + "whatsapp_cloud": ( + "You are on a text messaging communication platform, WhatsApp " + "(via Meta's official Business Cloud API). Standard markdown " + "(**bold**, ~~strike~~, # headers, [links](url)) is auto-converted " + "to WhatsApp's native syntax (*bold*, ~strike~, etc.) — feel free " + "to write in markdown. Tables are NOT supported — prefer bullet " + "lists or labeled key:value pairs. " + "You can send media files natively: include MEDIA:/absolute/path/to/file " + "in your response. Images (.jpg, .png) become photo attachments, " + "videos (.mp4) play inline, audio (.mp3, .ogg) sends as voice/audio " + "messages, other files arrive as documents. Image URLs in markdown " + "format ![alt](url) also work. " + "IMPORTANT: this platform has a 24-hour conversation window — if the " + "user hasn't messaged in 24h, free-form replies are refused by Meta " + "(error 131047). This rarely matters for live chat, but is worth " + "knowing if you're scheduling a delayed message." + ), "telegram": ( "You are on a text messaging communication platform, Telegram. " - "Standard markdown is automatically converted to Telegram format. " + "Standard Markdown is automatically converted to Telegram formatting. " "Supported: **bold**, *italic*, ~~strikethrough~~, ||spoiler||, " "`inline code`, ```code blocks```, [links](url), and ## headers. " - "Telegram has NO table syntax — prefer bullet lists or labeled " - "key: value pairs over pipe tables (any tables you do emit are " - "auto-rewritten into row-group bullets, which you can produce " - "directly for cleaner output). " + "Telegram now supports rich Markdown, so lean into it: whenever it " + "makes the answer clearer or easier to scan, actively reach for real " + "Markdown tables (pipe `| col | col |` syntax), bullet and numbered " + "lists, task lists (`- [ ]` / `- [x]`), headings, nested blockquotes, " + "collapsible details, footnotes/references, math/formulas (`$...$`, " + "`$$...$$`), underline, subscript/superscript, marked (highlighted) " + "text, and anchors. Default to structured formatting over dense " + "paragraphs for any comparison, set of steps, key/value summary, or " + "tabular data. Prefer real Markdown tables and task lists over " + "hand-built bullet substitutes when presenting structured data; these " + "degrade gracefully (tables become readable bullet groups) when rich " + "rendering is unavailable, but advanced constructs like math and " + "collapsible details may render as plain source text in that case. " "You can send media files natively: to deliver a file to the user, " "include MEDIA:/absolute/path/to/file in your response. Images " "(.png, .jpg, .webp) appear as photos, audio (.ogg) sends as voice " @@ -1138,7 +1164,7 @@ def build_skills_system_prompt( or get_session_env("HERMES_SESSION_PLATFORM") or "" ) - disabled = get_disabled_skill_names() + disabled = get_disabled_skill_names(_platform_hint or None) cache_key = ( str(skills_dir.resolve()), tuple(str(d) for d in external_dirs), @@ -1418,13 +1444,13 @@ def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) - lines = [ "# Nous Subscription", - "Nous subscription includes managed web tools (Firecrawl), image generation (FAL), OpenAI TTS, and browser automation (Browser Use) by default. Modal execution is optional.", + "Nous subscription includes managed web tools (Firecrawl), image generation (FAL), OpenAI TTS, OpenAI Whisper STT, and browser automation (Browser Use) by default. Modal execution is optional.", "Current capability status:", ] lines.extend(_status_line(feature) for feature in features.items()) lines.extend( [ - "When a Nous-managed feature is active, do not ask the user for Firecrawl, FAL, OpenAI TTS, or Browser-Use API keys.", + "When a Nous-managed feature is active, do not ask the user for Firecrawl, FAL, OpenAI TTS, OpenAI Whisper, or Browser-Use API keys.", "If the user is not subscribed and asks for a capability that Nous subscription would unlock or simplify, suggest Nous subscription as one option alongside direct setup or local alternatives.", "Do not mention subscription unless the user asks about it or it directly solves the current missing capability.", "Useful commands: hermes setup, hermes setup tools, hermes setup terminal, hermes status.", diff --git a/agent/skill_utils.py b/agent/skill_utils.py index 62bcc5a2b4b..6f68d3041b5 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -272,27 +272,65 @@ def skill_matches_environment(frontmatter: Dict[str, Any]) -> bool: # ── Disabled skills ─────────────────────────────────────────────────────── +_RAW_CONFIG_CACHE: Dict[Tuple[str, int, int], Dict[str, Any]] = {} + + +def _raw_config_cache_clear() -> None: + """Test hook — drop the shared raw config cache.""" + _RAW_CONFIG_CACHE.clear() + + +def _load_raw_config() -> Dict[str, Any]: + """Read config.yaml with a shared mtime+size keyed cache. + + This module intentionally avoids importing ``hermes_cli.config`` on the + skill prompt/build path. A tiny local cache gives the same repeated-read + win without pulling the heavier CLI config stack into startup. + """ + config_path = get_config_path() + if not config_path.exists(): + return {} + try: + stat = config_path.stat() + cache_key = (str(config_path), stat.st_mtime_ns, stat.st_size) + except OSError: + cache_key = None + + if cache_key is not None: + cached = _RAW_CONFIG_CACHE.get(cache_key) + if cached is not None: + return cached + + try: + parsed = yaml_load(config_path.read_text(encoding="utf-8")) + except Exception as e: + logger.debug("Could not read skill config %s: %s", config_path, e) + return {} + if not isinstance(parsed, dict): + return {} + + if cache_key is not None: + _RAW_CONFIG_CACHE.clear() + _RAW_CONFIG_CACHE[cache_key] = parsed + return parsed + + def get_disabled_skill_names(platform: str | None = None) -> Set[str]: """Read disabled skill names from config.yaml. Args: platform: Explicit platform name (e.g. ``"telegram"``). When *None*, resolves from ``HERMES_PLATFORM`` or - ``HERMES_SESSION_PLATFORM`` env vars. Falls back to the - global disabled list when no platform is determined. + ``HERMES_SESSION_PLATFORM`` env vars. Returns the global + disabled list, unioned with the platform-specific list when a + platform is resolved (a globally-disabled skill stays disabled + on every platform). Reads the config file directly (no CLI config imports) to stay lightweight. """ - config_path = get_config_path() - if not config_path.exists(): - return set() - try: - parsed = yaml_load(config_path.read_text(encoding="utf-8")) - except Exception as e: - logger.debug("Could not read skill config %s: %s", config_path, e) - return set() - if not isinstance(parsed, dict): + parsed = _load_raw_config() + if not parsed: return set() skills_cfg = parsed.get("skills") @@ -305,13 +343,14 @@ def get_disabled_skill_names(platform: str | None = None) -> Set[str]: or os.getenv("HERMES_PLATFORM") or get_session_env("HERMES_SESSION_PLATFORM") ) + global_disabled = _normalize_string_set(skills_cfg.get("disabled")) if resolved_platform: platform_disabled = (skills_cfg.get("platform_disabled") or {}).get( resolved_platform ) if platform_disabled is not None: - return _normalize_string_set(platform_disabled) - return _normalize_string_set(skills_cfg.get("disabled")) + return global_disabled | _normalize_string_set(platform_disabled) + return global_disabled def _normalize_string_set(values) -> Set[str]: @@ -336,6 +375,7 @@ _EXTERNAL_DIRS_CACHE: Dict[Tuple[str, int], List[Path]] = {} def _external_dirs_cache_clear() -> None: """Test hook — drop the in-process cache.""" _EXTERNAL_DIRS_CACHE.clear() + _raw_config_cache_clear() def get_external_skills_dirs() -> List[Path]: @@ -368,11 +408,8 @@ def get_external_skills_dirs() -> List[Path]: # Return a copy so callers can't mutate the cached list. return list(cached) - try: - parsed = yaml_load(config_path.read_text(encoding="utf-8")) - except Exception: - return [] - if not isinstance(parsed, dict): + parsed = _load_raw_config() + if not parsed: return [] skills_cfg = parsed.get("skills") @@ -584,15 +621,7 @@ def resolve_skill_config_values( current values (or the declared default if the key isn't set). Path values are expanded via ``os.path.expanduser``. """ - config_path = get_config_path() - config: Dict[str, Any] = {} - if config_path.exists(): - try: - parsed = yaml_load(config_path.read_text(encoding="utf-8")) - if isinstance(parsed, dict): - config = parsed - except Exception: - pass + config = _load_raw_config() resolved: Dict[str, Any] = {} for var in config_vars: diff --git a/agent/ssl_guard.py b/agent/ssl_guard.py new file mode 100644 index 00000000000..557f8566c32 --- /dev/null +++ b/agent/ssl_guard.py @@ -0,0 +1,94 @@ +"""Preventive SSL CA certificate checks for Hermes Agent. + +This module catches broken CA bundle paths before OpenAI/httpx turns them into +opaque ``FileNotFoundError: [Errno 2] No such file or directory`` failures. +""" + +from __future__ import annotations + +import logging +import os +import ssl +from pathlib import Path + +from agent.errors import SSLConfigurationError + +logger = logging.getLogger(__name__) + +_CA_BUNDLE_ENV_VARS = ( + "HERMES_CA_BUNDLE", + "SSL_CERT_FILE", + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", +) + +_SKIP_VALUES = {"1", "true", "yes", "on"} + + +def _skip_ssl_guard_enabled() -> bool: + return os.getenv("HERMES_SKIP_SSL_GUARD", "").strip().lower() in _SKIP_VALUES + + +def _repair_hint() -> str: + return ( + "Repair: python -m pip install --force-reinstall certifi openai httpx\n" + "If you configured a custom corporate CA bundle, fix or unset the " + "broken CA bundle environment variable." + ) + + +def _ssl_err(message: str) -> SSLConfigurationError: + """Create a consistent, user-actionable SSL configuration error.""" + return SSLConfigurationError(f"{message}\n{_repair_hint()}") + + +def _validate_bundle_path(label: str, value: str, *, require_substantial: bool = False) -> None: + path = Path(value).expanduser() + if not path.exists(): + raise _ssl_err(f"{label} points to a missing CA bundle: {value}") + if not path.is_file(): + raise _ssl_err(f"{label} does not point to a CA bundle file: {value}") + if require_substantial and path.stat().st_size < 1024: + raise _ssl_err(f"{label} at {value} appears corrupted (too small)") + try: + ctx = ssl.create_default_context(cafile=str(path)) + except Exception as exc: + raise _ssl_err(f"{label} CA bundle at {value} cannot be loaded: {exc}") from exc + if not ctx.get_ca_certs(): + raise _ssl_err(f"{label} CA bundle at {value} did not load any certificates") + + +def verify_ca_bundle() -> None: + """Verify configured and bundled CA certificates are present and loadable. + + Raises: + SSLConfigurationError: If an explicit CA-bundle environment variable + points at a bad path, or if certifi's bundled ``cacert.pem`` is + missing/corrupt. + """ + if _skip_ssl_guard_enabled(): + logger.debug("SSL CA bundle guard skipped via HERMES_SKIP_SSL_GUARD") + return + + for env_var in _CA_BUNDLE_ENV_VARS: + value = os.getenv(env_var) + if value: + _validate_bundle_path(env_var, value) + + try: + import certifi + except Exception as exc: + raise _ssl_err(f"certifi is not importable: {exc}") from exc + + ca_bundle = str(certifi.where()) + _validate_bundle_path("certifi", ca_bundle, require_substantial=True) + + +def verify_ca_bundle_with_fallback() -> None: + """Backward-compatible wrapper for older call sites. + + The old PR name mentioned a platform fallback, but allowing startup with a + broken certifi bundle still leaves httpx/OpenAI and requests call sites + failing later. Keep the wrapper name but enforce the same check. + """ + verify_ca_bundle() diff --git a/agent/transports/anthropic.py b/agent/transports/anthropic.py index 3a209f2d753..aad49161385 100644 --- a/agent/transports/anthropic.py +++ b/agent/transports/anthropic.py @@ -186,10 +186,21 @@ class AnthropicTransport(ProviderTransport): def validate_response(self, response: Any) -> bool: """Check Anthropic response structure is valid. - An empty content list is legitimate when ``stop_reason == "end_turn"`` - — the model's canonical way of signalling "nothing more to add" after - a tool turn that already delivered the user-facing text. Treating it - as invalid falsely retries a completed response. + An empty content list is legitimate for terminal stop reasons that + carry no text payload: + + - ``end_turn`` — the model's canonical "nothing more to add" after a + tool turn that already delivered the user-facing text. + - ``refusal`` — the model declined to respond (Claude 4.5+). The + Messages API returns an empty ``content`` list with this stop + reason. Treating it as invalid sends a deterministic refusal into + the invalid-response retry loop, which reproduces the refusal on + every attempt and surfaces a misleading "rate limited / invalid + response" error instead of the refusal. ``normalize_response`` maps + ``refusal`` → ``content_filter`` so the agent loop's refusal handler + can surface it. + + Treating either as invalid falsely retries a completed response. """ if response is None: return False @@ -197,7 +208,7 @@ class AnthropicTransport(ProviderTransport): if not isinstance(content_blocks, list): return False if not content_blocks: - return getattr(response, "stop_reason", None) == "end_turn" + return getattr(response, "stop_reason", None) in {"end_turn", "refusal"} return True def extract_cache_stats(self, response: Any) -> Optional[Dict[str, int]]: diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py index 3649f21d3eb..8f79bfc680b 100644 --- a/agent/transports/chat_completions.py +++ b/agent/transports/chat_completions.py @@ -671,8 +671,42 @@ class ChatCompletionsTransport(ProviderTransport): if rd: provider_data["reasoning_details"] = rd + # OpenAI structured-refusal field. When a model declines, the SDK + # populates ``message.refusal`` with the explanation and leaves + # ``content`` empty. OpenAI-compatible proxies that front Anthropic / + # Bedrock (e.g. Nous Portal) surface a Claude refusal this way — or via + # ``finish_reason="content_filter"`` — instead of the native + # ``stop_reason="refusal"``. Without capturing it the refusal looks + # like an empty response, so the agent loop retries a deterministic + # refusal three times and gives up with "no content after retries". + # Promote it to content + a ``content_filter`` finish reason so the + # loop's refusal handler surfaces it clearly and stops. ``refusal`` is + # ``None`` for normal responses, so this is a no-op in the common case. + content = msg.content + refusal = getattr(msg, "refusal", None) + if refusal is None and hasattr(msg, "model_extra"): + _msg_extra = getattr(msg, "model_extra", None) or {} + if isinstance(_msg_extra, dict): + refusal = _msg_extra.get("refusal") + if isinstance(refusal, str) and refusal.strip(): + # Record the refusal explanation regardless — it's useful provider + # metadata even when the model also returned a usable payload. + provider_data["refusal"] = refusal + _has_text = isinstance(content, str) and content.strip() + _has_tool_calls = bool(tool_calls) + # Only promote to a terminal ``content_filter`` when the refusal is + # the *sole* payload — no visible text and no tool calls. A response + # that carries real content (or tool calls) alongside a refusal note + # is a normal, usable turn: surfacing it as a failed safety refusal + # would discard the model's actual work. In the empty-payload case, + # adopt the refusal as content so the loop has something to show. + if not _has_text and not _has_tool_calls: + content = refusal + if finish_reason in (None, "stop"): + finish_reason = "content_filter" + return NormalizedResponse( - content=msg.content, + content=content, tool_calls=tool_calls, finish_reason=finish_reason, reasoning=reasoning, diff --git a/agent/transports/codex.py b/agent/transports/codex.py index ab82f6202f1..1d24ac3355a 100644 --- a/agent/transports/codex.py +++ b/agent/transports/codex.py @@ -218,22 +218,10 @@ class ResponsesApiTransport(ProviderTransport): kwargs.pop("timeout", None) if is_codex_backend: - prompt_cache_key = kwargs.get("prompt_cache_key") - cache_scope_id = str(prompt_cache_key or session_id or "").strip() - if cache_scope_id: - existing_extra_headers = kwargs.get("extra_headers") - merged_extra_headers: Dict[str, str] = {} - if isinstance(existing_extra_headers, dict): - merged_extra_headers.update( - { - str(key): str(value) - for key, value in existing_extra_headers.items() - if key and value is not None - } - ) - merged_extra_headers["session_id"] = cache_scope_id - merged_extra_headers["x-client-request-id"] = cache_scope_id - kwargs["extra_headers"] = merged_extra_headers + # chatgpt.com/backend-api/codex rejects body-level + # ``extra_headers`` with HTTP 400. Correlation/cache routing for + # this backend must not be sent through the Responses payload. + kwargs.pop("extra_headers", None) max_tokens = params.get("max_tokens") if max_tokens is not None and not is_codex_backend: diff --git a/apps/bootstrap-installer/src-tauri/src/update.rs b/apps/bootstrap-installer/src-tauri/src/update.rs index 658bff6c540..40d136f960d 100644 --- a/apps/bootstrap-installer/src-tauri/src/update.rs +++ b/apps/bootstrap-installer/src-tauri/src/update.rs @@ -3,8 +3,9 @@ //! Driven when the installer is launched as `Hermes-Setup.exe --update` (see //! `AppMode` in lib.rs). The desktop app hands off to us — it exits, then we: //! -//! 1. wait for the old Hermes desktop process to fully exit (so the venv -//! shim is free; otherwise `hermes update` aborts with exit code 2), +//! 1. wait for the old Hermes desktop process to fully exit (so both the +//! venv shim and packaged app.asar are free; otherwise `hermes update` +//! or repair bootstrap can race locked files), //! 2. run `hermes update --yes --gateway` (Python/repo update; this does NOT //! rebuild apps/desktop by design — see cmd_update in hermes_cli/main.py), //! 3. run `hermes desktop --build-only` (the rebuild step update skips), @@ -38,8 +39,8 @@ use crate::events::{BootstrapEvent, LogStream, StageInfo, StageState}; /// hermes_cli/main.py (sys.exit(2)). We surface a targeted message for this. const UPDATE_EXIT_CONCURRENT: i32 = 2; -/// How long to wait for the old desktop process to release the venv shim -/// before giving up and letting `hermes update`'s own guard decide. +/// How long to wait for the old desktop process to release files under the +/// install tree before giving up and letting `hermes update`'s own guard decide. const DESKTOP_EXIT_WAIT: Duration = Duration::from_secs(20); const DESKTOP_EXIT_POLL: Duration = Duration::from_millis(500); @@ -150,8 +151,10 @@ async fn run_update(app: AppHandle) -> Result<()> { // ---- pre-step: wait for the old desktop to die ----------------------- // The desktop exec'd us then called app.exit(), but process teardown is // async on Windows. If it still holds the venv shim, `hermes update` - // aborts with exit 2. Give it a bounded window to clear. - wait_for_venv_free(&install_root, &app).await; + // aborts with exit 2. If it still holds the packaged app.asar, + // install.ps1's repair/re-clone path cannot move/remove the install tree. + // Give both handles a bounded window to clear. + wait_for_install_locks_free(&install_root, &app, "update").await; // ---- stage 1: hermes update ----------------------------------------- // Pass --branch so `hermes update` targets the branch this installer was @@ -173,8 +176,8 @@ async fn run_update(app: AppHandle) -> Result<()> { vec!["update".into(), "--yes".into(), "--gateway".into()]; // --force skips `hermes update`'s Windows running-exe guard (which would // `sys.exit(2)` and dead-end the handoff). By contract the desktop has - // already exited and waited for the venv shim to unlock before launching - // us, and wait_for_venv_free below force-kills any straggler — so by the + // already exited and waited for the install locks to clear before launching + // us, and wait_for_install_locks_free below force-kills any straggler — so by the // time `hermes update` runs there is no legitimate hermes.exe to protect, // and the guard would only produce a false "Hermes is still running" stop. update_args.push("--force".into()); @@ -391,48 +394,57 @@ async fn run_update(app: AppHandle) -> Result<()> { Ok(()) } -/// Poll until the venv shim is no longer locked (Windows) or a bounded timeout -/// elapses. On non-Windows this is a short fixed grace since file locking -/// isn't the failure mode there. -async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) { - let shim = venv_hermes(install_root); +/// Poll until the venv shim AND packaged desktop app bundle are no longer locked +/// (Windows) or a bounded timeout elapses. On non-Windows this is a short fixed +/// grace since file locking isn't the failure mode there. +pub(crate) async fn wait_for_install_locks_free(install_root: &Path, app: &AppHandle, stage: &str) { + let lock_targets = install_lock_probe_paths(install_root); let deadline = Instant::now() + DESKTOP_EXIT_WAIT; - emit_log(app, Some("update"), LogStream::Stdout, "[update] waiting for Hermes to exit…"); + emit_log(app, Some(stage), LogStream::Stdout, "[handoff] waiting for Hermes to exit…"); loop { - if !is_locked(&shim) { + let locked = locked_paths(&lock_targets); + if locked.is_empty() { return; } if Instant::now() >= deadline { - // Last resort: a backend hermes.exe (or a grandchild it spawned) - // is still holding the shim. The desktop should have reaped its - // tree before handing off, but SIGTERM races / detached - // grandchildren / AV handles can leave a straggler. Rather than - // "proceed anyway" straight into uv's "Access is denied", force-kill - // every hermes.exe except ourselves, then give the OS a beat to - // unload the image. + // Last resort: a backend hermes.exe (or the desktop Hermes.exe + // itself) is still holding one of the update-sensitive files. The + // desktop should have reaped its tree before handing off, but + // SIGTERM races / detached grandchildren / AV handles can leave a + // straggler. Rather than "proceed anyway" straight into uv's + // "Access is denied" or install.ps1's locked app.asar failure, + // force-kill every Hermes.exe except ourselves, then give the OS a + // beat to unload the image. emit_log( app, - Some("update"), + Some(stage), LogStream::Stdout, - "[update] Hermes still holding the venv shim; force-killing stragglers…", + &format!( + "[handoff] Hermes still holding install files ({}); force-killing stragglers…", + format_locked_paths(&locked) + ), ); force_kill_other_hermes(); tokio::time::sleep(Duration::from_millis(800)).await; - if !is_locked(&shim) { + let locked_after_kill = locked_paths(&lock_targets); + if locked_after_kill.is_empty() { emit_log( app, - Some("update"), + Some(stage), LogStream::Stdout, - "[update] venv shim freed after force-kill", + "[handoff] install files freed after force-kill", ); } else { emit_log( app, - Some("update"), + Some(stage), LogStream::Stdout, - "[update] venv shim still locked; proceeding (--force + quarantine will handle it)", + &format!( + "[handoff] install files still locked ({}); proceeding (--force + quarantine will handle it)", + format_locked_paths(&locked_after_kill) + ), ); } return; @@ -441,13 +453,44 @@ async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) { } } +fn install_lock_probe_paths(install_root: &Path) -> Vec { + let mut paths = vec![venv_hermes(install_root)]; + paths.extend(desktop_app_payload_paths(install_root)); + paths +} + +fn desktop_app_payload_paths(install_root: &Path) -> Vec { + let release = install_root.join("apps").join("desktop").join("release"); + if cfg!(target_os = "windows") { + vec![ + release.join("win-unpacked").join("resources").join("app.asar"), + release.join("win-arm64-unpacked").join("resources").join("app.asar"), + ] + } else if cfg!(target_os = "macos") { + vec![ + release.join("mac").join("Hermes.app").join("Contents").join("Resources").join("app.asar"), + release.join("mac-arm64").join("Hermes.app").join("Contents").join("Resources").join("app.asar"), + ] + } else { + vec![release.join("linux-unpacked").join("resources").join("app.asar")] + } +} + +fn locked_paths(paths: &[PathBuf]) -> Vec { + paths.iter().filter(|p| is_locked(p)).cloned().collect() +} + +fn format_locked_paths(paths: &[PathBuf]) -> String { + paths.iter().map(|p| p.display().to_string()).collect::>().join(", ") +} + /// Force-kill any `hermes.exe` other than this process. Windows-only; a no-op /// elsewhere (POSIX has no mandatory-lock contention). We can't selectively /// target "the backend" by PID here — the desktop already exited and we never /// knew its children — so we kill the whole `hermes.exe` image tree via /// taskkill, excluding our own PID. /// -/// Safe w.r.t. our own update child: this runs inside `wait_for_venv_free`, +/// Safe w.r.t. our own update child: this runs inside the install-lock wait, /// which completes BEFORE we spawn `venv\Scripts\hermes.exe update`. At this /// point no update-driven hermes.exe exists yet, so the only hermes.exe images /// are stragglers from the old desktop — exactly what we want gone. (`/FI PID @@ -891,6 +934,29 @@ mod tests { assert!(!is_locked(Path::new("/nonexistent/does/not/exist/xyz"))); } + #[test] + fn lock_probe_paths_include_desktop_app_payload() { + let root = Path::new("/x/hermes-agent"); + let probes = install_lock_probe_paths(root); + + assert!( + probes.iter().any(|p| p == &venv_hermes(root)), + "venv shim remains part of the update lock probe" + ); + assert!( + probes.iter().any(|p| p.ends_with(Path::new("resources/app.asar"))), + "packaged app.asar must be probed so repair/re-clone waits for the old desktop to exit" + ); + } + + #[test] + fn locked_paths_ignores_missing_payloads() { + let root = Path::new("/nonexistent/hermes-agent"); + let probes = install_lock_probe_paths(root); + + assert!(locked_paths(&probes).is_empty()); + } + #[test] fn parses_update_branch_from_space_or_equals_args() { assert_eq!( diff --git a/apps/desktop/electron/backend-env.cjs b/apps/desktop/electron/backend-env.cjs new file mode 100644 index 00000000000..76329785be4 --- /dev/null +++ b/apps/desktop/electron/backend-env.cjs @@ -0,0 +1,112 @@ +const path = require('node:path') + +// Match the POSIX fallback surface used by the Python terminal environment. +// macOS apps launched from Finder/Dock often inherit only /usr/bin:/bin:/usr/sbin:/sbin, +// which misses Apple Silicon Homebrew and user-installed CLI tools such as codex. +const POSIX_SANE_PATH_ENTRIES = Object.freeze([ + '/opt/homebrew/bin', + '/opt/homebrew/sbin', + '/usr/local/sbin', + '/usr/local/bin', + '/usr/sbin', + '/usr/bin', + '/sbin', + '/bin' +]) + +function delimiterForPlatform(platform = process.platform) { + return platform === 'win32' ? ';' : ':' +} + +function pathModuleForPlatform(platform = process.platform) { + return platform === 'win32' ? path.win32 : path.posix +} + +function pathEnvKey(env = process.env, platform = process.platform) { + if (platform !== 'win32') return 'PATH' + return Object.keys(env || {}).find(key => key.toUpperCase() === 'PATH') || 'PATH' +} + +function currentPathValue(env = process.env, platform = process.platform) { + const key = pathEnvKey(env, platform) + return env?.[key] || '' +} + +function appendUniquePathEntries(entries, { delimiter = path.delimiter } = {}) { + const seen = new Set() + const ordered = [] + + for (const entry of entries) { + if (!entry) continue + const parts = Array.isArray(entry) ? entry : String(entry).split(delimiter) + for (const part of parts) { + if (!part || seen.has(part)) continue + seen.add(part) + ordered.push(part) + } + } + + return ordered.join(delimiter) +} + +function buildDesktopBackendPath({ + hermesHome, + venvRoot, + currentPath = '', + platform = process.platform, + pathModule = pathModuleForPlatform(platform) +} = {}) { + const delimiter = delimiterForPlatform(platform) + const hermesNodeBin = hermesHome ? pathModule.join(hermesHome, 'node', 'bin') : null + const venvBin = venvRoot ? pathModule.join(venvRoot, platform === 'win32' ? 'Scripts' : 'bin') : null + const saneEntries = platform === 'win32' ? [] : POSIX_SANE_PATH_ENTRIES + + return appendUniquePathEntries( + [hermesNodeBin, venvBin, currentPath, saneEntries], + { delimiter } + ) +} + +function normalizeHermesHomeRoot(hermesHome, { pathModule = pathModuleForPlatform(process.platform) } = {}) { + if (!hermesHome) return hermesHome + const resolved = pathModule.resolve(String(hermesHome)) + const parent = pathModule.dirname(resolved) + if (pathModule.basename(parent).toLowerCase() === 'profiles') { + return pathModule.dirname(parent) + } + return resolved +} + +function buildDesktopBackendEnv({ + hermesHome, + pythonPathEntries = [], + venvRoot, + currentEnv = process.env, + platform = process.platform, + pathModule = pathModuleForPlatform(platform) +} = {}) { + const delimiter = delimiterForPlatform(platform) + const currentPythonPath = currentEnv?.PYTHONPATH || '' + const key = pathEnvKey(currentEnv, platform) + + return { + PYTHONPATH: appendUniquePathEntries([...pythonPathEntries, currentPythonPath], { delimiter }), + [key]: buildDesktopBackendPath({ + hermesHome, + venvRoot, + currentPath: currentPathValue(currentEnv, platform), + platform, + pathModule + }) + } +} + +module.exports = { + POSIX_SANE_PATH_ENTRIES, + appendUniquePathEntries, + buildDesktopBackendEnv, + buildDesktopBackendPath, + delimiterForPlatform, + normalizeHermesHomeRoot, + pathEnvKey +} diff --git a/apps/desktop/electron/backend-env.test.cjs b/apps/desktop/electron/backend-env.test.cjs new file mode 100644 index 00000000000..75e0c79d5d6 --- /dev/null +++ b/apps/desktop/electron/backend-env.test.cjs @@ -0,0 +1,111 @@ +const test = require('node:test') +const assert = require('node:assert/strict') +const path = require('node:path') + +const { + POSIX_SANE_PATH_ENTRIES, + appendUniquePathEntries, + buildDesktopBackendEnv, + buildDesktopBackendPath, + normalizeHermesHomeRoot, + pathEnvKey +} = require('./backend-env.cjs') + +test('desktop backend PATH adds Hermes-managed bins and missing POSIX sane entries', () => { + const result = buildDesktopBackendPath({ + hermesHome: '/Users/test/.hermes', + venvRoot: '/Users/test/.hermes/hermes-agent/venv', + currentPath: '/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin', + platform: 'darwin', + pathModule: path.posix + }) + + const entries = result.split(':') + assert.equal(entries[0], '/Users/test/.hermes/node/bin') + assert.equal(entries[1], '/Users/test/.hermes/hermes-agent/venv/bin') + assert.ok(entries.includes('/opt/homebrew/bin'), 'Apple Silicon Homebrew bin is added') + assert.ok(entries.includes('/opt/homebrew/sbin'), 'Apple Silicon Homebrew sbin is added') + assert.ok(entries.includes('/usr/local/sbin'), 'missing standard sbin is added') + + for (const expected of POSIX_SANE_PATH_ENTRIES) { + assert.ok(entries.includes(expected), `${expected} should be present`) + } +}) + +test('desktop backend PATH preserves first occurrence and avoids duplicates', () => { + const result = buildDesktopBackendPath({ + hermesHome: '/Users/test/.hermes', + venvRoot: '/Users/test/.hermes/hermes-agent/venv', + currentPath: '/opt/homebrew/bin:/usr/bin:/opt/homebrew/bin:/bin', + platform: 'darwin', + pathModule: path.posix + }) + + const entries = result.split(':') + assert.equal(entries.filter(entry => entry === '/opt/homebrew/bin').length, 1) + assert.ok( + entries.indexOf('/opt/homebrew/bin') < entries.indexOf('/opt/homebrew/sbin'), + 'existing Homebrew bin keeps its precedence over appended missing sane entries' + ) +}) + +test('buildDesktopBackendEnv extends PYTHONPATH and backend PATH together', () => { + const env = buildDesktopBackendEnv({ + hermesHome: '/Users/test/.hermes', + pythonPathEntries: ['/repo/hermes-agent'], + venvRoot: '/Users/test/.hermes/hermes-agent/venv', + currentEnv: { + PATH: '/usr/bin:/bin', + PYTHONPATH: '/existing/pythonpath' + }, + platform: 'darwin', + pathModule: path.posix + }) + + assert.equal(env.PYTHONPATH, '/repo/hermes-agent:/existing/pythonpath') + assert.ok(env.PATH.startsWith('/Users/test/.hermes/node/bin:/Users/test/.hermes/hermes-agent/venv/bin:')) + assert.ok(env.PATH.includes('/opt/homebrew/bin')) +}) + +test('normalizeHermesHomeRoot maps profile homes back to the global Hermes root', () => { + assert.equal( + normalizeHermesHomeRoot('/Users/test/.hermes/profiles/oracle', { pathModule: path.posix }), + '/Users/test/.hermes' + ) + assert.equal( + normalizeHermesHomeRoot('C:\\Users\\test\\AppData\\Local\\hermes\\profiles\\oracle', { pathModule: path.win32 }), + 'C:\\Users\\test\\AppData\\Local\\hermes' + ) + assert.equal( + normalizeHermesHomeRoot('/Users/test/.hermes', { pathModule: path.posix }), + '/Users/test/.hermes' + ) +}) + +test('Windows PATH casing and delimiter are preserved without POSIX sane entries', () => { + const env = buildDesktopBackendEnv({ + hermesHome: 'C:\\Users\\test\\AppData\\Local\\hermes', + pythonPathEntries: ['C:\\repo\\hermes-agent'], + venvRoot: 'C:\\Users\\test\\AppData\\Local\\hermes\\hermes-agent\\venv', + currentEnv: { + Path: 'C:\\Windows\\System32;C:\\Windows', + PYTHONPATH: 'C:\\existing\\pythonpath' + }, + platform: 'win32', + pathModule: path.win32 + }) + + assert.equal(pathEnvKey({ Path: 'x' }, 'win32'), 'Path') + assert.equal(env.PATH, undefined) + assert.ok(env.Path.startsWith('C:\\Users\\test\\AppData\\Local\\hermes\\node\\bin;')) + assert.ok(env.Path.includes('\\venv\\Scripts;')) + assert.ok(env.Path.includes(';C:\\Windows\\System32;C:\\Windows')) + assert.equal(env.Path.includes('/opt/homebrew/bin'), false) +}) + +test('appendUniquePathEntries drops empty entries and keeps first occurrence', () => { + assert.equal( + appendUniquePathEntries([':/a::/b', ['/a', '/c']], { delimiter: ':' }), + '/a:/b:/c' + ) +}) diff --git a/apps/desktop/electron/backend-ready.cjs b/apps/desktop/electron/backend-ready.cjs new file mode 100644 index 00000000000..9af41e549c4 --- /dev/null +++ b/apps/desktop/electron/backend-ready.cjs @@ -0,0 +1,66 @@ +const _READY_RE = /^HERMES_DASHBOARD_READY port=(\d+)/m + +/** + * Watch a child process's stdout for the `HERMES_DASHBOARD_READY port=` + * line that web_server.py prints after uvicorn binds its socket. + * + * Returns the parsed port. Rejects if: + * - the child exits before emitting the line + * - the child emits an `error` event + * - no line arrives within the timeout + * + * A single `cleanup()` tears down every listener (data/exit/error/timeout) + * on every terminal path — resolve, reject, or timeout — so repeated + * backend spawns don't leak listener slots on the child. + */ +function waitForDashboardPort(child, timeoutMs = 45_000) { + return new Promise((resolve, reject) => { + let buf = '' + let done = false + + function cleanup() { + if (done) return + done = true + clearTimeout(timer) + child.stdout.off('data', onData) + child.off('exit', onExit) + child.off('error', onError) + } + + function onData(chunk) { + buf += chunk.toString() + let nl + while ((nl = buf.indexOf('\n')) !== -1) { + const line = buf.slice(0, nl) + buf = buf.slice(nl + 1) + const m = line.match(_READY_RE) + if (m) { + cleanup() + resolve(parseInt(m[1], 10)) + return + } + } + } + + function onExit(code, signal) { + cleanup() + reject(new Error(`Hermes backend: exited before port announcement (${signal || code})`)) + } + + function onError(err) { + cleanup() + reject(err) + } + + const timer = setTimeout(() => { + cleanup() + reject(new Error(`Timed out waiting for Hermes backend port announcement (${timeoutMs}ms)`)) + }, timeoutMs) + + child.stdout.on('data', onData) + child.on('exit', onExit) + child.on('error', onError) + }) +} + +module.exports = { waitForDashboardPort } diff --git a/apps/desktop/electron/git-worktrees.cjs b/apps/desktop/electron/git-worktrees.cjs new file mode 100644 index 00000000000..570397b2c95 --- /dev/null +++ b/apps/desktop/electron/git-worktrees.cjs @@ -0,0 +1,174 @@ +'use strict' + +// Resolve git-worktree relationships for a set of session cwds, reading git's +// on-disk metadata directly (no `git` spawn per path): +// +// - A normal checkout has a `.git` DIRECTORY at its root → it's the main +// worktree; its repo root IS that directory's parent. +// - A linked worktree has a `.git` FILE: `gitdir: /.git/worktrees/`. +// That admin dir's `commondir` points back at the shared `/.git`, whose +// parent is the main repo root. +// +// Grouping by repoRoot therefore clusters a repo's main checkout with all of its +// linked worktrees, regardless of how the worktree directories are named. The +// branch (read from the worktree's own HEAD) gives each worktree a meaningful +// label. + +const fs = require('node:fs') +const path = require('node:path') +const { resolveRequestedPathForIpc } = require('./hardening.cjs') + +// Walk up from `start` to the nearest ancestor that carries a `.git` entry +// (file for a linked worktree, dir for the main checkout). Capped so a stray +// path can't loop forever. +function findGitHost(start, fsImpl) { + let dir = start + + for (let i = 0; i < 64; i += 1) { + const dotgit = path.join(dir, '.git') + + try { + if (fsImpl.existsSync(dotgit)) { + return dir + } + } catch { + return null + } + + const parent = path.dirname(dir) + + if (parent === dir) { + return null + } + + dir = parent + } + + return null +} + +function readBranch(gitDir, fsImpl) { + try { + const head = fsImpl.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim() + const ref = head.match(/^ref:\s*refs\/heads\/(.+)$/) + + if (ref) { + return ref[1] + } + + // Detached HEAD: surface a short sha so the worktree still gets a label. + return /^[0-9a-f]{7,40}$/i.test(head) ? head.slice(0, 8) : null + } catch { + return null + } +} + +// Given the directory that owns the `.git` entry, resolve its worktree identity. +function resolveFromHost(host, fsImpl) { + const dotgit = path.join(host, '.git') + let stat + + try { + stat = fsImpl.statSync(dotgit) + } catch { + return null + } + + if (stat.isDirectory()) { + return { + repoRoot: host, + worktreeRoot: host, + isMainWorktree: true, + branch: readBranch(dotgit, fsImpl) + } + } + + // Linked worktree: `.git` is a file pointing at the admin dir. + let contents + + try { + contents = fsImpl.readFileSync(dotgit, 'utf8').trim() + } catch { + return null + } + + const match = contents.match(/^gitdir:\s*(.+)$/m) + + if (!match) { + return null + } + + const adminDir = path.resolve(host, match[1].trim()) + + // `commondir` resolves to the shared `/.git`; fall back to walking two + // levels up from `/.git/worktrees/` if it's missing. + let commonDir + + try { + const rel = fsImpl.readFileSync(path.join(adminDir, 'commondir'), 'utf8').trim() + commonDir = path.resolve(adminDir, rel) + } catch { + commonDir = path.dirname(path.dirname(adminDir)) + } + + return { + repoRoot: path.dirname(commonDir), + worktreeRoot: host, + isMainWorktree: false, + branch: readBranch(adminDir, fsImpl) + } +} + +function resolveWorktree(startPath, fsImpl = fs) { + let resolved + + try { + resolved = resolveRequestedPathForIpc(startPath, { purpose: 'Worktree lookup' }) + } catch { + return null + } + + let start = resolved + + try { + const stat = fsImpl.statSync(resolved) + + if (!stat.isDirectory()) { + start = path.dirname(resolved) + } + } catch { + return null + } + + const host = findGitHost(start, fsImpl) + + if (!host) { + return null + } + + return resolveFromHost(host, fsImpl) +} + +// Batch entry point for the renderer: maps each requested cwd to its worktree +// info (or null when it isn't inside a git checkout / can't be read). Dedupes so +// many sessions sharing a cwd cost one lookup. +async function worktreesForIpc(cwds, options = {}) { + const fsImpl = options.fs || fs + const list = Array.isArray(cwds) ? cwds : [] + const out = {} + + for (const cwd of list) { + if (typeof cwd !== 'string' || !cwd.trim() || cwd in out) { + continue + } + + out[cwd] = resolveWorktree(cwd, fsImpl) + } + + return out +} + +module.exports = { + resolveWorktree, + worktreesForIpc +} diff --git a/apps/desktop/electron/hardening.cjs b/apps/desktop/electron/hardening.cjs index 812dc3f77c7..7b568ec3d11 100644 --- a/apps/desktop/electron/hardening.cjs +++ b/apps/desktop/electron/hardening.cjs @@ -1,4 +1,5 @@ const fs = require('node:fs') +const os = require('node:os') const path = require('node:path') const { fileURLToPath } = require('node:url') @@ -142,7 +143,14 @@ function rejectUnsafePathSyntax(filePath, purpose = 'File read') { function resolveRequestedPathForIpc(filePath, options = {}) { const purpose = String(options.purpose || 'File read') - const raw = rejectUnsafePathSyntax(filePath, purpose) + let raw = rejectUnsafePathSyntax(filePath, purpose) + + // Gateway-reported cwds (config `terminal.cwd`, remote sessions) routinely + // arrive as `~/...`. Node's fs has no shell — without expansion the path + // resolves under process.cwd() and every read "ENOENT"s forever. + if (raw === '~' || raw.startsWith('~/') || raw.startsWith('~\\')) { + raw = path.join(os.homedir(), raw.slice(1)) + } if (/^file:/i.test(raw)) { let resolvedPath diff --git a/apps/desktop/electron/hardening.test.cjs b/apps/desktop/electron/hardening.test.cjs index a52ee27c830..b38a03b0082 100644 --- a/apps/desktop/electron/hardening.test.cjs +++ b/apps/desktop/electron/hardening.test.cjs @@ -106,6 +106,19 @@ test('resolveRequestedPathForIpc resolves relative paths from the trimmed base d ) }) +test('resolveRequestedPathForIpc expands ~ to the home directory', () => { + assert.equal(resolveRequestedPathForIpc('~', { purpose: 'Directory read' }), path.resolve(os.homedir())) + assert.equal( + resolveRequestedPathForIpc('~/www/project', { purpose: 'Directory read' }), + path.resolve(os.homedir(), 'www/project') + ) + // `~user` shorthand is NOT expanded — only the caller's own home. + assert.equal( + resolveRequestedPathForIpc('~other/secret', { baseDir: os.tmpdir(), purpose: 'Directory read' }), + path.resolve(os.tmpdir(), '~other/secret') + ) +}) + test('resolveReadableFileForIpc validates existence type size and sensitivity', async t => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-hardening-')) t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 911e26e1106..c714a46ee46 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -26,19 +26,23 @@ const { pathToFileURL } = require('node:url') const { execFileSync, spawn } = require('node:child_process') const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs') const { runBootstrap } = require('./bootstrap-runner.cjs') -const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./session-windows.cjs') +const { + buildSessionWindowUrl, + createSessionWindowRegistry, + SESSION_WINDOW_MIN_HEIGHT, + SESSION_WINDOW_MIN_WIDTH +} = require('./session-windows.cjs') const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs') const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs') const { adoptServedDashboardToken } = require('./dashboard-token.cjs') -const { PortPool } = require('./port-pool.cjs') +const { waitForDashboardPort } = require('./backend-ready.cjs') const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs') const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs') +const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs') const { readDirForIpc } = require('./fs-read-dir.cjs') const { gitRootForIpc } = require('./git-root.cjs') -const { - OFFICIAL_REPO_HTTPS_URL, - isOfficialSshRemote -} = require('./update-remote.cjs') +const { worktreesForIpc } = require('./git-worktrees.cjs') +const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs') const { buildPosixCleanupScript, buildWindowsCleanupScript, @@ -95,6 +99,7 @@ try { nodePty = require(nodePtyDir) } } catch { + console.log(`[terminal] failed to load node-pty from path ${nodePtyDir}`) nodePty = null nodePtyDir = null } @@ -107,12 +112,6 @@ if (USER_DATA_OVERRIDE) { app.setPath('userData', resolvedUserData) } -const PORT_FLOOR = 9120 -const PORT_CEILING = 9199 -// In-process port reservations that close the pickPort() TOCTOU window where -// two concurrent backend spawns could be handed the same port. See -// port-pool.cjs for the full rationale. -const portPool = new PortPool(PORT_FLOOR, PORT_CEILING) const DEV_SERVER = process.env.HERMES_DESKTOP_DEV_SERVER const IS_PACKAGED = app.isPackaged const IS_MAC = process.platform === 'darwin' @@ -241,7 +240,7 @@ if (INSTALL_STAMP) { // HERMES_HOME beneath the throwaway userData dir so a fresh-install run never // touches the user's real ~/.hermes / %LOCALAPPDATA%\hermes. function resolveHermesHome() { - if (process.env.HERMES_HOME) return path.resolve(process.env.HERMES_HOME) + if (process.env.HERMES_HOME) return normalizeHermesHomeRoot(process.env.HERMES_HOME) if (USER_DATA_OVERRIDE) return path.join(path.resolve(USER_DATA_OVERRIDE), 'hermes-home') if (IS_WINDOWS && process.env.LOCALAPPDATA) { const localappdata = path.join(process.env.LOCALAPPDATA, 'hermes') @@ -346,10 +345,110 @@ const APP_ICON_PATHS = [ let rendererTitleBarTheme = null const terminalSessions = new Map() +// Force the NATIVE window appearance (vibrancy material, titlebar, the +// pre-first-paint window background) to follow the APP theme instead of the +// OS appearance. With `vibrancy` set, macOS paints an NSVisualEffectView that +// tracks the window's effective appearance and ignores `backgroundColor` — +// so a dark-themed app on a light-mode Mac flashes a white material on every +// new window until the renderer covers it. The renderer reports its mode via +// 'hermes:native-theme' ('dark' | 'light' | 'system'); we pin +// nativeTheme.themeSource to it and persist the value so cold launches paint +// correctly before the renderer has even loaded. +const NATIVE_THEME_CONFIG_PATH = path.join(app.getPath('userData'), 'native-theme.json') +const THEME_SOURCES = new Set(['dark', 'light', 'system']) + +function readPersistedThemeSource() { + try { + const parsed = JSON.parse(fs.readFileSync(NATIVE_THEME_CONFIG_PATH, 'utf8')) + + if (parsed && THEME_SOURCES.has(parsed.themeSource)) { + return parsed.themeSource + } + } catch { + // Missing / malformed → follow the OS like a fresh install. + } + + return 'system' +} + +function writePersistedThemeSource(mode) { + try { + fs.mkdirSync(path.dirname(NATIVE_THEME_CONFIG_PATH), { recursive: true }) + fs.writeFileSync(NATIVE_THEME_CONFIG_PATH, JSON.stringify({ themeSource: mode }, null, 2), 'utf8') + } catch (error) { + rememberLog(`[theme] write native theme failed: ${error.message}`) + } +} + +nativeTheme.themeSource = readPersistedThemeSource() + +// Window translucency (see-through window). One lever, 0–100; 0 = off (the +// default). Mapped to the native window opacity so the desktop shows through +// the whole window. Persisted so a cold launch applies it at window creation, +// before the renderer reports its value. macOS + Windows only; `setOpacity` is +// a no-op on Linux. See store/translucency. +const TRANSLUCENCY_CONFIG_PATH = path.join(app.getPath('userData'), 'translucency.json') + +function clampIntensity(value) { + const n = Math.round(Number(value)) + + return Number.isFinite(n) ? Math.min(100, Math.max(0, n)) : 0 +} + +function readPersistedTranslucency() { + try { + return clampIntensity(JSON.parse(fs.readFileSync(TRANSLUCENCY_CONFIG_PATH, 'utf8')).intensity) + } catch { + return 0 + } +} + +function writePersistedTranslucency(intensity) { + try { + fs.mkdirSync(path.dirname(TRANSLUCENCY_CONFIG_PATH), { recursive: true }) + fs.writeFileSync(TRANSLUCENCY_CONFIG_PATH, JSON.stringify({ intensity }, null, 2), 'utf8') + } catch (error) { + rememberLog(`[translucency] write failed: ${error.message}`) + } +} + +let translucencyIntensity = readPersistedTranslucency() + +// Map the 0–100 lever to a window opacity. Floor at 0.3 so the most see-through +// setting is still usable rather than nearly invisible. 0 → fully opaque. +function windowOpacity() { + return 1 - (translucencyIntensity / 100) * 0.7 +} + +// Re-apply translucency to a live window (runtime toggle, no recreation). +// `setOpacity` is a no-op on Linux, which is fine — it just stays opaque there. +function applyWindowTranslucency(win) { + if (!win || win.isDestroyed() || typeof win.setOpacity !== 'function') { + return + } + + try { + win.setOpacity(windowOpacity()) + } catch (error) { + rememberLog(`[translucency] apply failed: ${error.message}`) + } +} + function isHexColor(value) { return typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value) } +// Background color to paint a window with BEFORE its renderer loads, so a new +// (or reopened) window doesn't flash white/light in dark mode. Prefer the theme +// the renderer last reported; fall back to the OS preference on first launch. +function getWindowBackgroundColor() { + if (rendererTitleBarTheme && isHexColor(rendererTitleBarTheme.background)) { + return rendererTitleBarTheme.background + } + + return nativeTheme.shouldUseDarkColors ? '#111111' : '#f7f7f7' +} + function getTitleBarOverlayOptions() { if (IS_MAC) { return { height: TITLEBAR_HEIGHT } @@ -1162,10 +1261,14 @@ function findSystemPython() { if (pyExe) { for (const version of SUPPORTED_VERSIONS) { try { - const out = execFileSync(pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], hiddenWindowsChildOptions({ - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'] - })) + const out = execFileSync( + pyExe, + [`-${version}`, '-c', 'import sys; print(sys.executable)'], + hiddenWindowsChildOptions({ + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'] + }) + ) const candidate = out.trim() if (candidate && fileExists(candidate)) return candidate } catch { @@ -1300,11 +1403,15 @@ function resolveUpdateRoot() { function runGit(args, options = {}) { return new Promise((resolve, reject) => { - const child = spawn(resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, hiddenWindowsChildOptions({ - cwd: options.cwd, - env: { ...process.env, ...(options.env || {}), GIT_TERMINAL_PROMPT: '0' }, - stdio: ['ignore', 'pipe', 'pipe'] - })) + const child = spawn( + resolveGitBinary(), + IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, + hiddenWindowsChildOptions({ + cwd: options.cwd, + env: { ...process.env, ...(options.env || {}), GIT_TERMINAL_PROMPT: '0' }, + stdio: ['ignore', 'pipe', 'pipe'] + }) + ) let stdout = '' let stderr = '' @@ -1728,6 +1835,44 @@ async function applyUpdates(opts = {}) { } } +async function handOffWindowsBootstrapRecovery(reason) { + if (!IS_WINDOWS || !IS_PACKAGED) return false + + const updater = resolveUpdaterBinary() + if (!updater) return false + + const updateRoot = resolveUpdateRoot() + const { branch: configuredBranch } = readDesktopUpdateConfig() + const branch = directoryExists(path.join(updateRoot, '.git')) + ? await resolveHealedBranch(updateRoot, configuredBranch || DEFAULT_UPDATE_BRANCH) + : configuredBranch || DEFAULT_UPDATE_BRANCH + const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin') + const venvHermes = path.join(venvBin, IS_WINDOWS ? 'hermes.exe' : 'hermes') + const updaterArgs = fileExists(venvHermes) ? ['--update', '--branch', branch] : ['--repair', '--branch', branch] + + await releaseBackendLockForUpdate(updateRoot) + + const child = spawn(updater, updaterArgs, { + cwd: HERMES_HOME, + env: { + ...process.env, + HERMES_HOME, + PATH: [path.join(HERMES_HOME, 'node', 'bin'), venvBin, process.env.PATH].filter(Boolean).join(path.delimiter) + }, + detached: true, + stdio: 'ignore', + windowsHide: false + }) + child.unref() + + rememberLog(`[bootstrap] handed off ${reason} recovery to updater: ${updater} ${updaterArgs.join(' ')}; exiting desktop to release app.asar`) + setTimeout(() => { + app.quit() + }, 600) + + return true +} + // Resolve the hermes CLI to drive an in-app update: prefer the venv shim in // the install we're updating, fall back to `hermes` on PATH. function resolveHermesCliBinary(updateRoot) { @@ -1741,11 +1886,15 @@ function runStreamedUpdate(command, args, { cwd, env, stage } = {}) { return new Promise(resolve => { let child try { - child = spawn(command, args, hiddenWindowsChildOptions({ - cwd, - env: { ...process.env, ...(env || {}) }, - stdio: ['ignore', 'pipe', 'pipe'] - })) + child = spawn( + command, + args, + hiddenWindowsChildOptions({ + cwd, + env: { ...process.env, ...(env || {}) }, + stdio: ['ignore', 'pipe', 'pipe'] + }) + ) } catch (err) { resolve({ code: 1, error: err.message }) return @@ -2133,9 +2282,11 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) { label, command: python, args: ['-m', 'hermes_cli.main', ...dashboardArgs], - env: { - PYTHONPATH: [root, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter) - }, + env: buildDesktopBackendEnv({ + hermesHome: HERMES_HOME, + pythonPathEntries: [root], + venvRoot: path.join(root, 'venv') + }), root, bootstrap: Boolean(options.bootstrap), shell: false @@ -2154,9 +2305,11 @@ function createActiveBackend(dashboardArgs) { label: `Hermes at ${ACTIVE_HERMES_ROOT}`, command: fileExists(venvPython) ? venvPython : findSystemPython(), args: ['-m', 'hermes_cli.main', ...dashboardArgs], - env: { - PYTHONPATH: [ACTIVE_HERMES_ROOT, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter) - }, + env: buildDesktopBackendEnv({ + hermesHome: HERMES_HOME, + pythonPathEntries: [ACTIVE_HERMES_ROOT], + venvRoot: VENV_ROOT + }), root: ACTIVE_HERMES_ROOT, bootstrap: true, shell: false @@ -2317,6 +2470,14 @@ async function ensureRuntime(backend) { if (backend.kind === 'bootstrap-needed') { rememberLog('[bootstrap] no Hermes install found; starting first-launch bootstrap') + if (await handOffWindowsBootstrapRecovery('bootstrap-needed')) { + const handoffError = new Error('Hermes recovery was handed off to Hermes Setup. The desktop will restart when recovery completes.') + handoffError.isBootstrapFailure = true + handoffError.bootstrapHandedOff = true + bootstrapFailure = handoffError + throw handoffError + } + // Eagerly flip the bootstrap UI state to 'active' so the renderer // shows the install overlay BEFORE the runner finishes fetching the // manifest (which on slow networks can take tens of seconds and would @@ -2446,24 +2607,6 @@ async function ensureRuntime(backend) { return backend } -function isPortAvailable(port) { - return new Promise(resolve => { - const server = net.createServer() - server.once('error', () => resolve(false)) - server.once('listening', () => { - server.close(() => resolve(true)) - }) - server.listen(port, '127.0.0.1') - }) -} - -async function pickPort() { - const port = await portPool.reserve(isPortAvailable) - if (port === null) { - throw new Error(`No free localhost port in ${PORT_FLOOR}-${PORT_CEILING}`) - } - return port -} function fetchJson(url, token, options = {}) { return new Promise((resolve, reject) => { @@ -4541,49 +4684,41 @@ async function spawnPoolBackend(profile, entry) { } } - const port = await pickPort() const token = crypto.randomBytes(32).toString('base64url') // --profile wins over the inherited HERMES_HOME env (see _apply_profile_override // step 3 in hermes_cli/main.py), so the child re-homes to this profile. - const dashboardArgs = ['--profile', profile, 'dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)] - let backend - let hermesCwd - let webDist - try { - backend = await ensureRuntime(resolveHermesBackend(dashboardArgs)) - hermesCwd = resolveHermesCwd() - webDist = resolveWebDist() - } catch (error) { - // These run before the child exists / its exit handler is attached, so a - // throw here would otherwise leak the reservation and slowly exhaust the - // 9120-9199 range across switch cycles in one app session. - portPool.release(port) - throw error - } + // --port 0: the OS assigns an ephemeral port; the child announces it on stdout. + const dashboardArgs = ['--profile', profile, 'dashboard', '--no-open', '--host', '127.0.0.1', '--port', '0'] + const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs)) + const hermesCwd = resolveHermesCwd() + const webDist = resolveWebDist() rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`) - const child = spawn(backend.command, backend.args, hiddenWindowsChildOptions({ - cwd: hermesCwd, - env: { - ...process.env, - HERMES_HOME, - ...backend.env, - // Pin the gateway's tool/terminal cwd to the same directory we chose for - // the child process. Inherited TERMINAL_CWD (or a stale config bridge) - // can still point at the install dir even when spawn cwd is home. - TERMINAL_CWD: hermesCwd, - HERMES_DASHBOARD_SESSION_TOKEN: token, - // Marks this dashboard backend as desktop-spawned so it runs the cron - // scheduler tick loop (the gateway isn't running under the app). - HERMES_DESKTOP: '1', - HERMES_WEB_DIST: webDist - }, - shell: backend.shell, - stdio: ['ignore', 'pipe', 'pipe'] - })) + const child = spawn( + backend.command, + backend.args, + hiddenWindowsChildOptions({ + cwd: hermesCwd, + env: { + ...process.env, + HERMES_HOME, + ...backend.env, + // Pin the gateway's tool/terminal cwd to the same directory we chose for + // the child process. Inherited TERMINAL_CWD (or a stale config bridge) + // can still point at the install dir even when spawn cwd is home. + TERMINAL_CWD: hermesCwd, + HERMES_DASHBOARD_SESSION_TOKEN: token, + // Marks this dashboard backend as desktop-spawned so it runs the cron + // scheduler tick loop (the gateway isn't running under the app). + HERMES_DESKTOP: '1', + HERMES_WEB_DIST: webDist + }, + shell: backend.shell, + stdio: ['ignore', 'pipe', 'pipe'] + }) + ) entry.process = child - entry.port = port entry.token = token child.stdout.on('data', rememberLog) @@ -4597,13 +4732,11 @@ async function spawnPoolBackend(profile, entry) { child.once('error', error => { rememberLog(`Hermes backend for profile "${profile}" failed to start: ${error.message}`) backendPool.delete(profile) - portPool.release(port) rejectStart?.(error) }) child.once('exit', (code, signal) => { rememberLog(`Hermes backend for profile "${profile}" exited (${signal || code})`) backendPool.delete(profile) - portPool.release(port) if (!ready) { rejectStart?.( new Error(`Hermes backend for profile "${profile}" exited before it became ready (${signal || code}).`) @@ -4611,6 +4744,10 @@ async function spawnPoolBackend(profile, entry) { } }) + // Discover the ephemeral port the child bound to + const port = await Promise.race([waitForDashboardPort(child), startFailed]) + entry.port = port + const baseUrl = `http://127.0.0.1:${port}` await Promise.race([waitForHermes(baseUrl, token), startFailed]) ready = true @@ -4638,7 +4775,6 @@ function stopPoolBackend(profile) { const entry = backendPool.get(profile) if (!entry) return backendPool.delete(profile) - if (entry.port) portPool.release(entry.port) if (entry.process && !entry.process.killed) { try { entry.process.kill('SIGTERM') @@ -4724,11 +4860,6 @@ async function startHermes() { } if (connectionPromise) return connectionPromise - // Hoisted so the outer .catch can release a port reserved by pickPort() when - // a throw (e.g. ensureRuntime failing) happens before the child's exit - // handler is attached. Stays null on the remote path (no port picked). - let reservedPort = null - connectionPromise = (async () => { await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8) // Resolve for the desktop's primary profile so a per-profile remote @@ -4756,11 +4887,9 @@ async function startHermes() { } } - await advanceBootProgress('backend.port', 'Finding an open local port', 16) - const port = await pickPort() - reservedPort = port const token = crypto.randomBytes(32).toString('base64url') - const dashboardArgs = ['dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)] + // --port 0: the OS assigns an ephemeral port; the child announces it on stdout. + const dashboardArgs = ['dashboard', '--no-open', '--host', '127.0.0.1', '--port', '0'] // Pin the desktop's chosen profile via the global --profile flag. This is // deterministic (it wins over the sticky ~/.hermes/active_profile file) and // resolves HERMES_HOME the same way `hermes -p ` does on the CLI. An @@ -4778,30 +4907,34 @@ async function startHermes() { await advanceBootProgress('backend.spawn', `Starting Hermes backend via ${backend.label}`, 84) rememberLog(`Starting Hermes backend via ${backend.label}`) - hermesProcess = spawn(backend.command, backend.args, hiddenWindowsChildOptions({ - cwd: hermesCwd, - env: { - ...process.env, - // Explicitly pin HERMES_HOME for the child so Python's get_hermes_home() - // resolves to the SAME location our resolveHermesHome() picked. Without - // this pin, Python falls back to ~/.hermes on every platform — fine on - // mac/linux (where our default matches), but on Windows our default is - // %LOCALAPPDATA%\hermes, which differs from C:\Users\\.hermes. - // Mismatch would split config / sessions / .env / logs across two - // directories. install.ps1 sets HERMES_HOME via setx; the desktop - // can't reliably do that, so we set it inline for every spawn. - HERMES_HOME, - ...backend.env, - TERMINAL_CWD: hermesCwd, - HERMES_DASHBOARD_SESSION_TOKEN: token, - // Marks this dashboard backend as desktop-spawned so it runs the cron - // scheduler tick loop (the gateway isn't running under the app). - HERMES_DESKTOP: '1', - HERMES_WEB_DIST: webDist - }, - shell: backend.shell, - stdio: ['ignore', 'pipe', 'pipe'] - })) + hermesProcess = spawn( + backend.command, + backend.args, + hiddenWindowsChildOptions({ + cwd: hermesCwd, + env: { + ...process.env, + // Explicitly pin HERMES_HOME for the child so Python's get_hermes_home() + // resolves to the SAME location our resolveHermesHome() picked. Without + // this pin, Python falls back to ~/.hermes on every platform — fine on + // mac/linux (where our default matches), but on Windows our default is + // %LOCALAPPDATA%\hermes, which differs from C:\Users\\.hermes. + // Mismatch would split config / sessions / .env / logs across two + // directories. install.ps1 sets HERMES_HOME via setx; the desktop + // can't reliably do that, so we set it inline for every spawn. + HERMES_HOME, + ...backend.env, + TERMINAL_CWD: hermesCwd, + HERMES_DASHBOARD_SESSION_TOKEN: token, + // Marks this dashboard backend as desktop-spawned so it runs the cron + // scheduler tick loop (the gateway isn't running under the app). + HERMES_DESKTOP: '1', + HERMES_WEB_DIST: webDist + }, + shell: backend.shell, + stdio: ['ignore', 'pipe', 'pipe'] + }) + ) hermesProcess.stdout.on('data', rememberLog) hermesProcess.stderr.on('data', rememberLog) @@ -4823,7 +4956,6 @@ async function startHermes() { ) hermesProcess = null connectionPromise = null - portPool.release(port) sendBackendExit({ code: null, signal: null, error: error.message }) rejectBackendStart?.(error) }) @@ -4831,7 +4963,6 @@ async function startHermes() { rememberLog(`Hermes backend exited (${signal || code})`) hermesProcess = null connectionPromise = null - portPool.release(port) sendBackendExit({ code, signal }) if (!backendReady) { const message = `Hermes backend exited before it became ready (${signal || code}).` @@ -4852,6 +4983,10 @@ async function startHermes() { } }) + await advanceBootProgress('backend.port', 'Waiting for Hermes backend to launch', 86) + // Discover the ephemeral port the child bound to + const port = await Promise.race([waitForDashboardPort(hermesProcess), backendStartFailed]) + const baseUrl = `http://127.0.0.1:${port}` await advanceBootProgress('backend.wait', 'Waiting for Hermes backend to become ready', 90) await Promise.race([waitForHermes(baseUrl, token), backendStartFailed]) @@ -4891,7 +5026,6 @@ async function startHermes() { { allowDecrease: true } ) connectionPromise = null - portPool.release(reservedPort) throw error }) @@ -4939,21 +5073,29 @@ function focusWindow(win) { } // Open (or focus) a standalone window for a single chat session. -function createSessionWindow(sessionId) { +function createSessionWindow(sessionId, { watch = false } = {}) { return sessionWindows.openOrFocus(sessionId, () => { const icon = getAppIconPath() const win = new BrowserWindow({ - width: 480, - height: 800, - minWidth: 420, - minHeight: 620, + width: SESSION_WINDOW_MIN_WIDTH, + height: SESSION_WINDOW_MIN_HEIGHT, + minWidth: SESSION_WINDOW_MIN_WIDTH, + minHeight: SESSION_WINDOW_MIN_HEIGHT, title: 'Hermes', titleBarStyle: 'hidden', titleBarOverlay: getTitleBarOverlayOptions(), trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined, vibrancy: IS_MAC ? 'sidebar' : undefined, + opacity: windowOpacity(), icon, - backgroundColor: '#f7f7f7', + // Don't show until the renderer's first themed paint is ready. macOS + // `vibrancy` ignores `backgroundColor` and paints a translucent OS + // material (which follows the OS appearance, not the app theme), so a + // dark-themed app on a light-mode Mac flashes white until the renderer + // covers it. ready-to-show fires after the boot-time paint in + // themes/context.tsx, so the window appears already themed. + show: false, + backgroundColor: getWindowBackgroundColor(), webPreferences: { preload: path.join(__dirname, 'preload.cjs'), contextIsolation: true, @@ -4968,6 +5110,10 @@ function createSessionWindow(sessionId) { win.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION) } + win.once('ready-to-show', () => { + if (!win.isDestroyed()) win.show() + }) + win.on('will-enter-full-screen', () => sendWindowStateChanged(true)) win.on('enter-full-screen', () => sendWindowStateChanged(true)) win.on('will-leave-full-screen', () => sendWindowStateChanged(false)) @@ -4978,7 +5124,8 @@ function createSessionWindow(sessionId) { win.loadURL( buildSessionWindowUrl(sessionId, { devServer: DEV_SERVER, - rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex() + rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex(), + watch }) ) @@ -5004,8 +5151,13 @@ function createWindow() { titleBarOverlay: getTitleBarOverlayOptions(), trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined, vibrancy: IS_MAC ? 'sidebar' : undefined, + opacity: windowOpacity(), icon, - backgroundColor: '#f7f7f7', + // Hidden until the first themed paint so macOS `vibrancy` (which ignores + // `backgroundColor` and follows the OS appearance) can't flash a light + // material before the renderer paints the app theme. See createSessionWindow. + show: false, + backgroundColor: getWindowBackgroundColor(), webPreferences: { preload: path.join(__dirname, 'preload.cjs'), contextIsolation: true, @@ -5041,6 +5193,10 @@ function createWindow() { } } + mainWindow.once('ready-to-show', () => { + if (mainWindow && !mainWindow.isDestroyed()) mainWindow.show() + }) + mainWindow.on('will-enter-full-screen', () => sendWindowStateChanged(true)) mainWindow.on('enter-full-screen', () => sendWindowStateChanged(true)) mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false)) @@ -5152,12 +5308,12 @@ ipcMain.handle('hermes:backend:touch', async (_event, profile) => { return { ok: true } }) ipcMain.handle('hermes:gateway:ws-url', async (_event, profile) => freshGatewayWsUrl(profile)) -ipcMain.handle('hermes:window:openSession', async (_event, sessionId) => { +ipcMain.handle('hermes:window:openSession', async (_event, sessionId, opts) => { if (typeof sessionId !== 'string' || !sessionId.trim()) { return { ok: false, error: 'invalid-session-id' } } - createSessionWindow(sessionId.trim()) + createSessionWindow(sessionId.trim(), { watch: opts?.watch === true }) return { ok: true } }) @@ -5453,11 +5609,30 @@ ipcMain.handle('hermes:api', async (_event, request) => { ipcMain.handle('hermes:notify', (_event, payload) => { if (!Notification.isSupported()) return false - new Notification({ + // Action buttons render only on signed macOS builds; elsewhere they're dropped + // and the body click still works. + const actions = Array.isArray(payload?.actions) ? payload.actions : [] + const notification = new Notification({ title: payload?.title || 'Hermes', body: payload?.body || '', - silent: Boolean(payload?.silent) - }).show() + silent: Boolean(payload?.silent), + actions: actions.map(action => ({ type: 'button', text: String(action?.text || '') })) + }) + notification.on('click', () => { + if (!mainWindow || mainWindow.isDestroyed()) return + focusWindow(mainWindow) + if (payload?.sessionId) { + mainWindow.webContents.send('hermes:focus-session', payload.sessionId) + } + }) + notification.on('action', (_actionEvent, index) => { + if (!mainWindow || mainWindow.isDestroyed()) return + const action = actions[index] + if (action?.id) { + mainWindow.webContents.send('hermes:notification-action', { sessionId: payload?.sessionId, actionId: action.id }) + } + }) + notification.show() return true }) @@ -5565,6 +5740,35 @@ ipcMain.on('hermes:titlebar-theme', (_event, payload) => { mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions()) }) +// Pin the native appearance to the app theme (see NATIVE_THEME_CONFIG_PATH). +ipcMain.on('hermes:native-theme', (_event, mode) => { + if (!THEME_SOURCES.has(mode)) { + return + } + + if (nativeTheme.themeSource !== mode) { + nativeTheme.themeSource = mode + writePersistedThemeSource(mode) + } +}) + +// See-through window translucency. Persist + re-apply opacity to every open +// window at runtime (no recreation, so caching/sessions are untouched). +ipcMain.on('hermes:translucency', (_event, payload) => { + const next = clampIntensity(payload && payload.intensity) + + if (next === translucencyIntensity) { + return + } + + translucencyIntensity = next + writePersistedTranslucency(next) + + for (const win of BrowserWindow.getAllWindows()) { + applyWindowTranslucency(win) + } +}) + ipcMain.handle('hermes:openExternal', (_event, url) => { if (!openExternalUrl(url)) { throw new Error('Invalid external URL') @@ -5816,6 +6020,8 @@ ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dir ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath)) +ipcMain.handle('hermes:fs:worktrees', async (_event, cwds) => worktreesForIpc(cwds)) + ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => { if (!nodePty) { throw new Error('PTY support is unavailable. Reinstall desktop dependencies and restart Hermes.') @@ -6002,11 +6208,15 @@ async function getUninstallSummary() { resolve(value) } try { - const child = spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], hiddenWindowsChildOptions({ - cwd: agentRoot, - env: { ...process.env, HERMES_HOME, NO_COLOR: '1' }, - stdio: ['ignore', 'pipe', 'ignore'] - })) + const child = spawn( + py, + ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], + hiddenWindowsChildOptions({ + cwd: agentRoot, + env: { ...process.env, HERMES_HOME, NO_COLOR: '1' }, + stdio: ['ignore', 'pipe', 'ignore'] + }) + ) child.stdout.on('data', chunk => { stdout += chunk.toString() }) @@ -6164,7 +6374,7 @@ let _rendererReadyForDeepLink = false function _extractDeepLink(argv) { if (!Array.isArray(argv)) return null - return argv.find((a) => typeof a === 'string' && a.startsWith(`${HERMES_PROTOCOL}://`)) || null + return argv.find(a => typeof a === 'string' && a.startsWith(`${HERMES_PROTOCOL}://`)) || null } function handleDeepLink(url) { @@ -6208,9 +6418,7 @@ ipcMain.handle('hermes:deep-link-ready', () => { _pendingDeepLink = null handleDeepLink( `${HERMES_PROTOCOL}://${queued.kind}/${encodeURIComponent(queued.name)}` + - (Object.keys(queued.params).length - ? '?' + new URLSearchParams(queued.params).toString() - : ''), + (Object.keys(queued.params).length ? '?' + new URLSearchParams(queued.params).toString() : '') ) } return { ok: true } @@ -6221,9 +6429,7 @@ function registerDeepLinkProtocol() { if (process.defaultApp && process.argv.length >= 2) { // Dev: register with the electron exec path + entry script so the OS can // relaunch us with the URL. - app.setAsDefaultProtocolClient(HERMES_PROTOCOL, process.execPath, [ - path.resolve(process.argv[1]), - ]) + app.setAsDefaultProtocolClient(HERMES_PROTOCOL, process.execPath, [path.resolve(process.argv[1])]) } else { app.setAsDefaultProtocolClient(HERMES_PROTOCOL) } @@ -6256,7 +6462,6 @@ app.on('open-url', (event, url) => { handleDeepLink(url) }) - app.whenReady().then(() => { if (IS_MAC) { Menu.setApplicationMenu(buildApplicationMenu()) diff --git a/apps/desktop/electron/port-pool.cjs b/apps/desktop/electron/port-pool.cjs deleted file mode 100644 index 35131090814..00000000000 --- a/apps/desktop/electron/port-pool.cjs +++ /dev/null @@ -1,73 +0,0 @@ -'use strict' - -/** - * In-process port reservation pool for the desktop backend launcher. - * - * pickPort() probes a localhost port with a throwaway server and closes it - * before the real bind happens in a separate Python child. Between that probe - * and the child's bind there is a TOCTOU window: a second concurrent spawn - * (the primary backend racing a pool backend) can be handed the SAME port, and - * one then dies with EADDRINUSE ("address already in use" -> "Object has been - * destroyed" boot loop). Reserving the chosen port in THIS process until the - * child exits closes that window. - * - * The OS bind remains the source of truth; this only deconflicts racers inside - * this process — it can't stop a foreign squatter, which the probe + the - * EADDRINUSE self-heal still cover. - * - * The pool is dependency-injected (the availability probe is passed in) and - * free of Electron/Node socket I/O, so it is unit-tested without real sockets - * (see port-pool.test.cjs). - */ -class PortPool { - /** - * @param {number} floor inclusive lowest port to hand out - * @param {number} ceiling inclusive highest port to hand out - */ - constructor(floor, ceiling) { - this.floor = floor - this.ceiling = ceiling - this._reserved = new Set() - } - - /** @returns {boolean} whether `port` is currently reserved in-process. */ - has(port) { - return this._reserved.has(port) - } - - /** Release a previously reserved port. No-op if it was not reserved. */ - release(port) { - this._reserved.delete(port) - } - - /** Drop all reservations. */ - clear() { - this._reserved.clear() - } - - /** @returns {number} count of currently reserved ports. */ - get size() { - return this._reserved.size - } - - /** - * Reserve and return the lowest port in [floor, ceiling] that is neither - * already reserved in-process nor rejected by `isAvailable(port)`, or null - * if every port is taken. `isAvailable` may be sync (boolean) or async - * (Promise); it is awaited either way. - * - * @param {(port: number) => boolean | Promise} isAvailable - * @returns {Promise} - */ - async reserve(isAvailable) { - for (let port = this.floor; port <= this.ceiling; port += 1) { - if (this._reserved.has(port)) continue - if (!(await isAvailable(port))) continue - this._reserved.add(port) - return port - } - return null - } -} - -module.exports = { PortPool } diff --git a/apps/desktop/electron/port-pool.test.cjs b/apps/desktop/electron/port-pool.test.cjs deleted file mode 100644 index f2600ce7d5f..00000000000 --- a/apps/desktop/electron/port-pool.test.cjs +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Tests for electron/port-pool.cjs. - * - * Run with: node --test electron/port-pool.test.cjs - * - * PortPool is the in-process reservation that closes the pickPort() TOCTOU - * window. These cover selection order, skipping reserved/unavailable ports, - * release/reuse, exhaustion, and async probes — without real sockets. - */ - -const test = require('node:test') -const assert = require('node:assert/strict') - -const { PortPool } = require('./port-pool.cjs') - -const allFree = () => true - -test('reserve returns the lowest free port and reserves it', async () => { - const pool = new PortPool(9120, 9199) - const port = await pool.reserve(allFree) - assert.equal(port, 9120) - assert.ok(pool.has(9120)) - assert.equal(pool.size, 1) -}) - -test('reserve skips ports already reserved in-process', async () => { - const pool = new PortPool(9120, 9199) - const first = await pool.reserve(allFree) - const second = await pool.reserve(allFree) - assert.equal(first, 9120) - assert.equal(second, 9121) -}) - -test('reserve skips ports the probe rejects', async () => { - const pool = new PortPool(9120, 9199) - const busy = new Set([9120, 9121]) - const port = await pool.reserve(p => !busy.has(p)) - assert.equal(port, 9122) -}) - -test('reserve returns null when every port is taken', async () => { - const pool = new PortPool(9120, 9121) - await pool.reserve(allFree) - await pool.reserve(allFree) - assert.equal(await pool.reserve(allFree), null) -}) - -test('release frees a reserved port for reuse', async () => { - const pool = new PortPool(9120, 9120) - assert.equal(await pool.reserve(allFree), 9120) - assert.equal(await pool.reserve(allFree), null) // exhausted - pool.release(9120) - assert.ok(!pool.has(9120)) - assert.equal(await pool.reserve(allFree), 9120) // reusable -}) - -test('release is a no-op for an unreserved port', () => { - const pool = new PortPool(9120, 9199) - pool.release(9120) - assert.equal(pool.size, 0) -}) - -test('reserve awaits an async probe', async () => { - const pool = new PortPool(9120, 9199) - const busy = new Set([9120]) - const port = await pool.reserve(p => Promise.resolve(!busy.has(p))) - assert.equal(port, 9121) -}) - -test('clear drops all reservations', async () => { - const pool = new PortPool(9120, 9199) - await pool.reserve(allFree) - await pool.reserve(allFree) - assert.equal(pool.size, 2) - pool.clear() - assert.equal(pool.size, 0) -}) diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index 9880d4bcf58..544037e7869 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -5,7 +5,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { revalidateConnection: () => ipcRenderer.invoke('hermes:connection:revalidate'), touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile), getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile), - openSessionWindow: sessionId => ipcRenderer.invoke('hermes:window:openSession', sessionId), + openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts), getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'), getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile), saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload), @@ -39,6 +39,8 @@ contextBridge.exposeInMainWorld('hermesDesktop', { watchPreviewFile: url => ipcRenderer.invoke('hermes:watchPreviewFile', url), stopPreviewFileWatch: id => ipcRenderer.invoke('hermes:stopPreviewFileWatch', id), setTitleBarTheme: payload => ipcRenderer.send('hermes:titlebar-theme', payload), + setNativeTheme: mode => ipcRenderer.send('hermes:native-theme', mode), + setTranslucency: payload => ipcRenderer.send('hermes:translucency', payload), setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)), openExternal: url => ipcRenderer.invoke('hermes:openExternal', url), fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url), @@ -52,6 +54,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { getRecentLogs: () => ipcRenderer.invoke('hermes:logs:recent'), readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath), gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath), + worktrees: cwds => ipcRenderer.invoke('hermes:fs:worktrees', cwds), terminal: { dispose: id => ipcRenderer.invoke('hermes:terminal:dispose', id), resize: (id, size) => ipcRenderer.invoke('hermes:terminal:resize', id, size), @@ -91,6 +94,16 @@ contextBridge.exposeInMainWorld('hermesDesktop', { ipcRenderer.on('hermes:window-state-changed', listener) return () => ipcRenderer.removeListener('hermes:window-state-changed', listener) }, + onFocusSession: callback => { + const listener = (_event, sessionId) => callback(sessionId) + ipcRenderer.on('hermes:focus-session', listener) + return () => ipcRenderer.removeListener('hermes:focus-session', listener) + }, + onNotificationAction: callback => { + const listener = (_event, payload) => callback(payload) + ipcRenderer.on('hermes:notification-action', listener) + return () => ipcRenderer.removeListener('hermes:notification-action', listener) + }, onPreviewFileChanged: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:preview-file-changed', listener) diff --git a/apps/desktop/electron/session-windows.cjs b/apps/desktop/electron/session-windows.cjs index 8775feb1bce..172ca16c757 100644 --- a/apps/desktop/electron/session-windows.cjs +++ b/apps/desktop/electron/session-windows.cjs @@ -5,22 +5,30 @@ const { pathToFileURL } = require('node:url') +// Secondary windows open at the minimum usable size — a compact side panel for +// subagent watch / cmd-click session pop-out, not a second full desktop. +const SESSION_WINDOW_MIN_WIDTH = 420 +const SESSION_WINDOW_MIN_HEIGHT = 620 + // Build the renderer URL for a secondary window. The renderer uses a // HashRouter, so the session route lives after the '#'. The `?win=secondary` // flag MUST sit in the query string BEFORE the '#': anything after the '#' is // treated as the route by HashRouter and would break routeSessionId(). The // renderer reads the flag from window.location.search to suppress the install / -// onboarding overlays and the global session sidebar. -function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath } = {}) { +// onboarding overlays and the global session sidebar. `watch=1` marks a +// spectator window (e.g. a running subagent's session): the renderer resumes +// it lazily so the gateway never builds an agent just to stream into it. +function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath, watch } = {}) { + const query = `?win=secondary${watch ? '&watch=1' : ''}` const route = `#/${encodeURIComponent(sessionId)}` if (devServer) { const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer - return `${base}/?win=secondary${route}` + return `${base}/${query}${route}` } - return `${pathToFileURL(rendererIndexPath).toString()}?win=secondary${route}` + return `${pathToFileURL(rendererIndexPath).toString()}${query}${route}` } // A small registry keyed by sessionId that guarantees one window per chat: @@ -83,4 +91,9 @@ function createSessionWindowRegistry() { } } -module.exports = { buildSessionWindowUrl, createSessionWindowRegistry } +module.exports = { + buildSessionWindowUrl, + createSessionWindowRegistry, + SESSION_WINDOW_MIN_HEIGHT, + SESSION_WINDOW_MIN_WIDTH +} diff --git a/apps/desktop/electron/session-windows.test.cjs b/apps/desktop/electron/session-windows.test.cjs index 3453971eb51..a668b0ac082 100644 --- a/apps/desktop/electron/session-windows.test.cjs +++ b/apps/desktop/electron/session-windows.test.cjs @@ -76,6 +76,12 @@ test('buildSessionWindowUrl builds a packaged file URL with the flag before the assert.match(url, /^file:\/\/.*index\.html\?win=secondary#\/abc$/) }) +test('buildSessionWindowUrl adds the watch flag for spectator windows, before the hash', () => { + const url = buildSessionWindowUrl('abc', { devServer: 'http://localhost:5173', watch: true }) + + assert.equal(url, 'http://localhost:5173/?win=secondary&watch=1#/abc') +}) + test('registry opens one window per session and focuses on re-open', () => { const registry = createSessionWindowRegistry() let built = 0 diff --git a/apps/desktop/electron/windows-child-process.test.cjs b/apps/desktop/electron/windows-child-process.test.cjs index 92989c978bb..4239da56e23 100644 --- a/apps/desktop/electron/windows-child-process.test.cjs +++ b/apps/desktop/electron/windows-child-process.test.cjs @@ -42,6 +42,9 @@ test('intentional or interactive desktop child processes stay documented', () => const source = readElectronFile('main.cjs') assert.match(source, /windowsHide: false/) + assert.match(source, /handOffWindowsBootstrapRecovery/) + assert.match(source, /'--repair', '--branch'/) + assert.match(source, /'--update', '--branch'/) assert.match(source, /nodePty\.spawn\(command, args/) assert.match(source, /spawn\('cmd\.exe', \['\/c', 'start'/) }) diff --git a/apps/desktop/index.html b/apps/desktop/index.html index 0ef2dcb59ab..831478f6e93 100644 --- a/apps/desktop/index.html +++ b/apps/desktop/index.html @@ -9,6 +9,28 @@ Hermes +
diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a552f950f20..52be586f013 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -18,7 +18,8 @@ "profile:main": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .", "profile:main:cpu": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .", "start": "npm run build && electron .", - "build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && node scripts/assert-dist-built.cjs", + "build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && npm run postbuild", + "postbuild": "node scripts/assert-dist-built.cjs", "builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 electron-builder", "pack": "npm run build && npm run builder -- --dir", "dist": "npm run build && npm run builder", @@ -35,7 +36,7 @@ "test:desktop:nsis": "node scripts/test-desktop.mjs nsis", "test:desktop:existing": "node scripts/test-desktop.mjs existing", "test:desktop:fresh": "node scripts/test-desktop.mjs fresh", - "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/port-pool.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs", + "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs", "typecheck": "tsc -p . --noEmit", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix", @@ -89,6 +90,7 @@ "react-router-dom": "^7.17.0", "react-shiki": "^0.9.3", "remark-math": "^6.0.0", + "remend": "^1.3.0", "shiki": "^4.0.2", "streamdown": "^2.5.0", "tailwind-merge": "^3.5.0", @@ -97,6 +99,7 @@ "unicode-animations": "^1.0.3", "unified": "^11.0.5", "unist-util-visit-parents": "^6.0.2", + "use-stick-to-bottom": "^1.1.6", "vfile": "^6.0.3", "web-haptics": "^0.0.6" }, diff --git a/apps/desktop/src/app/agents/index.tsx b/apps/desktop/src/app/agents/index.tsx index ff0aa8fb654..ec8f186dd1b 100644 --- a/apps/desktop/src/app/agents/index.tsx +++ b/apps/desktop/src/app/agents/index.tsx @@ -3,8 +3,8 @@ import { type ReactNode, useEffect, useMemo, useState } from 'react' import { useElapsedSeconds } from '@/components/chat/activity-timer' import { ActivityTimerText } from '@/components/chat/activity-timer-text' -import { BrailleSpinner } from '@/components/ui/braille-spinner' import { FadeText } from '@/components/ui/fade-text' +import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { type Translations, useI18n } from '@/i18n' import { AlertCircle, CheckCircle2, Sparkles } from '@/lib/icons' import { useEnterAnimation } from '@/lib/use-enter-animation' @@ -25,7 +25,7 @@ import { OverlayView } from '../overlays/overlay-view' function statusGlyph(status: SubagentStatus, a: Translations['agents']): ReactNode { if (status === 'running' || status === 'queued') { return ( - {entry.text} {active ? ( - 0 ? (
-

{t.agents.files}

+

+ {t.agents.files} +

{fileLines.slice(0, 8).map(line => (

{line} diff --git a/apps/desktop/src/app/chat/composer/completion-drawer.tsx b/apps/desktop/src/app/chat/composer/completion-drawer.tsx index d7738cb82a7..021af0bda56 100644 --- a/apps/desktop/src/app/chat/composer/completion-drawer.tsx +++ b/apps/desktop/src/app/chat/composer/completion-drawer.tsx @@ -2,25 +2,21 @@ import type { Unstable_TriggerAdapter } from '@assistant-ui/core' import { ComposerPrimitive } from '@assistant-ui/react' import type { ReactNode } from 'react' -export const COMPLETION_DRAWER_CLASS = [ - 'absolute bottom-[calc(100%+0.375rem)] left-0 z-50', - 'w-80 max-w-[calc(100vw-2rem)]', - 'max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain', - 'rounded-xl border border-(--ui-stroke-secondary)', - 'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_97%,transparent)]', - 'p-1 text-xs text-popover-foreground shadow-lg', - 'backdrop-blur-md' -].join(' ') +import { composerFusedDockCard } from '@/components/chat/composer-dock' +import { cn } from '@/lib/utils' -export const COMPLETION_DRAWER_BELOW_CLASS = [ - 'absolute left-0 top-[calc(100%+0.375rem)] z-50', - 'w-80 max-w-[calc(100vw-2rem)]', - 'max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain', - 'rounded-xl border border-(--ui-stroke-secondary)', - 'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_97%,transparent)]', - 'p-1 text-xs text-popover-foreground shadow-lg', - 'backdrop-blur-md' -].join(' ') +// Same docked chrome as the queue/status stack, but its own thing: a narrow, +// left-aligned card (not full width) that fuses to the composer's edge instead +// of floating above it. `left-1` matches the stack's `mx-1` inset; the negative +// margin overlaps the seam so the composer's (now-transparent) edge border reads +// as shared. Fused (opaque) fill — the composer surface swaps to the same fill +// while a drawer is open, so the two paint as one panel. +const DRAWER_SHELL = + 'absolute left-1 z-50 w-80 max-w-[calc(100%-0.5rem)] max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain p-1 text-xs text-popover-foreground' + +export const COMPLETION_DRAWER_CLASS = cn(DRAWER_SHELL, 'bottom-full -mb-[9px]', composerFusedDockCard('top')) + +export const COMPLETION_DRAWER_BELOW_CLASS = cn(DRAWER_SHELL, 'top-full -mt-[9px]', composerFusedDockCard('bottom')) export function ComposerCompletionDrawer({ adapter, diff --git a/apps/desktop/src/app/chat/composer/context-menu.tsx b/apps/desktop/src/app/chat/composer/context-menu.tsx index 3f09ec2fccb..22c10985f82 100644 --- a/apps/desktop/src/app/chat/composer/context-menu.tsx +++ b/apps/desktop/src/app/chat/composer/context-menu.tsx @@ -11,6 +11,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { Kbd } from '@/components/ui/kbd' import { useI18n } from '@/i18n' import { Clipboard, FileText, FolderOpen, type IconComponent, ImageIcon, Link, MessageSquareText } from '@/lib/icons' import { cn } from '@/lib/utils' @@ -86,7 +87,7 @@ export function ContextMenu({

{c.tipPre} - @ + @ {c.tipPost}
diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx index ed65795d1c4..8bc1a2b7cf9 100644 --- a/apps/desktop/src/app/chat/composer/controls.tsx +++ b/apps/desktop/src/app/chat/composer/controls.tsx @@ -1,5 +1,6 @@ import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' +import { KbdCombo } from '@/components/ui/kbd' import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' @@ -63,7 +64,14 @@ export function ComposerControls({ }) { const { t } = useI18n() const c = t.composer - const steerLabel = `${c.steer} (${formatCombo('mod+enter')})` + const steerCombo = formatCombo('mod+enter') + const steerLabel = `${c.steer} (${steerCombo})` + const steerTip = ( + + {c.steer} + + + ) if (conversation.active) { return @@ -75,7 +83,7 @@ export function ComposerControls({
{canSteer && ( - +
) } + +function HotkeyRow({ combos, description }: { combos: string[]; description: string }) { + return ( +
+ + {combos.map(combo => ( + + ))} + + {description} +
+ ) +} diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 94e80d6bec3..dc3f0a490cb 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -14,6 +14,7 @@ import { } from 'react' import { hermesDirectiveFormatter, type SlashChipKind } from '@/components/assistant-ui/directive-text' +import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-dock' import { Button } from '@/components/ui/button' import { useMediaQuery } from '@/hooks/use-media-query' import { useResizeObserver } from '@/hooks/use-resize-observer' @@ -42,12 +43,16 @@ import { import { $queuedPromptsBySession, enqueueQueuedPrompt, + MAX_AUTO_DRAIN_ATTEMPTS, + migrateQueuedPrompts, promoteQueuedPrompt, type QueuedPromptEntry, removeQueuedPrompt, - shouldAutoDrainOnSettle, + shouldAutoDrain, updateQueuedPrompt } from '@/store/composer-queue' +import { $statusItemsBySession } from '@/store/composer-status' +import { notify } from '@/store/notifications' import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session' import { $threadScrolledUp } from '@/store/thread-scroll' import { useTheme } from '@/themes' @@ -80,12 +85,16 @@ import { import { QueuePanel } from './queue-panel' import { composerPlainText, + deleteSelectionInEditor, + insertPlainTextAtCaret, + normalizeComposerEditorDom, placeCaretEnd, refChipElement, renderComposerContents, RICH_INPUT_SLOT, slashChipElement } from './rich-editor' +import { ComposerStatusStack } from './status-stack' import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils' import { ComposerTriggerPopover } from './trigger-popover' import type { ChatBarProps } from './types' @@ -128,6 +137,12 @@ function slashChipKindForItem(item: Unstable_TriggerItem): SlashChipKind { return 'command' } +/** A `/` query is at its arg stage once it's past the command name. */ +const slashArgStage = (query: string) => query.includes(' ') + +/** The `/command` token of a slash query (`personality x` → `/personality`). */ +const slashCommandToken = (query: string) => `/${query.split(/\s+/, 1)[0]?.toLowerCase() ?? ''}` + interface QueueEditState { attachments: ComposerAttachment[] draft: string @@ -168,8 +183,8 @@ export function ChatBar({ const draft = useAuiState(s => s.composer.text) const attachments = useStore($composerAttachments) const queuedPromptsBySession = useStore($queuedPromptsBySession) + const statusItemsBySession = useStore($statusItemsBySession) const scrolledUp = useStore($threadScrolledUp) - const sessionMessages = useStore($messages) const activeQueueSessionKey = queueSessionKey || sessionId || null const queuedPrompts = useMemo( @@ -177,15 +192,29 @@ export function ChatBar({ [activeQueueSessionKey, queuedPromptsBySession] ) + // Status items (subagents, background processes) are keyed by the RUNTIME + // session id — gateway events and process.list both speak that id. Only the + // queue uses the stored-session fallback key (prompts can queue pre-resume). + const statusSessionId = sessionId ?? null + + const statusStackVisible = useMemo( + () => + queuedPrompts.length > 0 || (statusSessionId ? (statusItemsBySession[statusSessionId]?.length ?? 0) > 0 : false), + [queuedPrompts.length, statusItemsBySession, statusSessionId] + ) + const composerRef = useRef(null) const composerSurfaceRef = useRef(null) const editorRef = useRef(null) const draftRef = useRef(draft) - const previousBusyRef = useRef(busy) const pendingDraftPersistRef = useRef<{ scope: string | null; text: string } | null>(null) const activeQueueSessionKeyRef = useRef(activeQueueSessionKey) activeQueueSessionKeyRef.current = activeQueueSessionKey + const prevQueueKeyRef = useRef(activeQueueSessionKey) const drainingQueueRef = useRef(false) + // Per-entry auto-drain failure counts; bounds retries so a persistent 404 + // can't spin-loop. Cleared on success; reset naturally on remount/reconnect. + const drainFailuresRef = useRef(new Map()) const urlInputRef = useRef(null) const [urlOpen, setUrlOpen] = useState(false) @@ -226,6 +255,8 @@ export function ChatBar({ const gatewayState = useStore($gatewayState) const newSessionPlaceholders = t.composer.newSessionPlaceholders const followUpPlaceholders = t.composer.followUpPlaceholders + const reconnecting = gatewayState === 'closed' || gatewayState === 'error' + const inputDisabled = disabled && !reconnecting // Resting placeholder: a starter for brand-new sessions, a continuation for // existing ones. Picked once and only re-rolled when we genuinely move to a @@ -256,11 +287,13 @@ export function ChatBar({ setRestingPlaceholder(pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders)) }, [followUpPlaceholders, newSessionPlaceholders, sessionId]) - // When the bar is disabled it's because the gateway isn't open. Distinguish a - // cold start ("Starting Hermes...") from a dropped connection we're trying to - // restore (e.g. after the Mac slept) so the stuck state reads as recoverable. + // When the transport is disabled it's because the gateway isn't open. + // Distinguish a cold start ("Starting Hermes...") from a dropped connection + // we're trying to restore. During reconnect, keep the textbox editable so a + // flaky network doesn't block drafting; only submit/backend actions stay + // disabled until the gateway is open again. const placeholder = disabled - ? gatewayState === 'closed' || gatewayState === 'error' + ? reconnecting ? t.composer.placeholderReconnecting : t.composer.placeholderStarting : restingPlaceholder @@ -302,13 +335,13 @@ export function ChatBar({ ) useEffect(() => { - if (!disabled) { + if (!inputDisabled) { focusInput() } - }, [disabled, focusInput, focusKey, focusRequestId]) + }, [focusInput, focusKey, focusRequestId, inputDisabled]) useEffect(() => { - if (disabled) { + if (inputDisabled) { return undefined } @@ -328,7 +361,7 @@ export function ChatBar({ offFocus() offInsert() } - }, [appendExternalText, disabled]) + }, [appendExternalText, inputDisabled]) // Keep draftRef in sync with the assistant-ui composer state for callers // that read the latest text outside the React render cycle. We don't push @@ -507,48 +540,6 @@ export function ChatBar({ }) }, []) - const handlePaste = (event: ClipboardEvent) => { - const imageBlobs = extractClipboardImageBlobs(event.clipboardData) - - if (imageBlobs.length > 0) { - event.preventDefault() - - if (onAttachImageBlob) { - triggerHaptic('selection') - - for (const blob of imageBlobs) { - void onAttachImageBlob(blob) - } - } - - return - } - - // Trim surrounding whitespace so a copy that dragged along leading/trailing - // blank lines (common when selecting from terminals, code blocks, web pages) - // doesn't dump multiline padding into the composer. Internal newlines are - // preserved — only the edges are cleaned up. - const pastedText = event.clipboardData.getData('text').trim() - - if (!pastedText) { - event.preventDefault() - - return - } - - if (DATA_IMAGE_URL_RE.test(pastedText)) { - event.preventDefault() - - return - } - - event.preventDefault() - document.execCommand('insertText', false, pastedText) - const nextDraft = composerPlainText(event.currentTarget) - draftRef.current = nextDraft - aui.composer().setText(nextDraft) - } - const [trigger, setTrigger] = useState(null) const [triggerActive, setTriggerActive] = useState(0) const [triggerItems, setTriggerItems] = useState([]) @@ -585,7 +576,15 @@ export function ChatBar({ } const before = textBeforeCaret(editor) - const detected = detectTrigger(before ?? composerPlainText(editor)) + const found = detectTrigger(before ?? composerPlainText(editor)) + + // The arg-stage popover is only useful for commands with an options screen. + // For a no-arg command it would dead-end on "No matches", so drop it — the + // directive is already complete. + const detected = + found?.kind === '/' && slashArgStage(found.query) && !desktopSlashCommandTakesArgs(slashCommandToken(found.query)) + ? null + : found setTrigger(detected) @@ -602,9 +601,7 @@ export function ChatBar({ // (which drives `hasComposerPayload` → the send button). Shared by the input // and compositionend paths so committed IME text reaches state through either. const flushEditorToDraft = (editor: HTMLDivElement) => { - if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') { - editor.replaceChildren() - } + normalizeComposerEditorDom(editor) const nextDraft = composerPlainText(editor) @@ -627,6 +624,46 @@ export function ChatBar({ flushEditorToDraft(event.currentTarget) } + const handlePaste = (event: ClipboardEvent) => { + const imageBlobs = extractClipboardImageBlobs(event.clipboardData) + + if (imageBlobs.length > 0) { + event.preventDefault() + + if (onAttachImageBlob) { + triggerHaptic('selection') + + for (const blob of imageBlobs) { + void onAttachImageBlob(blob) + } + } + + return + } + + // Trim surrounding whitespace so a copy that dragged along leading/trailing + // blank lines (common when selecting from terminals, code blocks, web pages) + // doesn't dump multiline padding into the composer. Internal newlines are + // preserved — only the edges are cleaned up. + const pastedText = event.clipboardData.getData('text').trim() + + if (!pastedText) { + event.preventDefault() + + return + } + + if (DATA_IMAGE_URL_RE.test(pastedText)) { + event.preventDefault() + + return + } + + event.preventDefault() + insertPlainTextAtCaret(event.currentTarget, pastedText) + flushEditorToDraft(event.currentTarget) + } + const triggerAdapter: Unstable_TriggerAdapter | null = trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null @@ -642,6 +679,12 @@ export function ChatBar({ const triggerLoading = trigger?.kind === '@' ? at.loading : trigger?.kind === '/' ? slash.loading : false + // Suppress the "No matches" empty state once a slash command is past its name: + // a no-arg command has nothing to offer, and a fully-typed arg commits on + // Space/Tab — neither should dead-end on a popover. + const argStageEmpty = + trigger?.kind === '/' && slashArgStage(trigger.query) && !triggerLoading && !triggerItems.length + const closeTrigger = () => { setTrigger(null) setTriggerItems([]) @@ -652,6 +695,25 @@ export function ChatBar({ setTriggerActive(idx => Math.min(idx, Math.max(0, triggerItems.length - 1))) }, [triggerItems.length]) + // Commit the literally-typed `/command arg` as a directive chip — used when + // the completion list is empty because the arg is already fully typed (the + // backend completer drops exact matches). Reuses the chip path via a + // synthetic item whose serialized form is the verbatim text. + const commitTypedSlashDirective = () => { + if (trigger?.kind !== '/') { + return + } + + const text = `/${trigger.query.trimEnd()}` + + replaceTriggerWithChip({ + id: text, + type: 'slash', + label: text.slice(1), + metadata: { command: slashCommandToken(trigger.query), display: text, meta: '', group: '', action: '', rawText: text } + }) + } + const replaceTriggerWithChip = (item: Unstable_TriggerItem) => { const editor = editorRef.current @@ -688,8 +750,7 @@ export function ChatBar({ // already an arg pick (`/personality alice`), so it commits normally. const command = (item.metadata as { command?: string } | undefined)?.command ?? '' - const expandsToArgs = - trigger.kind === '/' && !serialized.includes(' ') && desktopSlashCommandTakesArgs(command) + const expandsToArgs = trigger.kind === '/' && !serialized.includes(' ') && desktopSlashCommandTakesArgs(command) const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} ` const directive = !starter && serialized.match(/^@([^:]+):(.+)$/) @@ -771,6 +832,18 @@ export function ChatBar({ return } + // Non-collapsed Backspace/Delete: native selection-delete is ~O(n²) on large + // drafts (Ctrl+A → Delete froze ~1.3s). Collapsed carets fall through. + if ( + (event.key === 'Backspace' || event.key === 'Delete') && + deleteSelectionInEditor(event.currentTarget) + ) { + event.preventDefault() + flushEditorToDraft(event.currentTarget) + + return + } + // Cmd/Ctrl+Shift+K drains the next queued message. Plain Cmd/Ctrl+K is // reserved for the global command palette. if ((event.metaKey || event.ctrlKey) && !event.altKey && event.shiftKey && event.key.toLowerCase() === 'k') { @@ -800,7 +873,15 @@ export function ChatBar({ return } - if (event.key === 'Enter' || event.key === 'Tab') { + // Enter / Tab / Space all accept the highlighted item: a no-arg command + // commits its directive chip, an arg-taking command expands to its + // options step, and an arg option commits the full `/cmd arg` chip. Space + // is slash-only (an `@` mention takes a literal space) and gated to a + // non-empty query so a bare `/ ` still types a space. + const acceptOnSpace = event.key === ' ' && trigger.kind === '/' && Boolean(trigger.query.trim()) + const accept = event.key === 'Enter' || event.key === 'Tab' || acceptOnSpace + + if (accept) { event.preventDefault() triggerKeyConsumedRef.current = true const item = triggerItems[triggerActive] @@ -821,6 +902,24 @@ export function ChatBar({ } } + // Arg stage with nothing left to suggest — a fully-typed arg the backend + // completer no longer echoes (it drops the exact match), e.g. + // `/personality creative`. Space/Tab still commit what's typed as a single + // directive chip; Enter falls through to submit (send it as-is). + if ( + trigger?.kind === '/' && + !triggerItems.length && + (event.key === ' ' || event.key === 'Tab') && + slashArgStage(trigger.query) && + trigger.query.trim() + ) { + event.preventDefault() + triggerKeyConsumedRef.current = true + commitTypedSlashDirective() + + return + } + // ArrowUp/ArrowDown navigate, in priority order: the queue (edit entries in // place) then sent-message history. The history ring is derived from live // session messages each press — single source of truth, no mirror. @@ -853,7 +952,9 @@ export function ChatBar({ event.preventDefault() triggerKeyConsumedRef.current = true - const history = deriveUserHistory(sessionMessages, chatMessageText) + // $messages is read imperatively (not subscribed) so the composer + // doesn't re-render on every streaming delta flush. + const history = deriveUserHistory($messages.get(), chatMessageText) const entry = browseBackward(sessionId, currentDraft, history) if (entry !== null) { @@ -878,7 +979,7 @@ export function ChatBar({ event.preventDefault() triggerKeyConsumedRef.current = true - const history = deriveUserHistory(sessionMessages, chatMessageText) + const history = deriveUserHistory($messages.get(), chatMessageText) const result = browseForward(sessionId, history) if (result !== null) { @@ -914,6 +1015,10 @@ export function ChatBar({ const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0 + if (disabled) { + return + } + if (!busy && !hasLivePayload && queuedPrompts.length > 0) { void drainNextQueued() @@ -1113,11 +1218,8 @@ export function ChatBar({ } } - const stashAt = ( - scope: string | null, - text = draftRef.current, - attachments = $composerAttachments.get() - ) => stashSessionDraft(scope, text, attachments) + const stashAt = (scope: string | null, text = draftRef.current, attachments = $composerAttachments.get()) => + stashSessionDraft(scope, text, attachments) // Per-thread draft swap — the composer's only session coupling. Lifecycle // never clears composer state; this effect alone stashes on leave, restores @@ -1315,6 +1417,7 @@ export function ChatBar({ return false } + drainFailuresRef.current.delete(entry.id) removeQueuedPrompt(activeQueueSessionKey, entry.id) resetBrowseState(sessionId) @@ -1326,16 +1429,17 @@ export function ChatBar({ [activeQueueSessionKey, onSubmit, queuedPrompts, sessionId] ) - const drainNextQueued = useCallback( - () => - runDrain(entries => { - const skip = queueEdit?.entryId + const pickDrainHead = useCallback( + (entries: QueuedPromptEntry[]) => { + const skip = queueEditRef.current?.entryId - return skip ? entries.find(e => e.id !== skip) : entries[0] - }), - [queueEdit, runDrain] + return skip ? entries.find(e => e.id !== skip) : entries[0] + }, + [] // reads the edit id off a ref so the lock-holder always sees the latest ) + const drainNextQueued = useCallback(() => runDrain(pickDrainHead), [pickDrainHead, runDrain]) + const sendQueuedNow = useCallback( (id: string) => { if (!activeQueueSessionKey || id === queueEdit?.entryId) { @@ -1353,30 +1457,76 @@ export function ChatBar({ return true } + // A manual send clears the auto-drain backoff so a stuck entry the user + // taps gets a fresh attempt (and re-enables auto-retry on success). + drainFailuresRef.current.delete(id) + return runDrain(entries => entries.find(e => e.id === id)) }, [activeQueueSessionKey, busy, onCancel, queueEdit, runDrain] ) - // Auto-drain on busy → false (turn settled). Queued turns always flow once - // the session is idle again — whether the turn finished naturally or the - // user interrupted it. Interrupting to reach a queued message is the whole - // point of the queue, so we never suppress the drain. To cancel queued - // turns, the user deletes them from the panel. - useEffect(() => { - const wasBusy = previousBusyRef.current - previousBusyRef.current = busy - - if ( - shouldAutoDrainOnSettle({ - isBusy: busy, - queueLength: queuedPrompts.length, - wasBusy - }) - ) { - void drainNextQueued() + // Edge-independent auto-drain: send the head whenever the session is idle and + // the queue is non-empty, bounding retries so a thrown/rejected onSubmit (e.g. + // a stale-session 404) can't strand the entry permanently nor spin-loop. The + // drain lock serializes sends; a remount/reconnect resets the failure counts. + const autoDrainNext = useCallback(() => { + if (busy || drainingQueueRef.current || !activeQueueSessionKey) { + return } - }, [busy, drainNextQueued, queuedPrompts.length]) + + const entry = pickDrainHead(queuedPrompts) + + if (!entry || (drainFailuresRef.current.get(entry.id) ?? 0) >= MAX_AUTO_DRAIN_ATTEMPTS) { + return + } + + const onFail = () => { + const fails = (drainFailuresRef.current.get(entry.id) ?? 0) + 1 + drainFailuresRef.current.set(entry.id, fails) + + if (fails >= MAX_AUTO_DRAIN_ATTEMPTS) { + notify({ + id: 'composer-queue-stuck', + kind: 'error', + title: t.composer.queueStuckTitle, + message: t.composer.queueStuckBody + }) + } + } + + void runDrain(() => entry) + .then(sent => { + if (!sent) { + onFail() + } + }) + .catch(onFail) + }, [activeQueueSessionKey, busy, pickDrainHead, queuedPrompts, runDrain, t]) + + // Re-key on a runtime session-id change. A stable stored id (queueSessionKey) + // never churns, so a change there is a real session switch and must NOT + // migrate; only the runtime-derived key (queueSessionKey falsy → key is + // sessionId) churns on a backend bounce/resume of the same conversation. + useEffect(() => { + const prev = prevQueueKeyRef.current + prevQueueKeyRef.current = activeQueueSessionKey + + if (queueSessionKey || !prev || !activeQueueSessionKey || prev === activeQueueSessionKey) { + return + } + + migrateQueuedPrompts(prev, activeQueueSessionKey) + }, [activeQueueSessionKey, queueSessionKey]) + + // Queued turns flow whenever the session is idle — on the busy→false settle + // edge, on mount/reconnect, and after a re-key — so a swallowed edge can't + // strand them. To cancel queued turns, the user deletes them from the panel. + useEffect(() => { + if (shouldAutoDrain({ isBusy: busy, queueLength: queuedPrompts.length })) { + autoDrainNext() + } + }, [autoDrainNext, busy, queuedPrompts.length]) // Queue-edit cleanup: on session swap the scope effect already stashed the // edit snapshot; only restore into the composer when still on the same scope. @@ -1411,6 +1561,10 @@ export function ChatBar({ } const submitDraft = () => { + if (disabled) { + return + } + // Source the text from the DOM editor, not React state. The AUI composer // state (`draft`) and the derived `hasComposerPayload` lag the DOM by a // render, so on fast typing or IME composition the final keystroke(s) may @@ -1591,6 +1745,7 @@ export function ChatBar({ const input = (
window.setTimeout(closeTrigger, 80)} @@ -1669,6 +1824,7 @@ export function ChatBar({ className="group/composer absolute bottom-0 left-1/2 z-30 w-[min(var(--composer-width),calc(100%-2rem))] max-w-full -translate-x-1/2 rounded-2xl pt-2 pb-[var(--composer-shell-pad-block-end)]" data-drag-active={dragActive ? '' : undefined} data-slot="composer-root" + data-status-stack={statusStackVisible ? '' : undefined} data-thread-scrolled-up={scrolledUp ? '' : undefined} onDragEnter={handleDragEnter} onDragLeave={handleDragLeave} @@ -1686,7 +1842,7 @@ export function ChatBar({ ref={composerRef} > {showHelpHint && } - {trigger && ( + {trigger && !argStageEmpty && ( )} - {activeQueueSessionKey && queuedPrompts.length > 0 && ( - // Out of flow so the queue never inflates the composer's measured - // height (that drives thread bottom padding → chat resizes on - // queue). Overlaps -mb-2 onto the surface's top border for a shared - // edge; capped + scrollable. Overlays the chat instead of pushing it. -
- { - if (removeQueuedPrompt(activeQueueSessionKey, id) && queueEdit?.entryId === id) { - exitQueuedEdit('cancel') - } - }} - onEdit={beginQueuedEdit} - onSendNow={id => void sendQueuedNow(id)} - /> -
- )} + {/* Session-scoped status stack (todos, subagents, background tasks, + queue). Out of flow so it never inflates the composer's measured + height; it overlays the chat instead of pushing it, and publishes + its own --status-stack-measured-height so the thread's clearance + accounts for it. Collapses to nothing when every status is empty. */} + 0 ? ( + { + if (removeQueuedPrompt(activeQueueSessionKey, id) && queueEdit?.entryId === id) { + exitQueuedEdit('cancel') + } + }} + onEdit={beginQueuedEdit} + onSendNow={id => void sendQueuedNow(id)} + /> + ) : null + } + sessionId={statusSessionId} + />
@@ -1824,12 +1977,8 @@ export function ChatBarFallback() { aria-hidden className={cn( 'pointer-events-none absolute inset-0 -z-10 rounded-[inherit]', - 'bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)]', - 'backdrop-blur-[0.75rem] backdrop-saturate-[1.12]', - '[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]', - 'transition-[background-color] duration-150 ease-out', - 'group-data-[thread-scrolled-up]/composer:bg-[color-mix(in_srgb,var(--dt-card)_48%,transparent)]', - 'group-focus-within/composer:bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)]' + composerFill, + composerSurfaceGlass )} />
diff --git a/apps/desktop/src/app/chat/composer/inline-refs.ts b/apps/desktop/src/app/chat/composer/inline-refs.ts index 9aae24db4c5..6e580266212 100644 --- a/apps/desktop/src/app/chat/composer/inline-refs.ts +++ b/apps/desktop/src/app/chat/composer/inline-refs.ts @@ -3,7 +3,12 @@ import { contextPath } from '@/lib/chat-runtime' import type { DroppedFile } from '../hooks/use-composer-actions' -import { composerPlainText, escapeHtml, placeCaretEnd, refChipHtml } from './rich-editor' +import { + composerPlainText, + normalizeComposerEditorDom, + placeCaretEnd, + refChipElement +} from './rich-editor' /** A chip to insert: a raw `@kind:value` string, or a typed value + display label. */ export type InlineRefInput = string | { kind: string; label?: string; value: string } @@ -89,56 +94,102 @@ export function droppedFileInlineRefs(candidates: DroppedFile[], cwd: string | n return candidates.map(candidate => droppedFileInlineRef(candidate, cwd)).filter((ref): ref is string => Boolean(ref)) } -export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly InlineRefInput[]) { - if (!refs.length) { +function parseInlineRef(ref: InlineRefInput): { kind: string; label?: string; rawValue: string } | null { + if (typeof ref !== 'string') { + return { kind: ref.kind, label: ref.label, rawValue: ref.value } + } + + const match = ref.match(/^@([^:]+):(.+)$/) + + if (!match) { return null } - const refsHtml = refs - .map(ref => { - if (typeof ref !== 'string') { - return refChipHtml(ref.kind, ref.value, ref.label) - } + return { kind: match[1] || 'file', rawValue: match[2] || '' } +} - const match = ref.match(/^@([^:]+):(.+)$/) +function plainTextInRange(editor: HTMLDivElement, range: Range, edge: 'after' | 'before') { + const slice = range.cloneRange() + slice.selectNodeContents(editor) - return match ? refChipHtml(match[1], match[2]) : escapeHtml(ref) - }) - .join(' ') + if (edge === 'before') { + slice.setEnd(range.startContainer, range.startOffset) + } else { + slice.setStart(range.endContainer, range.endOffset) + } + + const container = document.createElement('div') + container.appendChild(slice.cloneContents()) + + return composerPlainText(container) +} + +function buildRefFragment( + refs: readonly { kind: string; label?: string; rawValue: string }[], + { needsBeforeSpace, needsAfterSpace }: { needsAfterSpace: boolean; needsBeforeSpace: boolean } +) { + const fragment = document.createDocumentFragment() + + if (needsBeforeSpace) { + fragment.append(document.createTextNode(' ')) + } + + refs.forEach((ref, index) => { + if (index > 0) { + fragment.append(document.createTextNode(' ')) + } + + fragment.append(refChipElement(ref.kind, ref.rawValue, ref.label)) + }) + + if (needsAfterSpace) { + fragment.append(document.createTextNode(' ')) + } + + return fragment +} + +export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly InlineRefInput[]) { + const parsed = refs.map(parseInlineRef).filter((ref): ref is NonNullable => ref !== null) + + if (!parsed.length) { + return null + } + + editor.focus({ preventScroll: true }) const selection = window.getSelection() - const range = selection?.rangeCount && editor.contains(selection.getRangeAt(0).commonAncestorContainer) ? selection.getRangeAt(0) : null - editor.focus({ preventScroll: true }) + if (range && selection) { + const beforeText = plainTextInRange(editor, range, 'before') + const afterText = plainTextInRange(editor, range, 'after') - if (range) { - const beforeRange = range.cloneRange() - beforeRange.selectNodeContents(editor) - beforeRange.setEnd(range.startContainer, range.startOffset) - const beforeContainer = document.createElement('div') - beforeContainer.appendChild(beforeRange.cloneContents()) - - const afterRange = range.cloneRange() - afterRange.selectNodeContents(editor) - afterRange.setStart(range.endContainer, range.endOffset) - const afterContainer = document.createElement('div') - afterContainer.appendChild(afterRange.cloneContents()) - - const beforeText = composerPlainText(beforeContainer) - const afterText = composerPlainText(afterContainer) - const needsBeforeSpace = beforeText.length > 0 && !/\s$/.test(beforeText) - const needsAfterSpace = afterText.length === 0 || !/^\s/.test(afterText) - - document.execCommand('insertHTML', false, `${needsBeforeSpace ? ' ' : ''}${refsHtml}${needsAfterSpace ? ' ' : ''}`) + range.insertNode( + buildRefFragment(parsed, { + needsAfterSpace: afterText.length === 0 || !/^\s/.test(afterText), + needsBeforeSpace: beforeText.length > 0 && !/\s$/.test(beforeText) + }) + ) + range.collapse(false) + selection.removeAllRanges() + selection.addRange(range) } else { const current = composerPlainText(editor) + + editor.append( + buildRefFragment(parsed, { + needsAfterSpace: true, + needsBeforeSpace: current.length > 0 && !/\s$/.test(current) + }) + ) placeCaretEnd(editor) - document.execCommand('insertHTML', false, `${current && !/\s$/.test(current) ? ' ' : ''}${refsHtml} `) } + normalizeComposerEditorDom(editor) + return composerPlainText(editor) } diff --git a/apps/desktop/src/app/chat/composer/queue-panel.tsx b/apps/desktop/src/app/chat/composer/queue-panel.tsx index 33906452026..fb4365506c0 100644 --- a/apps/desktop/src/app/chat/composer/queue-panel.tsx +++ b/apps/desktop/src/app/chat/composer/queue-panel.tsx @@ -1,7 +1,6 @@ -import { useState } from 'react' - +import { StatusRow } from '@/components/chat/status-row' +import { StatusSection } from '@/components/chat/status-section' import { Button } from '@/components/ui/button' -import { DisclosureCaret } from '@/components/ui/disclosure-caret' import { Tip } from '@/components/ui/tooltip' import { type Translations, useI18n } from '@/i18n' import { ArrowUp, Pencil, Trash2 } from '@/lib/icons' @@ -23,108 +22,84 @@ const entryPreview = (entry: QueuedPromptEntry, c: Translations['composer']) => export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendNow }: QueuePanelProps) { const { t } = useI18n() const c = t.composer - const [collapsed, setCollapsed] = useState(true) if (entries.length === 0) { return null } return ( -
- + + {entries.map(entry => { + const isEditing = editingId === entry.id + const attachmentsCount = entry.attachments.length - {!collapsed && ( -
- {entries.map(entry => { - const isEditing = editingId === entry.id - const attachmentsCount = entry.attachments.length - const sendLabel = busy ? c.sendQueuedNext : c.sendQueuedNow - - return ( -
- -
-

{entryPreview(entry, c)}

- {(attachmentsCount > 0 || isEditing) && ( -
- {attachmentsCount > 0 && {c.attachments(attachmentsCount)}} - {isEditing && ( - - {c.editingInComposer} - - )} -
+ return ( + + + + + + + + + + + + } + trailingVisible={isEditing} + > +
+

{entryPreview(entry, c)}

+ {(attachmentsCount > 0 || isEditing) && ( +
+ {attachmentsCount > 0 && {c.attachments(attachmentsCount)}} + {isEditing && ( + + {c.editingInComposer} + )}
-
- - - - - - - - - -
-
- ) - })} -
- )} -
+ )} +
+ + ) + })} +
) } diff --git a/apps/desktop/src/app/chat/composer/rich-editor.test.ts b/apps/desktop/src/app/chat/composer/rich-editor.test.ts index c04e19a048b..12e3e9613ef 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.test.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.test.ts @@ -1,6 +1,25 @@ import { describe, expect, it } from 'vitest' -import { composerPlainText, renderComposerContents, RICH_INPUT_SLOT } from './rich-editor' +import { insertInlineRefsIntoEditor } from './inline-refs' +import { + composerPlainText, + deleteSelectionInEditor, + insertPlainTextAtCaret, + normalizeComposerEditorDom, + refChipElement, + renderComposerContents, + RICH_INPUT_SLOT +} from './rich-editor' + +const caretIn = (editor: HTMLElement) => { + const range = document.createRange() + const selection = window.getSelection()! + + range.selectNodeContents(editor) + range.collapse(false) + selection.removeAllRanges() + selection.addRange(range) +} describe('renderComposerContents', () => { it('renders refs and raw text without interpreting user text as HTML', () => { @@ -16,3 +35,100 @@ describe('renderComposerContents', () => { expect(composerPlainText(editor)).toBe('@file:`` raw') }) }) + +describe('normalizeComposerEditorDom', () => { + it('unwraps a single insertHTML wrapper div so plain text stays one line', () => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + editor.innerHTML = '
foo.ts
' + + normalizeComposerEditorDom(editor) + + expect(composerPlainText(editor)).toBe('@file:`src/foo.ts` ') + expect(editor.querySelector(':scope > div')).toBeNull() + }) + + it('removes a trailing br after a ref chip', () => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + editor.append(refChipElement('file', '`src/foo.ts`'), document.createElement('br')) + + normalizeComposerEditorDom(editor) + + expect(composerPlainText(editor)).toBe('@file:`src/foo.ts`') + expect(editor.querySelector('br')).toBeNull() + }) +}) + +describe('insertInlineRefsIntoEditor', () => { + it('inserts chips without wrapper divs or spurious newlines', () => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + + insertInlineRefsIntoEditor(editor, ['@file:`src/foo.ts`']) + + expect(editor.querySelector(':scope > div')).toBeNull() + expect(composerPlainText(editor)).toBe('@file:`src/foo.ts` ') + }) +}) + +describe('insertPlainTextAtCaret', () => { + it('inserts multiline text as text nodes + br', () => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + document.body.append(editor) + caretIn(editor) + + insertPlainTextAtCaret(editor, 'one\ntwo\nthree') + + expect(editor.querySelectorAll('br').length).toBe(2) + expect(composerPlainText(editor)).toBe('one\ntwo\nthree') + + editor.remove() + }) + + it('replaces the selected span', () => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + editor.textContent = 'abXYef' + document.body.append(editor) + + const text = editor.firstChild! + const selection = window.getSelection()! + const range = document.createRange() + + range.setStart(text, 2) + range.setEnd(text, 4) + selection.removeAllRanges() + selection.addRange(range) + + insertPlainTextAtCaret(editor, 'cd') + + expect(composerPlainText(editor)).toBe('abcdef') + + editor.remove() + }) +}) + +describe('deleteSelectionInEditor', () => { + it('clears a non-collapsed range and leaves a collapsed caret', () => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + editor.textContent = 'hello world' + document.body.append(editor) + + const selection = window.getSelection()! + const range = document.createRange() + + range.selectNodeContents(editor) + selection.removeAllRanges() + selection.addRange(range) + + expect(deleteSelectionInEditor(editor)).toBe(true) + expect(composerPlainText(editor)).toBe('') + expect(selection.getRangeAt(0).collapsed).toBe(true) + expect(deleteSelectionInEditor(editor)).toBe(false) + + editor.remove() + }) +}) diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts index ea6382f9abd..f74d2ee5bf7 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.ts @@ -132,6 +132,63 @@ export function renderComposerContents(target: HTMLElement, text: string) { appendComposerContents(target, text) } +/** Caret range when the selection lives inside `editor`; else null. */ +function composerSelectionRange(editor: HTMLElement) { + const selection = window.getSelection() + const range = selection?.rangeCount ? selection.getRangeAt(0) : null + + if (!selection || !range || !editor.contains(range.commonAncestorContainer)) { + return null + } + + return { range, selection } +} + +/** Insert plain text at the caret (replacing any selection). Pastes use this + * instead of `execCommand('insertText')` — Chromium's editing pipeline is + * ~O(n²) on large multiline blobs. */ +export function insertPlainTextAtCaret(editor: HTMLElement, text: string) { + const hit = composerSelectionRange(editor) + const fragment = document.createDocumentFragment() + + appendTextWithBreaks(fragment, text) + + const tail = fragment.lastChild + + if (hit) { + hit.range.deleteContents() + hit.range.insertNode(fragment) + } else { + editor.append(fragment) + } + + if (tail) { + const caret = document.createRange() + caret.setStartAfter(tail) + caret.collapse(true) + const selection = hit?.selection ?? window.getSelection() + selection?.removeAllRanges() + selection?.addRange(caret) + } +} + +/** Remove a non-collapsed selection in-editor. Skips collapsed carets so word/ + * line delete (Opt/Cmd+Backspace) stays native. Returns whether anything ran. */ +export function deleteSelectionInEditor(editor: HTMLElement) { + const hit = composerSelectionRange(editor) + + if (!hit || hit.range.collapsed) { + return false + } + + hit.range.deleteContents() + hit.range.collapse(true) + hit.selection.removeAllRanges() + hit.selection.addRange(hit.range) + + return true +} + /** Serialize a draft string into chip-HTML for the contenteditable surface. */ export function composerHtml(text: string) { let cursor = 0 @@ -184,3 +241,36 @@ export function placeCaretEnd(element: HTMLElement) { selection?.removeAllRanges() selection?.addRange(range) } + +/** Drop contenteditable junk that serializes as `\n` and falsely expands the composer. */ +export function normalizeComposerEditorDom(editor: HTMLElement) { + if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') { + editor.replaceChildren() + + return + } + + if (editor.childNodes.length === 1 && editor.firstChild?.nodeType === Node.ELEMENT_NODE) { + const wrapper = editor.firstChild as HTMLElement + + if (wrapper.tagName === 'DIV' && wrapper.dataset.slot !== RICH_INPUT_SLOT) { + editor.replaceChildren(...Array.from(wrapper.childNodes)) + } + } + + const last = editor.lastChild + + if (last?.nodeName !== 'BR') { + return + } + + let prev: ChildNode | null = last.previousSibling + + while (prev?.nodeType === Node.TEXT_NODE && !(prev.textContent || '').trim()) { + prev = prev.previousSibling + } + + if ((prev as HTMLElement | null)?.dataset.refText) { + editor.removeChild(last) + } +} diff --git a/apps/desktop/src/app/chat/composer/status-stack/index.tsx b/apps/desktop/src/app/chat/composer/status-stack/index.tsx new file mode 100644 index 00000000000..a13e039ecc6 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/status-stack/index.tsx @@ -0,0 +1,202 @@ +import { useStore } from '@nanostores/react' +import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef } from 'react' +import { useNavigate } from 'react-router-dom' + +import { blurComposerInput } from '@/app/chat/composer/focus' +import { AGENTS_ROUTE } from '@/app/routes' +import { composerDockCard } from '@/components/chat/composer-dock' +import { StatusSection } from '@/components/chat/status-section' +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { type Translations, useI18n } from '@/i18n' +import { cn } from '@/lib/utils' +import { + $statusItemsBySession, + type ComposerStatusItem, + dismissBackgroundProcess, + groupStatusItems, + refreshBackgroundProcesses, + type StatusGroup, + stopBackgroundProcess +} from '@/store/composer-status' +import { $threadScrolledUp } from '@/store/thread-scroll' +import { openSessionInNewWindow } from '@/store/windows' + +import { StatusItemRow } from './status-row' + +// Slow safety-net poll for silent exits (processes without notify_on_complete +// emit no event when they die). Only armed while a running row is on screen. +const BACKGROUND_POLL_MS = 5_000 + +const groupLabel = (group: StatusGroup, s: Translations['statusStack']) => { + if (group.type === 'todo') { + return s.todos(group.items.filter(i => i.todoStatus === 'completed').length, group.items.length) + } + + return group.type === 'subagent' ? s.subagents(group.items.length) : s.background(group.items.length) +} + +interface ComposerStatusStackProps { + /** The queue, built by the composer (it owns the queue's callbacks). Rendered + * as the last group so it stays fused to the composer like before. */ + queue: ReactNode + sessionId: null | string +} + +/** + * The status "sink" above the composer: one card (the queue's chrome) holding + * every session-scoped status — subagents, background tasks, queue — grouped by + * type and separated by light dividers. Collapses to nothing when empty. + */ +export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackProps) { + const { t } = useI18n() + const navigate = useNavigate() + const itemsBySession = useStore($statusItemsBySession) + const scrolledUp = useStore($threadScrolledUp) + + const groups = useMemo( + () => groupStatusItems(sessionId ? (itemsBySession[sessionId] ?? []) : []), + [itemsBySession, sessionId] + ) + + // Seed from the registry on session open; event-driven refreshes (terminal / + // process tool completions) live in use-message-stream. + useEffect(() => { + if (sessionId) { + void refreshBackgroundProcesses(sessionId) + } + }, [sessionId]) + + const hasRunningBackground = groups.some(g => g.type === 'background' && g.items.some(i => i.state === 'running')) + + useEffect(() => { + if (!sessionId || !hasRunningBackground) { + return + } + + const timer = setInterval(() => void refreshBackgroundProcesses(sessionId), BACKGROUND_POLL_MS) + + return () => clearInterval(timer) + }, [hasRunningBackground, sessionId]) + + const openAgents = () => navigate(AGENTS_ROUTE) + + const openSubagent = (item: ComposerStatusItem) => + item.sessionId ? void openSessionInNewWindow(item.sessionId, { watch: true }) : openAgents() + + const sections: { key: string; node: ReactNode }[] = groups.map(group => ({ + key: group.type, + node: ( + + {t.statusStack.agents} + + ) : undefined + } + defaultCollapsed={group.type !== 'todo'} + icon={ + group.type === 'todo' ? ( + + ) : undefined + } + label={groupLabel(group, t.statusStack)} + > + {group.items.map(item => ( + dismissBackgroundProcess(sessionId, id) : undefined} + onOpen={() => openSubagent(item)} + onStop={sessionId ? id => stopBackgroundProcess(sessionId, id) : undefined} + /> + ))} + + ) + })) + + if (queue) { + sections.push({ key: 'queue', node: queue }) + } + + const visible = sections.length > 0 + const stackRef = useRef(null) + + // The stack is out of flow (overlays the thread), so the composer's measured + // height never sees it. Publish our own measured height — bucketed like the + // composer's, to avoid style invalidation churn — so the thread's + // last-message clearance can add it and the stack never hides messages. + useLayoutEffect(() => { + const root = document.documentElement + const el = stackRef.current + + if (!visible || !el) { + root.style.removeProperty('--status-stack-measured-height') + + return + } + + let last = -1 + + const sync = () => { + const bucket = Math.round(el.getBoundingClientRect().height / 8) * 8 + + if (bucket !== last) { + last = bucket + root.style.setProperty('--status-stack-measured-height', `${bucket}px`) + } + } + + const observer = new ResizeObserver(sync) + observer.observe(el) + sync() + + return () => { + observer.disconnect() + root.style.removeProperty('--status-stack-measured-height') + } + }, [visible]) + + if (!visible) { + return null + } + + return ( +
blurComposerInput()} + ref={stackRef} + > + {/* The card paints the shared --composer-fill (rest / scrolled / focused + all match the composer surface by construction); on scroll we only + ghost the CONTENT — element opacity on the card would kill the blur. + Rounded top, square bottom; the bottom border is TRANSPARENT — the + composer surface's visible top border (which sits at a higher z) is the + single shared seam, so the two read as one fused capsule. */} +
+
+ {sections.map(section => ( +
{section.node}
+ ))} +
+
+
+ ) +} diff --git a/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx b/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx new file mode 100644 index 00000000000..27a9ef0262c --- /dev/null +++ b/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx @@ -0,0 +1,155 @@ +import { Fragment, memo, type ReactNode, useState } from 'react' + +import { StatusRow } from '@/components/chat/status-row' +import { TerminalOutput } from '@/components/chat/terminal-output' +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { DisclosureCaret } from '@/components/ui/disclosure-caret' +import { GlyphSpinner } from '@/components/ui/glyph-spinner' +import { Tip } from '@/components/ui/tooltip' +import { type Translations, useI18n } from '@/i18n' +import { ArrowUpRight, X } from '@/lib/icons' +import type { TodoStatus } from '@/lib/todos' +import { cn } from '@/lib/utils' +import type { ComposerStatusItem } from '@/store/composer-status' + +const toolLabel = (name: string) => + name + .split('_') + .filter(Boolean) + .map(part => part[0]!.toUpperCase() + part.slice(1)) + .join(' ') || name + +// Todo rows speak checkbox, not spinner-and-dot: a dashed ring while the item +// is still open (pending), codicons once it resolves, a live spinner only on +// the in-progress item. +const TODO_GLYPHS: Record, { icon: string; tone: string }> = { + cancelled: { icon: 'circle-slash', tone: 'text-muted-foreground/45' }, + completed: { icon: 'pass-filled', tone: 'text-emerald-500/80' } +} + +// Left slot: braille spinner while running, otherwise a small status dot +// (green = done, red = failed) so the slot is always filled and rows align. +function leadingGlyph(item: ComposerStatusItem, s: Translations['statusStack']): ReactNode { + if (item.todoStatus === 'pending') { + return ( + + ) + } + + if (item.todoStatus && item.todoStatus !== 'in_progress') { + const glyph = TODO_GLYPHS[item.todoStatus] + + return + } + + if (item.state === 'running') { + return ( + + ) + } + + return ( + + ) +} + +interface StatusItemRowProps { + item: ComposerStatusItem + /** Clear a finished background task from the stack. */ + onDismiss?: (id: string) => void + /** Open the subagent's own session window, livestreamed by the gateway's + * child-session mirror (Agents view fallback for older gateways). */ + onOpen?: () => void + /** Cancel a running background task. */ + onStop?: (id: string) => void +} + +/** + * Renders one {@link ComposerStatusItem} into the shared {@link StatusRow}. + * Memoised + keyed by id so parent re-renders never remount it (the spinner + * keeps ticking instead of resetting). + */ +export const StatusItemRow = memo(function StatusItemRow({ item, onDismiss, onOpen, onStop }: StatusItemRowProps) { + const { t } = useI18n() + const s = t.statusStack + const [outputOpen, setOutputOpen] = useState(false) + const failed = item.state === 'failed' + const running = item.state === 'running' + + const action = + item.type === 'background' + ? running + ? onStop && { label: s.stop, onClick: () => onStop(item.id) } + : onDismiss && { label: s.dismiss, onClick: () => onDismiss(item.id) } + : null + + const canOpen = item.type === 'subagent' && !!onOpen + const hasOutput = item.type === 'background' && !!item.output + const onActivate = canOpen ? onOpen : hasOutput ? () => setOutputOpen(open => !open) : undefined + + return ( + + + + + ) : canOpen ? ( + + ) : undefined + } + > + + {item.title} + + {item.type === 'subagent' && item.currentTool && ( + + {toolLabel(item.currentTool)} + + )} + {failed && typeof item.exitCode === 'number' && item.exitCode !== 0 && ( + + {s.exit(item.exitCode)} + + )} + {hasOutput && } + + {hasOutput && outputOpen && } + + ) +}) diff --git a/apps/desktop/src/app/chat/composer/trigger-popover.tsx b/apps/desktop/src/app/chat/composer/trigger-popover.tsx index dffa1ae7745..6f08a7e0347 100644 --- a/apps/desktop/src/app/chat/composer/trigger-popover.tsx +++ b/apps/desktop/src/app/chat/composer/trigger-popover.tsx @@ -1,16 +1,12 @@ import type { Unstable_TriggerItem } from '@assistant-ui/core' import { Fragment } from 'react' -import { BrailleSpinner } from '@/components/ui/braille-spinner' import { Codicon } from '@/components/ui/codicon' +import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { useI18n } from '@/i18n' import { cn } from '@/lib/utils' -import { - COMPLETION_DRAWER_BELOW_CLASS, - COMPLETION_DRAWER_CLASS, - CompletionDrawerEmpty -} from './completion-drawer' +import { COMPLETION_DRAWER_BELOW_CLASS, COMPLETION_DRAWER_CLASS, CompletionDrawerEmpty } from './completion-drawer' const AT_ICON_BY_TYPE: Record = { diff: 'diff', @@ -87,7 +83,7 @@ export function ComposerTriggerPopover({ {items.length === 0 ? ( loading ? (
- + {copy.lookupLoading}
) : ( diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts index 7b479bf4f6c..ddf38340235 100644 --- a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts +++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts @@ -1,6 +1,7 @@ import { useCallback } from 'react' -import { requestComposerFocus, requestComposerInsert } from '@/app/chat/composer/focus' +import { requestComposerFocus, requestComposerInsert, requestComposerInsertRefs } from '@/app/chat/composer/focus' +import { droppedFileInlineRef } from '@/app/chat/composer/inline-refs' import { formatRefValue } from '@/components/assistant-ui/directive-text' import { useI18n } from '@/i18n' import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime' @@ -286,6 +287,26 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway [currentCwd] ) + const insertContextPathInlineRef = useCallback( + (path: string, isDirectory = false) => { + if (!path) { + return false + } + + const ref = droppedFileInlineRef({ isDirectory, path }, currentCwd) + + if (!ref) { + return false + } + + requestComposerInsertRefs([ref]) + requestComposerFocus('main') + + return true + }, + [currentCwd] + ) + const attachContextFilePath = useCallback( (filePath: string) => { if (!filePath) { @@ -546,6 +567,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway attachDroppedItems, attachImageBlob, attachImagePath, + insertContextPathInlineRef, pasteClipboardImage, pickContextPaths, pickImages, diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 77d92248e3a..f890a5bfe6c 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -35,7 +35,9 @@ import { $gatewayState, $introPersonality, $introSeed, + $lastVisibleMessageIsUser, $messages, + $messagesEmpty, $selectedStoredSessionId, $sessions, sessionPinId @@ -43,7 +45,7 @@ import { import type { ModelOptionsResponse } from '@/types/hermes' import { routeSessionId } from '../routes' -import { titlebarHeaderBaseClass, titlebarHeaderShadowClass } from '../shell/titlebar' +import { titlebarHeaderBaseClass, titlebarHeaderShadowClass, titlebarHeaderTitleClass } from '../shell/titlebar' import { ChatDropOverlay } from './chat-drop-overlay' import { ChatSwapOverlay } from './chat-swap-overlay' @@ -53,8 +55,9 @@ import { droppedFileInlineRefs, type SessionDragPayload, sessionInlineRef } from import type { ChatBarState } from './composer/types' import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions' import { useFileDropZone } from './hooks/use-file-drop-zone' +import { ScrollToBottomButton } from './scroll-to-bottom-button' import { SessionActionsMenu } from './sidebar/session-actions-menu' -import { lastVisibleMessageIsUser, threadLoadingState } from './thread-loading' +import { threadLoadingState } from './thread-loading' interface ChatViewProps extends Omit, 'onSubmit'> { gateway: HermesGateway | null @@ -80,6 +83,7 @@ interface ChatViewProps extends Omit, 'onSubmit'> { onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void onEdit: (message: AppendMessage) => Promise onReload: (parentId: string | null) => Promise + onRestoreToMessage?: (messageId: string) => Promise onTranscribeAudio?: (audio: Blob) => Promise } @@ -125,7 +129,7 @@ function ChatHeader({ return (
diff --git a/apps/desktop/src/app/chat/right-rail/preview-file.tsx b/apps/desktop/src/app/chat/right-rail/preview-file.tsx index 8d9ce922917..6261200706c 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-file.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-file.tsx @@ -10,12 +10,16 @@ import { useEffect, useMemo, useState } from 'react' import ShikiHighlighter from 'react-shiki' import { Streamdown } from 'streamdown' +import { requestComposerFocus, requestComposerInsertRefs } from '@/app/chat/composer/focus' +import { droppedFileInlineRef } from '@/app/chat/composer/inline-refs' import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions' +import { isAddSelectionShortcut } from '@/app/right-sidebar/terminal/selection' import { PageLoader } from '@/components/page-loader' import { translateNow, useI18n } from '@/i18n' import { readDesktopFileDataUrl, readDesktopFileText } from '@/lib/desktop-fs' import { cn } from '@/lib/utils' import type { PreviewTarget } from '@/store/preview' +import { $currentCwd } from '@/store/session' const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const const TEXT_PREVIEW_MAX_BYTES = 512 * 1024 @@ -357,6 +361,38 @@ function SourceView({ filePath, language, text }: { filePath: string; language: startLineDrag(event, filePath, inSelection(line) && selection ? selection : { end: line, start: line }) } + // ⌘/Ctrl+L with a line selection drops the same `@line:path:start-end` ref the + // gutter drag produces — so the keyboard path mirrors dragging the lines into + // the composer. Capture-phase + stopPropagation so it beats the terminal's + // global ⌘L handler (which would otherwise grab the native text selection). + useEffect(() => { + if (!selection) { + return + } + + const onKeyDown = (event: KeyboardEvent) => { + if (!isAddSelectionShortcut(event)) { + return + } + + const lineEnd = selection.end > selection.start ? selection.end : undefined + const ref = droppedFileInlineRef({ line: selection.start, lineEnd, path: filePath }, $currentCwd.get()) + + if (!ref) { + return + } + + event.preventDefault() + event.stopPropagation() + requestComposerInsertRefs([ref]) + requestComposerFocus('main') + } + + window.addEventListener('keydown', onKeyDown, { capture: true }) + + return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) + }, [filePath, selection]) + return (
diff --git a/apps/desktop/src/app/chat/scroll-to-bottom-button.test.tsx b/apps/desktop/src/app/chat/scroll-to-bottom-button.test.tsx new file mode 100644 index 00000000000..f163ec84819 --- /dev/null +++ b/apps/desktop/src/app/chat/scroll-to-bottom-button.test.tsx @@ -0,0 +1,67 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { clearAllPrompts, setApprovalRequest } from '@/store/prompts' +import { $activeSessionId } from '@/store/session' +import { onScrollToBottomRequest, resetThreadScroll, setThreadAtBottom } from '@/store/thread-scroll' + +import { ScrollToBottomButton } from './scroll-to-bottom-button' + +function pendingApproval() { + $activeSessionId.set('sess-1') + setApprovalRequest({ command: 'rm -rf /tmp/x', description: 'dangerous command', sessionId: 'sess-1' }) +} + +afterEach(() => { + cleanup() + clearAllPrompts() + resetThreadScroll() + $activeSessionId.set(null) +}) + +// `getByRole('button')` excludes aria-hidden nodes, so "queryByRole null" is the +// control's hidden (parked-at-bottom) state. +describe('ScrollToBottomButton', () => { + it('stays hidden while parked at the bottom', () => { + render() + + expect(screen.queryByRole('button')).toBeNull() + }) + + it('is a plain jump-to-bottom control when scrolled up with no approval', () => { + setThreadAtBottom(false) + render() + + expect(screen.getByRole('button', { name: 'Scroll to bottom' })).toBeTruthy() + expect(screen.queryByText('Approval needed')).toBeNull() + }) + + it('morphs into the approval pill when scrolled up with a pending approval', () => { + pendingApproval() + setThreadAtBottom(false) + render() + + expect(screen.getByRole('button', { name: 'Approval needed' })).toBeTruthy() + expect(screen.getByText('Approval needed')).toBeTruthy() + }) + + it('does not morph while a pending approval is still in view (at bottom)', () => { + pendingApproval() + render() + + // Parked at bottom → control hidden, so it can't claim "approval needed". + expect(screen.queryByRole('button')).toBeNull() + }) + + it('re-arms sticky-bottom on click', () => { + const handler = vi.fn() + const stop = onScrollToBottomRequest(handler) + setThreadAtBottom(false) + render() + + fireEvent.click(screen.getByRole('button')) + + expect(handler).toHaveBeenCalledTimes(1) + stop() + }) +}) diff --git a/apps/desktop/src/app/chat/scroll-to-bottom-button.tsx b/apps/desktop/src/app/chat/scroll-to-bottom-button.tsx new file mode 100644 index 00000000000..dfe8b4e4d0b --- /dev/null +++ b/apps/desktop/src/app/chat/scroll-to-bottom-button.tsx @@ -0,0 +1,74 @@ +import { useStore } from '@nanostores/react' +import { useRef } from 'react' + +import { Codicon } from '@/components/ui/codicon' +import { useI18n } from '@/i18n' +import { triggerHaptic } from '@/lib/haptics' +import { cn } from '@/lib/utils' +import { $approvalRequest } from '@/store/prompts' +import { $threadJumpButtonVisible, requestScrollToBottom } from '@/store/thread-scroll' + +/** + * Floating "jump to bottom" control. Sits centered just above the composer, + * clearing the out-of-flow status stack via the same measured-height CSS vars + * the thread's bottom clearance uses (`--composer-measured-height` + + * `--status-stack-measured-height`), so it never overlaps the queue / subagent + * / background cards. Visible only while the user has scrolled meaningfully + * away from the bottom; clicking re-arms sticky-bottom and pins the viewport. + * + * When the turn is BLOCKED on an approval, this same control morphs into an + * "Approval needed" pill — the only response surface is the inline Run/Reject + * bar on the parked tool row, which is always the bottom-most content, so the + * existing scroll-to-bottom action lands the user right on it. One control, no + * collision, no second scroll path (native scrollIntoView would scroll + * overflow:hidden ancestors that can't scroll back and wreck the layout). + * + * Enter/exit motion lives in styles.css under `.thread-jump-button` — a + * directional scale (contract in from 1.1, contract out to 0.9) keyed off + * `data-state`. `idle` (never-shown) stays silent so it can't flash on mount; + * `in`/`out` only swap once it has actually appeared. + */ +export function ScrollToBottomButton() { + const { t } = useI18n() + const visible = useStore($threadJumpButtonVisible) + const request = useStore($approvalRequest) + // Scrolled away while an approval is pending → the inline Run/Reject bar is + // below the fold. Relabel so the user knows the session needs them, not just + // that there's more to read. + const approval = visible && Boolean(request) + const hasShownRef = useRef(false) + + if (visible) { + hasShownRef.current = true + } + + const state = visible ? 'in' : hasShownRef.current ? 'out' : 'idle' + const label = approval ? t.assistant.approval.jumpToApproval : t.assistant.thread.scrollToBottom + + return ( + + ) +} diff --git a/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx b/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx index f8db6e390e2..006bba439dd 100644 --- a/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx +++ b/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx @@ -168,7 +168,7 @@ export function SidebarCronJobsSection({
{open && ( - + {shown.map(job => ( `${GROUP_DND_ID_PREFIX}${id}` +// Sidebar reordering is a strictly vertical list. The dragged item's transform +// is rendered Y-only in useSortableBindings (no x, no scale); this just stops +// dnd-kit's auto-scroll from dragging the rail — or the window — sideways when +// the pointer nears an edge, killing the horizontal "drag to valhalla". +const reorderAutoScroll = { threshold: { x: 0, y: 0.2 } } -const parseGroupDndId = (id: string) => - id.startsWith(GROUP_DND_ID_PREFIX) ? id.slice(GROUP_DND_ID_PREFIX.length) : null +// One self-contained, nesting-safe reorderable list. It owns its DndContext, so a +// drag only ever collides with THIS list's own items — drop it at any depth (repos, +// worktrees, sessions) and reordering "just works" without leaking into the lists +// around or inside it. Pair each item with useSortableBindings(id); the list reports +// the new id order and the caller persists it. This is the single generic primitive +// behind every reorderable surface in the sidebar. +function ReorderableList({ + children, + ids, + onReorder, + sensors +}: { + children: React.ReactNode + ids: string[] + onReorder: (ids: string[]) => void + sensors?: ReturnType +}) { + const handleDragEnd = ({ active, over }: DragEndEvent) => { + if (!over || active.id === over.id) { + return + } + + const from = ids.indexOf(String(active.id)) + const to = ids.indexOf(String(over.id)) + + if (from >= 0 && to >= 0) { + onReorder(arrayMove(ids, from, to)) + } + } + + return ( + + + {children} + + + ) +} const countLabel = (loaded: number, total: number) => (total > loaded ? `${loaded}/${total}` : String(loaded)) const sessionTime = (s: SessionInfo) => s.last_active || s.started_at || 0 @@ -208,13 +252,6 @@ function sameIds(left: string[], right: string[]) { return left.length === right.length && left.every((item, index) => item === right[index]) } -const baseName = (path: string) => - path - .replace(/[/\\]+$/, '') - .split(/[/\\]/) - .filter(Boolean) - .pop() - // FTS results cover sessions that aren't in the loaded page; synthesize a // minimal SessionInfo so they render in the same row component (resume works // by id; the snippet stands in for the preview). @@ -241,36 +278,6 @@ function searchResultToSession(result: SessionSearchResult): SessionInfo { } } -function workspaceGroupsFor( - sessions: SessionInfo[], - noWorkspaceLabel: string, - options: { preserveSessionOrder?: boolean } = {} -): SidebarSessionGroup[] { - const groups = new Map() - - for (const session of sessions) { - const path = session.cwd?.trim() || '' - const id = path || '__no_workspace__' - const label = baseName(path) || path || noWorkspaceLabel - - const group = groups.get(id) ?? { id, label, path: path || null, sessions: [] } - group.sessions.push(session) - groups.set(id, group) - } - - if (!options.preserveSessionOrder) { - // Groups keep recency order (Map insertion = first-seen in the recency-sorted - // input, so an active project floats up), but rows *within* a group sort by - // creation time so they don't reshuffle every time a message lands — keeps - // muscle memory intact. - for (const group of groups.values()) { - group.sessions.sort((a, b) => b.started_at - a.started_at) - } - } - - return [...groups.values()] -} - function useSortableBindings(id: string) { const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id }) @@ -280,7 +287,10 @@ function useSortableBindings(id: string) { ref: setNodeRef, reorderable: true as const, style: { - transform: CSS.Transform.toString(transform), + // Uniform vertical list: only ever translate on Y. Ignoring x and the + // scaleX/scaleY that CSS.Transform.toString would emit keeps a dragged + // group/row from drifting sideways or morphing its size mid-drag. + transform: transform ? `translate3d(0px, ${transform.y}px, 0)` : undefined, transition: isDragging ? undefined : transition, willChange: isDragging ? 'transform' : undefined } @@ -347,7 +357,9 @@ export function ChatSidebar({ // otherwise be stuck in the grouped view with no way out. const showAllProfiles = multiProfile && profileScope === ALL_PROFILES const agentOrderIds = useStore($sidebarSessionOrderIds) + const agentOrderManual = useStore($sidebarSessionOrderManual) const workspaceOrderIds = useStore($sidebarWorkspaceOrderIds) + const workspaceParentOrderIds = useStore($sidebarWorkspaceParentOrderIds) const [searchQuery, setSearchQuery] = useState('') const [serverMatches, setServerMatches] = useState([]) const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false) @@ -403,8 +415,11 @@ export function ChatSidebar({ [sessions, showAllProfiles, profileScope] ) + // Agent session order is pinned to creation time (started_at), NOT activity — + // a new message must never float a session to the top. Position only changes + // for a brand-new session or an explicit manual drag (agentOrderIds). const sortedSessions = useMemo( - () => [...visibleSessions].sort((a, b) => sessionTime(b) - sessionTime(a)), + () => [...visibleSessions].sort((a, b) => (b.started_at || 0) - (a.started_at || 0)), [visibleSessions] ) @@ -506,28 +521,55 @@ export function ChatSidebar({ ) useEffect(() => { - const next = reconcileOrderIds( + const next = resolveManualSessionOrderIds( unpinnedAgentSessions.map(s => s.id), - agentOrderIds + agentOrderIds, + agentOrderManual ) - if (!sameIds(next, agentOrderIds)) { + if (!next.length && agentOrderManual) { + setSidebarSessionOrderManual(false) + } + + if (!next.length && agentOrderIds.length) { + setSidebarSessionOrderIds([]) + return + } + + if (next.length && !sameIds(next, agentOrderIds)) { setSidebarSessionOrderIds(next) } - }, [agentOrderIds, unpinnedAgentSessions]) + }, [agentOrderIds, agentOrderManual, unpinnedAgentSessions]) const agentSessions = useMemo( - () => orderByIds(unpinnedAgentSessions, s => s.id, agentOrderIds), - [unpinnedAgentSessions, agentOrderIds] + () => (agentOrderManual ? orderByIds(unpinnedAgentSessions, s => s.id, agentOrderIds) : unpinnedAgentSessions), + [unpinnedAgentSessions, agentOrderIds, agentOrderManual] ) // Recents are local-only: messaging-platform sessions are fetched as their // own slice ($messagingSessions) and rendered in self-managed per-platform // sections below, so there is no source-grouping magic to untangle here. - const agentGroups = useMemo( - () => orderByIds(workspaceGroupsFor(agentSessions, s.noWorkspace), g => g.id, workspaceOrderIds), - [agentSessions, s.noWorkspace, workspaceOrderIds] - ) + // + // Workspace grouping is a `parent (repo) → worktree → sessions` tree. Git + // metadata (probed locally) is authoritative; unresolved cwds fall back to a + // path-name heuristic inside workspaceTreeFor. Parents reorder via + // workspaceParentOrderIds; worktrees within a parent via workspaceOrderIds. + const worktreeGroupingActive = agentsGrouped && !showAllProfiles + const worktreeResolver = useWorktreeInfo(agentSessions, worktreeGroupingActive) + + const agentTree = useMemo(() => { + if (!worktreeGroupingActive) { + return undefined + } + + const tree = workspaceTreeFor(agentSessions, s.noWorkspace, worktreeResolver) + const orderedParents = orderByIds(tree, parent => parent.id, workspaceParentOrderIds) + + return orderedParents.map(parent => ({ + ...parent, + groups: orderByIds(parent.groups, group => group.id, workspaceOrderIds) + })) + }, [worktreeGroupingActive, agentSessions, s.noWorkspace, worktreeResolver, workspaceParentOrderIds, workspaceOrderIds]) const loadMoreForProfileGroup = useCallback( (profile: string) => { @@ -681,87 +723,73 @@ export function ChatSidebar({ const recentsMeta = countLabel(agentSessions.length, knownSessionTotal) - const displayAgentGroups = showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined + const displayAgentGroups = showAllProfiles ? profileGroups : undefined // The recents list owns its own (virtualized) scroll container only when it's a // long flat list. In that case it must keep its scroller even in short mode, so // we don't flatten it (flattening would defeat virtualization). Short flat lists - // and grouped views flatten into the single outer scroll instead. - const recentsVirtualizes = !displayAgentGroups?.length && displayAgentSessions.length >= VIRTUALIZE_THRESHOLD + // and grouped views (profile groups or the worktree tree) flatten into the + // single outer scroll instead. + const recentsVirtualizes = + !displayAgentGroups?.length && !agentTree?.length && displayAgentSessions.length >= VIRTUALIZE_THRESHOLD + // Keep the persisted parent + worktree orders reconciled with what's on screen: + // freshly-seen repos/worktrees surface at the top, vanished ones drop out of + // the saved order. useEffect(() => { - if (!displayAgentGroups?.length || showAllProfiles) { + if (!agentTree?.length) { return } - const next = reconcileOrderIds( - displayAgentGroups.map(g => g.id), + const nextParents = reconcileOrderIds( + agentTree.map(parent => parent.id), + workspaceParentOrderIds + ) + + if (!sameIds(nextParents, workspaceParentOrderIds)) { + setSidebarWorkspaceParentOrderIds(nextParents) + } + + const nextWorktrees = reconcileOrderIds( + agentTree.flatMap(parent => parent.groups.map(group => group.id)), workspaceOrderIds ) - if (!sameIds(next, workspaceOrderIds)) { - setSidebarWorkspaceOrderIds(next) + if (!sameIds(nextWorktrees, workspaceOrderIds)) { + setSidebarWorkspaceOrderIds(nextWorktrees) } - }, [displayAgentGroups, showAllProfiles, workspaceOrderIds]) + }, [agentTree, workspaceParentOrderIds, workspaceOrderIds]) const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0 const showSessionSections = showSessionSkeletons || sortedSessions.length > 0 - const handlePinnedDragEnd = ({ active, over }: DragEndEvent) => { - if (!over || active.id === over.id) { - return - } - - const newIndex = pinnedSessions.findIndex(s => s.id === String(over.id)) - - if (newIndex < 0) { - return - } - - // Sortable ids are live session ids; the pinned store is keyed by durable - // (lineage-root) ids, so translate before reordering. - const dragged = sessionByAnyId.get(String(active.id)) - reorderPinnedSession(dragged ? sessionPinId(dragged) : String(active.id), newIndex) + // Each reorderable list reports its OWN new id order; persisting is a direct, + // typed write — no id-prefix sniffing to figure out which level moved. + const reorderSessions = (ids: string[]) => { + setSidebarSessionOrderManual(true) + setSidebarSessionOrderIds(ids) } - const handleAgentDragEnd = ({ active, over }: DragEndEvent) => { - if (!over || active.id === over.id) { - return - } + const reorderParents = (ids: string[]) => setSidebarWorkspaceParentOrderIds(ids) - const activeId = String(active.id) - const overId = String(over.id) - const activeGroup = parseGroupDndId(activeId) - const overGroup = parseGroupDndId(overId) + // Worktrees persist as one flat list (orderByIds applies it per parent), so a + // single parent's new worktree order is spliced back over its slice. + const reorderWorktree = (parentId: string, ids: string[]) => + setSidebarWorkspaceOrderIds( + (agentTree ?? []).flatMap(parent => (parent.id === parentId ? ids : parent.groups.map(group => group.id))) + ) - if (activeGroup && overGroup) { - const groups = displayAgentGroups ?? [] - const oldIdx = groups.findIndex(g => g.id === activeGroup) - const newIdx = groups.findIndex(g => g.id === overGroup) + // Sortable rows carry live session ids; the pinned store is keyed by durable + // (lineage-root) ids, so translate before persisting the new order. + const reorderPinned = (ids: string[]) => + setPinnedSessionOrder( + ids.map(id => { + const session = sessionByAnyId.get(id) - if (oldIdx < 0 || newIdx < 0) { - return - } - - setSidebarWorkspaceOrderIds(arrayMove(groups, oldIdx, newIdx).map(g => g.id)) - - return - } - - if (activeGroup || overGroup) { - return - } - - const oldIdx = agentSessions.findIndex(s => s.id === activeId) - const newIdx = agentSessions.findIndex(s => s.id === overId) - - if (oldIdx < 0 || newIdx < 0) { - return - } - - setSidebarSessionOrderIds(arrayMove(agentSessions, oldIdx, newIdx).map(s => s.id)) - } + return session ? sessionPinId(session) : id + }) + ) return ( {s.nav[item.id] ?? item.label} {isNewSession && ( )} @@ -857,11 +886,11 @@ export function ChatSidebar({ )} {contentVisible && showSessionSections && ( -
+
{trimmedQuery && ( {s.noMatch(trimmedQuery)} @@ -891,7 +920,7 @@ export function ChatSidebar({ label={s.pinned} onArchiveSession={onArchiveSession} onDeleteSession={onDeleteSession} - onReorder={handlePinnedDragEnd} + onReorderSessions={reorderPinned} onResumeSession={onResumeSession} onToggle={() => setSidebarPinsOpen(!pinsOpen)} onTogglePin={unpinSession} @@ -908,7 +937,8 @@ export function ChatSidebar({ setSidebarRecentsOpen(!agentsOpen)} onTogglePin={pinSession} @@ -979,6 +1011,7 @@ export function ChatSidebar({ )} sessions={displayAgentSessions} sortable={!showAllProfiles && agentSessions.length > 1} + tree={agentTree} workingSessionIdSet={workingSessionIdSet} /> )} @@ -1121,20 +1154,6 @@ function SidebarPinnedEmptyState() { ) } -interface SidebarSessionGroup { - id: string - label: string - path: null | string - sessions: SessionInfo[] - // Profile color for the ALL-profiles view; absent for workspace groups. - color?: null | string - loadingMore?: boolean - mode?: 'profile' | 'source' | 'workspace' - onLoadMore?: () => void - sourceId?: string - totalCount?: number -} - interface MessagingSection { sourceId: string label: string @@ -1163,10 +1182,16 @@ interface SidebarSessionsSectionProps { headerAction?: React.ReactNode footer?: React.ReactNode groups?: SidebarSessionGroup[] + tree?: SidebarWorkspaceTree[] labelMeta?: React.ReactNode labelIcon?: React.ReactNode sortable?: boolean - onReorder?: (event: DragEndEvent) => void + // Per-level reorder callbacks. Each is optional; a list is draggable iff its + // callback is supplied. The flat session list, the repo parents, and a parent's + // worktrees each own an independent ReorderableList, so nothing collides. + onReorderSessions?: (ids: string[]) => void + onReorderParents?: (ids: string[]) => void + onReorderWorktree?: (parentId: string, ids: string[]) => void dndSensors?: ReturnType } @@ -1190,17 +1215,23 @@ function SidebarSessionsSection({ headerAction, footer, groups, + tree, labelMeta, labelIcon, sortable = false, - onReorder, + onReorderSessions, + onReorderParents, + onReorderWorktree, dndSensors }: SidebarSessionsSectionProps) { + const hasTreeSessions = Boolean(tree?.some(parent => parent.sessionCount > 0)) const hasGroupedSessions = Boolean(groups?.some(group => group.sessions.length > 0)) - const showEmptyState = forceEmptyState || (!hasGroupedSessions && sessions.length === 0) - const dndActive = sortable && !!onReorder + const showEmptyState = forceEmptyState || (!hasGroupedSessions && !hasTreeSessions && sessions.length === 0) + // The flat recents/pinned list is the only place sessions reorder by hand; + // grouped/tree views always sort by creation date and never drag. + const sessionsDraggable = sortable && !!onReorderSessions - const renderRow = (session: SessionInfo) => { + const renderRow = (session: SessionInfo, draggable: boolean) => { const rowProps = { isPinned: pinned, isSelected: session.id === activeSessionId, @@ -1212,74 +1243,58 @@ function SidebarSessionsSection({ session } - return sortable ? ( + return draggable ? ( ) : ( ) } - const renderRows = (items: SessionInfo[]) => items.map(renderRow) + // Sessions inside repos/worktrees are date-ordered and static. + const renderRows = (items: SessionInfo[]) => items.map(session => renderRow(session, false)) - const renderSessionList = (items: SessionInfo[]) => - dndActive ? ( - s.id)} strategy={verticalListSortingStrategy}> - {renderRows(items)} - - ) : ( - renderRows(items) - ) - - const renderNestedSessionList = (items: SessionInfo[]) => - dndActive ? ( - - s.id)} strategy={verticalListSortingStrategy}> - {renderRows(items)} - - - ) : ( - renderRows(items) - ) - - const flatVirtualized = !showEmptyState && !groups?.length && sessions.length >= VIRTUALIZE_THRESHOLD + const flatVirtualized = + !showEmptyState && !groups?.length && !tree?.length && sessions.length >= VIRTUALIZE_THRESHOLD let inner: React.ReactNode - let bodyOwnsDndContext = dndActive && !showEmptyState if (showEmptyState) { inner = emptyState - bodyOwnsDndContext = false - } else if (groups?.length) { - const groupNodes = groups.map(group => - dndActive ? ( - + onReorderParents ? ( + ) : ( - ) ) - inner = dndActive ? ( - - groupDndId(g.id))} strategy={verticalListSortingStrategy}> - {groupNodes} - - + inner = onReorderParents ? ( + parent.id)} onReorder={onReorderParents} sensors={dndSensors}> + {parentNodes} + ) : ( - groupNodes + parentNodes ) - bodyOwnsDndContext = false + } else if (groups?.length) { + // Profile/source groups never reorder; render them flat with static rows. + inner = groups.map(group => ( + + )) } else if (flatVirtualized) { - inner = ( + const virtual = ( ) - } else { - inner = renderSessionList(sessions) - } - const body = bodyOwnsDndContext ? ( - - {inner} - - ) : ( - inner - ) + inner = + sessionsDraggable && onReorderSessions ? ( + s.id)} onReorder={onReorderSessions} sensors={dndSensors}> + {virtual} + + ) : ( + virtual + ) + } else if (sessionsDraggable && onReorderSessions) { + inner = ( + s.id)} onReorder={onReorderSessions} sensors={dndSensors}> + {sessions.map(session => renderRow(session, true))} + + ) + } else { + inner = renderRows(sessions) + } // The virtualizer owns its own scroller, so suppress the wrapper's overflow // to avoid a double scroll container. @@ -1321,7 +1343,7 @@ function SidebarSessionsSection({ /> {open && ( - {body} + {inner} {footer} )} @@ -1366,6 +1388,20 @@ function SidebarWorkspaceGroup({ const hiddenCount = Math.max(0, totalCount - visibleSessions.length) const nextCount = Math.min(pageStep, hiddenCount) + // Leading glyph: profile color dot, platform avatar, or a branch mark for a + // worktree. When reorderable it doubles as the drag handle (icon ↔ grabber). + const leadingIcon = group.color ? ( +
@@ -230,6 +230,9 @@ interface FileTreeBodyProps { onLoadChildren: (id: string) => void | Promise onNodeOpenChange: (id: string, open: boolean) => void onPreviewFile?: (path: string) => void + /** Force-reload the root. The hook also auto-retries while errored, so this + * is the impatient-user path. */ + onRetry?: () => void openState: ReturnType['openState'] } @@ -244,6 +247,7 @@ function FileTreeBody({ onLoadChildren, onNodeOpenChange, onPreviewFile, + onRetry, openState }: FileTreeBodyProps) { const { t } = useI18n() @@ -254,7 +258,20 @@ function FileTreeBody({ } if (error) { - return + return ( +
+ + {onRetry && ( + + )} +
+ ) } if (loading && data.length === 0) { diff --git a/apps/desktop/src/app/right-sidebar/terminal/index.tsx b/apps/desktop/src/app/right-sidebar/terminal/index.tsx index c3842366254..5dc8f62ad4f 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/index.tsx +++ b/apps/desktop/src/app/right-sidebar/terminal/index.tsx @@ -9,7 +9,7 @@ import { useI18n } from '@/i18n' import { SidebarPanelLabel } from '../../shell/sidebar-label' import { setTerminalTakeover } from '../store' -import { addSelectionShortcutLabel } from './selection' +import { KbdCombo } from '@/components/ui/kbd' import { useTerminalSession } from './use-terminal-session' interface TerminalTabProps { @@ -69,7 +69,7 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) { variant="secondary" > {t.rightSidebar.addToChat} - {addSelectionShortcutLabel()} +
)} diff --git a/apps/desktop/src/app/right-sidebar/terminal/selection.ts b/apps/desktop/src/app/right-sidebar/terminal/selection.ts index 955a9ea1f18..2e6f0184e7c 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/selection.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/selection.ts @@ -99,8 +99,6 @@ export function resolveSurfaceColor(fallback: string): string { export const isMacPlatform = () => navigator.platform.toLowerCase().includes('mac') -export const addSelectionShortcutLabel = () => (isMacPlatform() ? '⌘L' : 'Ctrl+L') - export function isAddSelectionShortcut(event: KeyboardEvent) { const mod = isMacPlatform() ? event.metaKey : event.ctrlKey diff --git a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts index 7c0f13da5c0..1e5d4d275b7 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts @@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { CSSProperties } from 'react' import { triggerHaptic } from '@/lib/haptics' +import { $filePreviewTarget, $previewTarget } from '@/store/preview' import { useTheme } from '@/themes/context' import { makeTerminalReader, setActiveTerminalReader } from './buffer' @@ -20,6 +21,17 @@ import { type TerminalStatus = 'closed' | 'open' | 'starting' +// ⌘/Ctrl+L is a global shortcut, so a text selection in the file preview pane +// lands in this handler with no xterm selection. Label those with the previewed +// file's name instead of the shell, so the composer ref reads as a file quote +// rather than a bogus "zsh:N lines". +function previewSelectionLabel(): string { + const target = $filePreviewTarget.get() ?? $previewTarget.get() + const source = target?.path || target?.url || '' + + return source.split(/[\\/]/).filter(Boolean).pop() || target?.label?.trim() || '' +} + const HERMES_PATHS_MIME = 'application/x-hermes-paths' function readEscapeSequence(data: string, index: number) { @@ -257,16 +269,20 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes ) const addSelectionToChat = useCallback(() => { - const selectedText = readSelection() || selectionRef.current + const termSelection = (termRef.current?.getSelection() || selectionRef.current).trim() + const selectedText = termSelection || window.getSelection()?.toString() || '' const trimmed = selectedText.trim() if (!trimmed) { return } - const label = - selectionLabelRef.current || - (termRef.current ? terminalSelectionLabel(termRef.current, shellNameRef.current, selectedText) : 'selection') + // Terminal selection → shell-anchored label; anything else came from the + // preview pane sharing this global shortcut → label it with the file. + const label = termSelection + ? selectionLabelRef.current || + (termRef.current ? terminalSelectionLabel(termRef.current, shellNameRef.current, selectedText) : 'selection') + : previewSelectionLabel() || 'selection' onAddSelectionToChatRef.current(trimmed, label) termRef.current?.clearSelection() @@ -275,7 +291,7 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes setSelection('') setSelectionStyle(null) triggerHaptic('selection') - }, [readSelection]) + }, []) // Always listen — gating on the React selection state misses selections the // TUI redraw races. Only swallow ⌘/Ctrl+L when there's text to send, else it @@ -312,13 +328,19 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes const term = new Terminal({ allowProposedApi: true, - allowTransparency: true, + // Opaque canvas = WebGL's crisp fast-path. allowTransparency instead bakes + // glyphs as grayscale-alpha for compositing over a see-through canvas, which + // reads soft on every platform; VS Code keeps it off and our surface + // (--ui-bg-chrome) is opaque anyway, so withSurface paints it solid. + allowTransparency: false, convertEol: true, cursorBlink: true, fontFamily: "'JetBrains Mono', 'Cascadia Code', 'SF Mono', Menlo, Consolas, monospace", fontSize: 11, - fontWeight: '400', - fontWeightBold: '700', + // VS Code's terminal renders 'normal'/'bold' (400/700); we were using Medium + // (500) as the base, which reads a touch heavy at this size. + fontWeight: 'normal', + fontWeightBold: 'bold', letterSpacing: 0, lineHeight: 1.12, // Full-screen TUIs (hermes --tui, vim) grab the mouse, so a plain drag @@ -601,8 +623,10 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes startSession() } - // fonts.ready settles only already-requested faces; bold/italic aren't asked - // for until styled output paints (past atlas init), so warm them up front. + // fonts.ready settles only already-requested faces; the regular (400), + // bold (700) and italic aren't asked for until styled output paints (past + // atlas init), so warm them up front — otherwise the WebGL atlas bakes a + // fallback face and the terminal renders thin until a repaint. const warm = document.fonts?.load ? Promise.allSettled(['400', '700', 'italic 400'].map(v => document.fonts.load(`${v} 11px 'JetBrains Mono'`))) : Promise.resolve() diff --git a/apps/desktop/src/app/session/hooks/use-message-stream.ts b/apps/desktop/src/app/session/hooks/use-message-stream.ts index 442435956ac..c4718b88ac3 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream.ts @@ -2,6 +2,7 @@ import type { QueryClient } from '@tanstack/react-query' import { type MutableRefObject, useCallback, useEffect, useRef } from 'react' import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer' +import { translateNow } from '@/i18n' import { appendAssistantTextPart, appendReasoningPart, @@ -15,11 +16,21 @@ import { upsertToolPart } from '@/lib/chat-messages' import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime' +import { playCompletionSound } from '@/lib/completion-sound' import { gatewayEventRequiresSessionId } from '@/lib/gateway-events' +import { + dedupeGeneratedImageEchoesInParts, + generatedImageEchoSources, + stripGeneratedImageEchoes +} from '@/lib/generated-images' import { triggerHaptic } from '@/lib/haptics' import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors' +import { parseTodos } from '@/lib/todos' import { setClarifyRequest } from '@/store/clarify' +import { setSessionCompacting } from '@/store/compaction' +import { refreshBackgroundProcesses } from '@/store/composer-status' import { $gateway } from '@/store/gateway' +import { dispatchNativeNotification } from '@/store/native-notifications' import { notify } from '@/store/notifications' import { requestDesktopOnboarding } from '@/store/onboarding' import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts' @@ -37,6 +48,7 @@ import { setYoloActive } from '@/store/session' import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents' +import { setSessionTodos } from '@/store/todos' import { recordToolDiff } from '@/store/tool-diffs' import type { RpcEvent } from '@/types/hermes' @@ -52,6 +64,7 @@ interface MessageStreamOptions { queryClient: QueryClient refreshHermesConfig: () => Promise refreshSessions: () => Promise + sessionStateByRuntimeIdRef: MutableRefObject> updateSessionState: ( sessionId: string, updater: (state: ClientSessionState) => ClientSessionState, @@ -67,15 +80,7 @@ interface QueuedStreamDeltas { type SessionRuntimeStatePatch = Partial< Pick< ClientSessionState, - | 'branch' - | 'cwd' - | 'fast' - | 'model' - | 'personality' - | 'provider' - | 'reasoningEffort' - | 'serviceTier' - | 'yolo' + 'branch' | 'cwd' | 'fast' | 'model' | 'personality' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo' > > @@ -253,8 +258,14 @@ export function useMessageStream({ queryClient, refreshHermesConfig, refreshSessions, + sessionStateByRuntimeIdRef, updateSessionState }: MessageStreamOptions) { + const sessionInterrupted = useCallback( + (sessionId: string) => sessionStateByRuntimeIdRef.current.get(sessionId)?.interrupted ?? false, + [sessionStateByRuntimeIdRef] + ) + // Patch the in-flight assistant message (or seed it). Centralises the // streamId/groupId bookkeeping every event callback would otherwise repeat. const mutateStream = useCallback( @@ -323,6 +334,8 @@ export function useMessageStream({ const flushHandleRef = useRef(null) const lastFlushAtRef = useRef(0) const nativeSubagentSessionsRef = useRef>(new Set()) + // Turns that auto-compacted: skip post-turn hydrate so live scrollback survives. + const compactedTurnRef = useRef>(new Set()) const flushQueuedDeltas = useCallback( (sessionId?: string) => { @@ -341,7 +354,7 @@ export function useMessageStream({ if (queued.assistant) { mutateStream( id, - parts => appendAssistantTextPart(parts, queued.assistant), + parts => dedupeGeneratedImageEchoesInParts(appendAssistantTextPart(parts, queued.assistant)), () => [assistantTextPart(queued.assistant)] ) } @@ -478,6 +491,20 @@ export function useMessageStream({ // a tool part can't jump ahead of the text that preceded it. flushQueuedDeltas(sessionId) + if (sessionInterrupted(sessionId)) { + return + } + + // The composer status stack owns todo display now (no inline panel) — + // mirror every todo state the tool reports into its session store. + if (payload?.name === 'todo') { + const todos = parseTodos(payload.todos) ?? parseTodos(payload.result) ?? parseTodos(payload.args) + + if (todos) { + setSessionTodos(sessionId, todos) + } + } + if (!nativeSubagentSessionsRef.current.has(sessionId)) { for (const subagentPayload of delegateTaskPayloads(payload, phase, sourceEventType)) { upsertSubagent( @@ -491,12 +518,12 @@ export function useMessageStream({ mutateStream( sessionId, - parts => upsertToolPart(parts, payload, phase), + parts => dedupeGeneratedImageEchoesInParts(upsertToolPart(parts, payload, phase)), () => upsertToolPart([], payload, phase), { pending: m => phase !== 'complete' || (m.pending ?? false) } ) }, - [flushQueuedDeltas, mutateStream] + [flushQueuedDeltas, mutateStream, sessionInterrupted] ) const completeAssistantMessage = useCallback( @@ -524,9 +551,11 @@ export function useMessageStream({ const finalText = renderMediaTags(text).trim() const completionError = completionErrorText(finalText) const normalize = (value: string) => value.replace(/\s+/g, ' ').trim() - const dedupeReference = normalize(finalText) const replaceTextPart = (parts: ChatMessagePart[]) => { + const visibleFinalText = stripGeneratedImageEchoes(finalText, generatedImageEchoSources(parts)).trim() + const dedupeReference = normalize(visibleFinalText) + const kept = parts.filter(part => { if (part.type === 'text') { return false @@ -541,7 +570,7 @@ export function useMessageStream({ return !(r && (dedupeReference.startsWith(r) || r.startsWith(dedupeReference))) }) - return finalText ? [...kept, assistantTextPart(finalText)] : kept + return visibleFinalText ? [...kept, assistantTextPart(visibleFinalText)] : kept } const completeMessage = (message: ChatMessage): ChatMessage => @@ -613,18 +642,22 @@ export function useMessageStream({ void refreshSessions().catch(() => undefined) + if (compactedTurnRef.current.delete(sessionId)) { + shouldHydrate = false + } + if (shouldHydrate) { void hydrateFromStoredSession(3, completedState.storedSessionId, sessionId) } - if (document.hidden && sessionId === activeSessionIdRef.current) { - void window.hermesDesktop?.notify({ - title: 'Hermes finished', - body: text.slice(0, 140) || 'The response is ready.' - }) - } + dispatchNativeNotification({ + body: text.slice(0, 140) || translateNow('notifications.native.turnDoneBody'), + kind: 'turnDone', + sessionId, + title: translateNow('notifications.native.turnDoneTitle') + }) }, - [activeSessionIdRef, hydrateFromStoredSession, refreshSessions, updateSessionState] + [hydrateFromStoredSession, refreshSessions, updateSessionState] ) const failAssistantMessage = useCallback( @@ -677,9 +710,11 @@ export function useMessageStream({ (event: RpcEvent) => { const payload = event.payload as GatewayEventPayload | undefined const explicitSid = event.session_id || '' + if (!explicitSid && gatewayEventRequiresSessionId(event.type)) { return } + const sessionId = explicitSid || activeSessionIdRef.current const isActiveEvent = !!sessionId && sessionId === activeSessionIdRef.current @@ -797,6 +832,8 @@ export function useMessageStream({ flushQueuedDeltas(sessionId) clearSessionSubagents(sessionId) + setSessionCompacting(sessionId, false) + compactedTurnRef.current.delete(sessionId) nativeSubagentSessionsRef.current.delete(sessionId) if (isActiveEvent) { @@ -842,12 +879,11 @@ export function useMessageStream({ // session so a background turn finishing can't wipe the active chat's // prompt, and vice versa. clearAllPrompts(sessionId) + setSessionCompacting(sessionId, false) flushQueuedDeltas(sessionId) - if (isActiveEvent) { - triggerHaptic('streamDone') - } + playCompletionSound() const finalText = coerceGatewayText(payload?.text) || coerceGatewayText(payload?.rendered) completeAssistantMessage(sessionId, finalText) @@ -875,13 +911,19 @@ export function useMessageStream({ // the sidebar indicator clears as soon as it's answered, not only at // message.complete. updateSessionState(sessionId, state => (state.needsInput ? { ...state, needsInput: false } : state)) + + // terminal/process tool calls are the only things that spawn or reap + // background processes — sync the composer status stack right after. + if (!sessionInterrupted(sessionId) && (payload?.name === 'terminal' || payload?.name === 'process')) { + void refreshBackgroundProcesses(sessionId) + } } if (typeof payload?.inline_diff === 'string' && payload.inline_diff.trim()) { recordToolDiff(payload.tool_id || payload.name || '', payload.inline_diff) } } else if (SUBAGENT_EVENT_TYPES.has(event.type)) { - if (sessionId && payload) { + if (sessionId && payload && !sessionInterrupted(sessionId)) { if (!nativeSubagentSessionsRef.current.has(sessionId)) { pruneDelegateFallbackSubagents(sessionId) } @@ -924,6 +966,13 @@ export function useMessageStream({ if (sessionId) { updateSessionState(sessionId, state => ({ ...state, needsInput: true })) } + + dispatchNativeNotification({ + body: question, + kind: 'input', + sessionId, + title: translateNow('notifications.native.inputTitle') + }) } } else if (event.type === 'approval.request') { // Dangerous-command / execute_code approval. The Python side is blocked @@ -932,17 +981,31 @@ export function useMessageStream({ // Park it per-session (like clarify) so a *background* profile's turn can // raise it and wait — the sidebar flags "needs input" and the inline bar // surfaces once the user focuses that chat. + const command = typeof payload?.command === 'string' ? payload.command : '' + const description = typeof payload?.description === 'string' ? payload.description : 'dangerous command' + setApprovalRequest({ // false only when a tirith warning forbids it; backend omits the field otherwise. allowPermanent: payload?.allow_permanent !== false, - command: typeof payload?.command === 'string' ? payload.command : '', - description: typeof payload?.description === 'string' ? payload.description : 'dangerous command', + command, + description, sessionId: sessionId ?? null }) if (sessionId) { updateSessionState(sessionId, state => ({ ...state, needsInput: true })) } + + dispatchNativeNotification({ + actions: [ + { id: 'approve', text: translateNow('notifications.native.approveAction') }, + { id: 'reject', text: translateNow('notifications.native.rejectAction') } + ], + body: command || description, + kind: 'approval', + sessionId, + title: translateNow('notifications.native.approvalTitle') + }) } else if (event.type === 'sudo.request') { // Sudo password capture (tools/terminal_tool.py). Blocked on // sudo.respond {request_id, password}. @@ -954,6 +1017,13 @@ export function useMessageStream({ if (sessionId) { updateSessionState(sessionId, state => ({ ...state, needsInput: true })) } + + dispatchNativeNotification({ + body: translateNow('notifications.native.inputBody'), + kind: 'input', + sessionId, + title: translateNow('notifications.native.inputTitle') + }) } } else if (event.type === 'secret.request') { // Skill credential capture (tools/skills_tool.py). Blocked on @@ -961,16 +1031,26 @@ export function useMessageStream({ const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' if (requestId) { + const envVar = typeof payload?.env_var === 'string' ? payload.env_var : '' + const promptText = typeof payload?.prompt === 'string' ? payload.prompt : '' + setSecretRequest({ requestId, - envVar: typeof payload?.env_var === 'string' ? payload.env_var : '', - prompt: typeof payload?.prompt === 'string' ? payload.prompt : '', + envVar, + prompt: promptText, sessionId: sessionId ?? null }) if (sessionId) { updateSessionState(sessionId, state => ({ ...state, needsInput: true })) } + + dispatchNativeNotification({ + body: promptText || envVar || translateNow('notifications.native.inputBody'), + kind: 'input', + sessionId, + title: translateNow('notifications.native.inputTitle') + }) } } else if (event.type === 'terminal.read.request') { // read_terminal tool: serialize the renderer's xterm buffer and answer @@ -987,6 +1067,15 @@ export function useMessageStream({ text: result ? JSON.stringify(result) : '' }) } + } else if (event.type === 'status.update') { + if (sessionId && payload?.kind === 'compacting') { + setSessionCompacting(sessionId, true) + compactedTurnRef.current.add(sessionId) + } else if (sessionId && payload?.kind === 'process') { + // The gateway's notification poller announces background process + // completions / watch matches here — re-sync the status stack. + void refreshBackgroundProcesses(sessionId) + } } else if (event.type === 'error') { const errorMessage = payload?.message || 'Hermes reported an error' const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage) @@ -996,8 +1085,17 @@ export function useMessageStream({ // the failed turn (same intent as the message.complete clear). if (sessionId) { clearAllPrompts(sessionId) + setSessionCompacting(sessionId, false) + compactedTurnRef.current.delete(sessionId) } + dispatchNativeNotification({ + body: errorMessage, + kind: 'turnError', + sessionId, + title: translateNow('notifications.native.turnErrorTitle') + }) + if (looksLikeProviderSetup) { requestDesktopOnboarding(errorMessage) } else if (isActiveEvent) { @@ -1027,6 +1125,7 @@ export function useMessageStream({ flushQueuedDeltas, queryClient, refreshHermesConfig, + sessionInterrupted, updateSessionState, upsertToolCall ] diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx index e7dfe9d7da5..f9d9e58d09d 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx @@ -3,8 +3,9 @@ import type { MutableRefObject } from 'react' import { useEffect, useRef } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { textPart } from '@/lib/chat-messages' import { $composerAttachments, type ComposerAttachment } from '@/store/composer' -import { $connection, $sessions, setSessions } from '@/store/session' +import { $busy, $connection, $messages, $sessions, setSessions } from '@/store/session' import type { SessionInfo } from '@/types/hermes' import { uploadComposerAttachment, usePromptActions } from './use-prompt-actions' @@ -43,6 +44,7 @@ function sessionInfo(overrides: Partial = {}): SessionInfo { interface HarnessHandle { cancelRun: () => Promise + restoreToMessage: (messageId: string) => Promise steerPrompt: (text: string) => Promise submitText: ( text: string, @@ -57,6 +59,7 @@ function Harness({ refreshSessions, requestGateway, resumeStoredSession, + seedMessages, storedSessionId }: { busyRef?: MutableRefObject @@ -65,6 +68,7 @@ function Harness({ refreshSessions: () => Promise requestGateway: (method: string, params?: Record) => Promise resumeStoredSession?: (storedSessionId: string) => Promise | void + seedMessages?: unknown[] storedSessionId?: null | string }) { const activeSessionIdRef: MutableRefObject = { current: RUNTIME_SESSION_ID } @@ -73,7 +77,7 @@ function Harness({ } const localBusyRef = busyRef ?? { current: false } const stateRef = useRef({ - messages: [], + messages: seedMessages ?? [], busy: false, awaitingResponse: false, interrupted: true @@ -105,10 +109,11 @@ function Harness({ useEffect(() => { onReady({ cancelRun: actions.cancelRun, + restoreToMessage: actions.restoreToMessage, steerPrompt: actions.steerPrompt, submitText: actions.submitText }) - }, [actions.cancelRun, actions.steerPrompt, actions.submitText, onReady]) + }, [actions.cancelRun, actions.restoreToMessage, actions.steerPrompt, actions.submitText, onReady]) return null } @@ -320,6 +325,81 @@ describe('usePromptActions submit / queue drain semantics', () => { }) }) + it('a rejected fromQueue drain returns false (entry stays queued) and a later retry sends it', async () => { + // A stale-session 404 must not strand the queued entry: submitPrompt returns + // false on failure so the composer keeps it, and the edge-independent + // auto-drain re-attempts once the session is idle again. storedSessionId is + // null so the session.resume recovery path is skipped and the error surfaces. + let attempt = 0 + const requestGateway = vi.fn(async (method: string) => { + if (method === 'prompt.submit') { + attempt += 1 + + if (attempt === 1) { + throw new Error('404: {"detail":"Session not found"}') + } + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + render( + (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + storedSessionId={null} + /> + ) + + const first = await handle!.submitText('please send me', { fromQueue: true }) + expect(first).toBe(false) + + const second = await handle!.submitText('please send me', { fromQueue: true }) + expect(second).toBe(true) + expect(requestGateway).toHaveBeenCalledWith('prompt.submit', { + session_id: RUNTIME_SESSION_ID, + text: 'please send me' + }) + }) + + it('rides out a transient "session busy" so the user never sees it (retries, no error bubble)', async () => { + // A submit racing the settle edge can hit a transient 4009 before the turn + // has fully wound down. It must be invisible: retried in place until the + // gateway accepts, never a red "session busy" bubble. + let attempt = 0 + const seeds: Record[] = [] + const requestGateway = vi.fn(async (method: string) => { + if (method === 'prompt.submit') { + attempt += 1 + + if (attempt === 1) { + throw new Error('4009: session busy') + } + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + render( + (handle = h)} + onSeedState={s => seeds.push(s)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + /> + ) + + expect(await handle!.submitText('sent while settling')).toBe(true) + expect(attempt).toBe(2) // rode past the busy on the second try + // No assistant-error message was appended for the transient busy. + expect(seeds.some(s => Array.isArray(s.messages) && (s.messages as { error?: string }[]).some(m => m.error))).toBe( + false + ) + }) + it('a normal (non-queue) submit still respects the busyRef guard', async () => { const busyRef = { current: true } const requestGateway = vi.fn(async () => ({}) as never) @@ -395,6 +475,125 @@ describe('usePromptActions steerPrompt', () => { }) }) +describe('usePromptActions restoreToMessage', () => { + beforeEach(() => { + $busy.set(false) + $messages.set([ + { id: 'u1', role: 'user', parts: [textPart('first prompt')] }, + { id: 'a1', role: 'assistant', parts: [textPart('first answer')] }, + { id: 'u2', role: 'user', parts: [textPart('second prompt')] }, + { id: 'a2', role: 'assistant', parts: [textPart('second answer')] } + ]) + }) + + afterEach(() => { + cleanup() + $busy.set(false) + $messages.set([]) + vi.restoreAllMocks() + }) + + it('rewinds to the target user turn and resubmits its text', async () => { + const requestGateway = vi.fn(async () => ({}) as never) + let lastState: Record = {} + + let handle: HarnessHandle | null = null + render( + (handle = h)} + onSeedState={state => (lastState = state)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + seedMessages={$messages.get()} + /> + ) + + await handle!.restoreToMessage('u1') + + // Ordinal 0 = "truncate before the first visible user message": the gateway + // drops that turn and everything after, then runs the same text again. + expect(requestGateway).toHaveBeenCalledWith('prompt.submit', { + session_id: RUNTIME_SESSION_ID, + text: 'first prompt', + truncate_before_user_ordinal: 0 + }) + expect((lastState.messages as { id: string }[]).map(m => m.id)).toEqual(['u1']) + expect(lastState.busy).toBe(true) + }) + + it('rethrows gateway failures and clears the busy flags for the dialog to surface', async () => { + const requestGateway = vi.fn(async () => { + throw new Error('gateway exploded') + }) + + let lastState: Record = {} + let handle: HarnessHandle | null = null + + render( + (handle = h)} + onSeedState={state => (lastState = state)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + /> + ) + + await expect(handle!.restoreToMessage('u2')).rejects.toThrow('gateway exploded') + expect(lastState.busy).toBe(false) + }) + + it('interrupts the live turn and retries past "session busy" when reverting mid-stream', async () => { + $busy.set(true) + + let submitAttempts = 0 + const requestGateway = vi.fn(async (method: string) => { + if (method === 'prompt.submit') { + submitAttempts += 1 + + // The cooperative interrupt hasn't wound the turn down yet on the first + // try; the second attempt lands once the gateway reports idle. + if (submitAttempts === 1) { + throw new Error('session busy') + } + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + render( + (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + seedMessages={$messages.get()} + /> + ) + + await handle!.restoreToMessage('u1') + + expect(requestGateway).toHaveBeenCalledWith('session.interrupt', { session_id: RUNTIME_SESSION_ID }) + expect(submitAttempts).toBe(2) + expect(requestGateway).toHaveBeenCalledWith('prompt.submit', { + session_id: RUNTIME_SESSION_ID, + text: 'first prompt', + truncate_before_user_ordinal: 0 + }) + }) + + it('ignores non-user targets and unknown ids without touching the gateway', async () => { + const requestGateway = vi.fn(async () => ({}) as never) + + let handle: HarnessHandle | null = null + render( (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />) + + await handle!.restoreToMessage('a1') + await handle!.restoreToMessage('missing') + + expect(requestGateway).not.toHaveBeenCalled() + }) +}) + describe('usePromptActions file attachment sync', () => { afterEach(() => { cleanup() @@ -677,7 +876,7 @@ describe('usePromptActions sleep/wake session recovery', () => { const requestGateway = vi.fn(async (method: string) => { calls.push(method) if (method === 'prompt.submit') { - throw new Error('session busy') + throw new Error('gateway exploded') } return {} as never }) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts index b09d86ffd10..4c1b50b83ad 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts @@ -35,6 +35,7 @@ import { terminalContextBlocksFromDraft, updateComposerAttachment } from '@/store/composer' +import { resetSessionBackground } from '@/store/composer-status' import { clearNotifications, notify, notifyError } from '@/store/notifications' import { requestDesktopOnboarding } from '@/store/onboarding' import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile' @@ -52,6 +53,8 @@ import { setSessions, setYoloActive } from '@/store/session' +import { clearSessionSubagents } from '@/store/subagents' +import { clearSessionTodos } from '@/store/todos' import type { ClientSessionState, @@ -114,6 +117,40 @@ function isSessionNotFoundError(error: unknown): boolean { return /session not found/i.test(message) } +// The gateway refuses prompt.submit while a turn is running (4009 "session +// busy"). It's a transient concurrency guard, never a user-facing error: a +// submit racing the settle edge (or a rewind interrupting mid-turn) just waits +// a beat for the turn to wind down, then lands. Bounded so a genuinely stuck +// turn still surfaces eventually. +const SESSION_BUSY_RETRY_TIMEOUT_MS = 6_000 +const SESSION_BUSY_RETRY_INTERVAL_MS = 150 + +function isSessionBusyError(error: unknown): boolean { + return /session busy/i.test(error instanceof Error ? error.message : String(error)) +} + +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) + +// Retry a gateway call across transient "session busy" so it never reaches the +// user — the turn settles within the deadline and the call lands. +async function withSessionBusyRetry(call: () => Promise): Promise { + const deadline = Date.now() + SESSION_BUSY_RETRY_TIMEOUT_MS + + for (;;) { + try { + return await call() + } catch (err) { + if (isSessionBusyError(err) && Date.now() < deadline) { + await sleep(SESSION_BUSY_RETRY_INTERVAL_MS) + + continue + } + + throw err + } + } +} + function base64FromDataUrl(dataUrl: string): string { const comma = dataUrl.indexOf(',') @@ -523,6 +560,7 @@ export function usePromptActions({ // Images use their base64 preview so the thumbnail renders inline without // a (remote-mode 403-prone) /api/media fetch — see optimisticAttachmentRef. let attachmentRefs = attachments.map(optimisticAttachmentRef).filter((r): r is string => Boolean(r)) + const buildContextText = (atts: ComposerAttachment[]): string => { const contextRefs = atts .map(a => a.refText) @@ -540,6 +578,7 @@ export function usePromptActions({ // bounce the drained send. The drain lock serializes them; the user path // keeps the guard so a stray Enter mid-turn can't double-submit. const hasSendable = Boolean(visibleText || terminalContextBlocks || attachments.length || hasImage) + if (!hasSendable || (!options?.fromQueue && busyRef.current)) { return false } @@ -652,6 +691,7 @@ export function usePromptActions({ const syncedAttachments = await syncAttachmentsForSubmit(sessionId, attachments, { updateComposerAttachments: usingComposerAttachments }) + // Rewrite the optimistic message + prompt text with the synced refs so // the gateway receives @file: paths that resolve in its workspace. // (Images keep their inline base64 preview — see optimisticAttachmentRef.) @@ -665,18 +705,19 @@ export function usePromptActions({ let submitErr: unknown = null try { - await requestGateway('prompt.submit', { session_id: sessionId, text }) + await withSessionBusyRetry(() => requestGateway('prompt.submit', { session_id: sessionId, text })) } catch (firstErr) { if (isSessionNotFoundError(firstErr) && selectedStoredSessionIdRef.current) { // Re-register the session in the gateway and get a fresh live ID. const resumed = await requestGateway<{ session_id: string }>('session.resume', { session_id: selectedStoredSessionIdRef.current }) + const recoveredId = resumed?.session_id if (recoveredId) { activeSessionIdRef.current = recoveredId - await requestGateway('prompt.submit', { session_id: recoveredId, text }) + await withSessionBusyRetry(() => requestGateway('prompt.submit', { session_id: recoveredId, text })) } else { submitErr = firstErr } @@ -695,9 +736,17 @@ export function usePromptActions({ return true } catch (err) { + releaseBusy() + + // A queued drain that raced a not-yet-settled turn gets a transient + // "session busy" (4009). Don't surface an error bubble/toast — the entry + // stays queued and the composer's bounded auto-drain retries when idle. + if (options?.fromQueue && isSessionBusyError(err)) { + return false + } + const message = inlineErrorMessage(err, copy.promptFailed) - releaseBusy() updateSessionState(sessionId, state => ({ ...state, messages: [ @@ -1234,12 +1283,13 @@ export function usePromptActions({ const cancelRun = useCallback(async () => { const sessionId = activeSessionId || activeSessionIdRef.current + const releaseBusy = () => { + setMutableRef(busyRef, false) + setBusy(false) + } setAwaitingResponse(false) - // Interrupting keeps whatever was already generated and just - // stops — no "[interrupted]" marker. A pending/streaming message with no - // body text is dropped entirely so we never leave an empty bubble behind. const finalizeMessages = (messages: ChatMessage[], streamId?: string | null) => messages .filter( @@ -1251,8 +1301,7 @@ export function usePromptActions({ ) if (!sessionId) { - setMutableRef(busyRef, false) - setBusy(false) + releaseBusy() setMessages(finalizeMessages($messages.get())) return @@ -1260,13 +1309,12 @@ export function usePromptActions({ updateSessionState(sessionId, state => { const streamId = state.streamId - const messages = finalizeMessages(state.messages, streamId) return { ...state, messages, - busy: true, + busy: false, awaitingResponse: false, streamId: null, pendingBranchGroup: null, @@ -1274,8 +1322,13 @@ export function usePromptActions({ } }) + clearSessionTodos(sessionId) + clearSessionSubagents(sessionId) + resetSessionBackground(sessionId) + try { await requestGateway('session.interrupt', { session_id: sessionId }) + releaseBusy() } catch (err) { let stopError = err @@ -1284,11 +1337,13 @@ export function usePromptActions({ const resumed = await requestGateway<{ session_id: string }>('session.resume', { session_id: selectedStoredSessionIdRef.current }) + const recoveredId = resumed?.session_id if (recoveredId) { activeSessionIdRef.current = recoveredId await requestGateway('session.interrupt', { session_id: recoveredId }) + releaseBusy() return } @@ -1297,8 +1352,7 @@ export function usePromptActions({ } } - setMutableRef(busyRef, false) - setBusy(false) + releaseBusy() notifyError(stopError, copy.stopFailed) } }, [ @@ -1421,13 +1475,101 @@ export function usePromptActions({ [activeSessionId, copy.regenerateFailed, requestGateway, updateSessionState] ) + // Cursor-style "restore checkpoint": rewind the conversation to a past user + // prompt and run it again from there. Reuses the edit composer's rewind + // mechanism — `prompt.submit` with `truncate_before_user_ordinal` drops that + // user turn and everything after it from the session history, then the same + // text is submitted as a fresh turn. Callers confirm before invoking; errors + // are rethrown so the confirmation dialog can surface them inline. + // Submit a rewind (truncate-before-ordinal + resubmit). Because edit/restore + // can fire while a turn is streaming, interrupt the live turn first — the + // cooperative interrupt takes a beat, so the shared busy-retry rides it out. + const submitRewindPrompt = useCallback( + async (sessionId: string, text: string, truncateOrdinal: number | undefined, wasRunning: boolean) => { + if (wasRunning) { + try { + await requestGateway('session.interrupt', { session_id: sessionId }) + } catch { + // Best-effort — the busy-retry below still gates the submit. + } + } + + await withSessionBusyRetry(() => + requestGateway('prompt.submit', { + session_id: sessionId, + text, + ...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal }) + }) + ) + }, + [requestGateway] + ) + + const restoreToMessage = useCallback( + async (messageId: string) => { + const sessionId = activeSessionId || activeSessionIdRef.current + + if (!sessionId) { + return + } + + const messages = $messages.get() + const sourceIndex = messages.findIndex(m => m.id === messageId) + const source = messages[sourceIndex] + + if (!source || source.role !== 'user') { + return + } + + const text = chatMessageText(source).trim() + + if (!text) { + return + } + + const wasRunning = $busy.get() + const truncateBeforeUserOrdinal = visibleUserOrdinal(messages, sourceIndex) + + // The turns we're discarding may have spawned todos and background + // processes; they belong to the abandoned timeline, so wipe their status + // rows (and kill the live processes) before the fresh run repopulates. + clearSessionTodos(sessionId) + resetSessionBackground(sessionId) + + clearNotifications() + setMutableRef(busyRef, true) + setBusy(true) + setAwaitingResponse(true) + updateSessionState(sessionId, state => ({ + ...state, + busy: true, + awaitingResponse: true, + pendingBranchGroup: null, + sawAssistantPayload: false, + interrupted: false, + messages: state.messages.slice(0, sourceIndex + 1) + })) + + try { + await submitRewindPrompt(sessionId, text, truncateBeforeUserOrdinal, wasRunning) + } catch (err) { + setMutableRef(busyRef, false) + setBusy(false) + setAwaitingResponse(false) + updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false })) + throw err + } + }, + [activeSessionId, activeSessionIdRef, busyRef, submitRewindPrompt, updateSessionState] + ) + const editMessage = useCallback( async (edited: AppendMessage) => { const sessionId = activeSessionId || activeSessionIdRef.current const sourceId = edited.sourceId || edited.parentId const text = appendText(edited) - if (!sessionId || !sourceId || !text || edited.role !== 'user' || $busy.get()) { + if (!sessionId || !sourceId || !text || edited.role !== 'user') { return } @@ -1439,12 +1581,23 @@ export function usePromptActions({ return } + // Sending an edit is a revert: rewind to this prompt and re-run with the + // new text. It can fire mid-turn, so capture the live state — the submit + // helper interrupts first when a turn is running. + const wasRunning = $busy.get() + // Failed turn: optimistic user msg never reached the gateway, so truncating // by ordinal would 422. Submit as a plain resend instead. const nextMessage = messages[sourceIndex + 1] const isFailedTurn = nextMessage?.role === 'assistant' && Boolean(nextMessage.error) const editedMessage: ChatMessage = { ...source, parts: [textPart(text)] } + // Editing rewinds the conversation to this prompt — same as restore — so + // drop the abandoned timeline's todos/background rows (and kill the live + // processes) before the re-run repopulates them. + clearSessionTodos(sessionId) + resetSessionBackground(sessionId) + clearNotifications() setMutableRef(busyRef, true) setBusy(true) @@ -1459,24 +1612,18 @@ export function usePromptActions({ messages: [...state.messages.slice(0, sourceIndex), editedMessage] })) - const submit = (truncateOrdinal?: number) => - requestGateway('prompt.submit', { - session_id: sessionId, - text, - ...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal }) - }) - const isStaleTargetError = (err: unknown) => /no longer in session history|not in session history/i.test(err instanceof Error ? err.message : String(err)) try { - await submit(isFailedTurn ? undefined : visibleUserOrdinal(messages, sourceIndex)) + await submitRewindPrompt(sessionId, text, isFailedTurn ? undefined : visibleUserOrdinal(messages, sourceIndex), wasRunning) } catch (err) { let surfaced = err if (!isFailedTurn && isStaleTargetError(err)) { try { - await submit() + // Already interrupted on the first attempt — submit as a plain resend. + await submitRewindPrompt(sessionId, text, undefined, false) return } catch (retryErr) { @@ -1491,7 +1638,7 @@ export function usePromptActions({ notifyError(surfaced, copy.editFailed) } }, - [activeSessionId, activeSessionIdRef, busyRef, copy.editFailed, requestGateway, updateSessionState] + [activeSessionId, activeSessionIdRef, busyRef, copy.editFailed, submitRewindPrompt, updateSessionState] ) const handleThreadMessagesChange = useCallback( @@ -1534,6 +1681,7 @@ export function usePromptActions({ handleThreadMessagesChange, handoffSession, reloadFromMessage, + restoreToMessage, steerPrompt, submitText, transcribeVoiceAudio diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx new file mode 100644 index 00000000000..739e8b93756 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -0,0 +1,119 @@ +import { cleanup, render, waitFor } from '@testing-library/react' +import type { MutableRefObject } from 'react' +import { useEffect } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { $activeGatewayProfile, $newChatProfile } from '@/store/profile' +import { $currentCwd } from '@/store/session' + +import type { ClientSessionState } from '../../types' + +import { useSessionActions } from './use-session-actions' + +vi.mock('@/hermes', async importOriginal => ({ + ...(await importOriginal>()), + deleteSession: vi.fn(), + getSessionMessages: vi.fn(), + listAllProfileSessions: vi.fn(), + setApiRequestProfile: vi.fn(), + setSessionArchived: vi.fn() +})) + +const RUNTIME_SESSION_ID = 'rt-new-001' + +function Harness({ + onReady, + requestGateway +}: { + onReady: (create: (preview?: string | null) => Promise) => void + requestGateway: (method: string, params?: Record) => Promise +}) { + const ref = (value: T): MutableRefObject => ({ current: value }) + + const actions = useSessionActions({ + activeSessionId: null, + activeSessionIdRef: ref(null), + busyRef: ref(false), + creatingSessionRef: ref(false), + ensureSessionState: () => ({}) as ClientSessionState, + getRouteToken: () => 'token', + navigate: vi.fn() as never, + requestGateway, + runtimeIdByStoredSessionIdRef: ref(new Map()), + selectedStoredSessionId: null, + selectedStoredSessionIdRef: ref(null), + sessionStateByRuntimeIdRef: ref(new Map()), + syncSessionStateToView: vi.fn(), + updateSessionState: () => ({}) as ClientSessionState + }) + + useEffect(() => { + onReady(actions.createBackendSessionForSend) + }, [actions.createBackendSessionForSend, onReady]) + + return null +} + +async function createWith(profileSetup: () => void): Promise | undefined> { + let createParams: Record | undefined + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + if (method === 'session.create') { + createParams = params + + return { session_id: RUNTIME_SESSION_ID, stored_session_id: null } as never + } + + return {} as never + }) + + $currentCwd.set('') + profileSetup() + + let create: ((preview?: string | null) => Promise) | null = null + render( (create = c)} requestGateway={requestGateway} />) + await waitFor(() => expect(create).not.toBeNull()) + await create!() + + return createParams +} + +describe('createBackendSessionForSend profile routing', () => { + afterEach(() => { + cleanup() + $newChatProfile.set(null) + $activeGatewayProfile.set('default') + vi.restoreAllMocks() + }) + + it('routes a plain new chat (no explicit profile) to the live gateway profile', async () => { + // The "rubberband to default" bug: the top New Session button clears + // $newChatProfile to null. In global-remote mode one backend serves every + // profile, so an omitted `profile` lands the chat on the launch (default) + // profile. The session must instead carry the active gateway profile. + const params = await createWith(() => { + $activeGatewayProfile.set('coder') + $newChatProfile.set(null) + }) + + expect(params).toMatchObject({ profile: 'coder' }) + }) + + it('honours an explicit per-profile "+" selection', async () => { + const params = await createWith(() => { + $activeGatewayProfile.set('coder') + $newChatProfile.set('analyst') + }) + + expect(params).toMatchObject({ profile: 'analyst' }) + }) + + it('passes the default profile for single-profile users (backend resolves it to launch)', async () => { + const params = await createWith(() => { + $activeGatewayProfile.set('default') + $newChatProfile.set(null) + }) + + expect(params).toMatchObject({ profile: 'default' }) + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.ts b/apps/desktop/src/app/session/hooks/use-session-actions.ts index 8a419488740..9ce2ff1a8ff 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions.ts @@ -2,7 +2,7 @@ import type { MutableRefObject } from 'react' import { useCallback, useRef } from 'react' import type { NavigateFunction } from 'react-router-dom' -import { deleteSession, getSessionMessages, listAllProfileSessions, setSessionArchived } from '@/hermes' +import { deleteSession, getSession, getSessionMessages, setSessionArchived } from '@/hermes' import { useI18n } from '@/i18n' import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages' import { normalizePersonalityValue } from '@/lib/chat-runtime' @@ -12,7 +12,7 @@ import { clearQueuedPrompts } from '@/store/composer-queue' import { $pinnedSessionIds } from '@/store/layout' import { clearNotifications, notify, notifyError } from '@/store/notifications' import { requestDesktopOnboarding } from '@/store/onboarding' -import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile' +import { $activeGatewayProfile, $newChatProfile, $profiles, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile' import { $currentCwd, $messages, @@ -43,6 +43,7 @@ import { workspaceCwdForNewSession } from '@/store/session' import { reportBackendContract } from '@/store/updates' +import { isWatchWindow } from '@/store/windows' import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, SessionRuntimeInfo, UsageStats } from '@/types/hermes' import { NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../routes' @@ -235,18 +236,42 @@ async function resolveStoredSession(storedSessionId: string): Promise sessionMatchesStoredId(session, storedSessionId)) + const session = await getSession(storedSessionId) - if (resolved) { - upsertResolvedSession(resolved, storedSessionId) - } + upsertResolvedSession(session, storedSessionId) - return resolved + return session } catch { - return undefined + // Not on the active profile — fall through to the cross-profile probe. } + + // Multi-profile only: probe each other profile by id (still one cheap lookup + // each) rather than pulling every profile's recent sessions. The first hit + // carries its owning `profile`, which routes the resume to the right backend. + const activeKey = normalizeProfileKey($activeGatewayProfile.get()) + + const otherProfiles = $profiles + .get() + .map(profile => normalizeProfileKey(profile.name)) + .filter(key => key !== activeKey) + + for (const profile of otherProfiles) { + try { + const session = await getSession(storedSessionId, profile) + + upsertResolvedSession(session, storedSessionId) + + return session + } catch { + // Not on this profile; try the next. + } + } + + return undefined } type SessionRuntimeStatePatch = Partial< @@ -406,13 +431,17 @@ export function useSessionActions({ creatingSessionRef.current = true try { - // Route the new chat to the chosen profile's backend (null = primary, - // so single-profile users are unaffected). - await ensureGatewayProfile($newChatProfile.get()) + // A plain new session (top "New Session", /new, keybind) leaves + // $newChatProfile null to mean "use the live context"; the per-profile + // "+" sets it explicitly. Resolve null to the active gateway profile so + // session.create always carries it: in global-remote mode one backend + // serves every profile, so an omitted profile param silently lands the + // chat on the launch (default) profile — the "rubberbands back to + // default" bug. This is a no-op for single-profile/local-pooled users: + // a backend resolves its own launch profile to None (_profile_home). + const newChatProfile = $newChatProfile.get() ?? normalizeProfileKey($activeGatewayProfile.get()) + await ensureGatewayProfile(newChatProfile) const cwd = $currentCwd.get().trim() || workspaceCwdForNewSession() - // Pass the owning profile so a new chat under a non-launch profile (global - // remote mode) builds its agent + persists against THAT profile's home/db. - const newChatProfile = $newChatProfile.get() const created = await requestGateway('session.create', { cols: 96, @@ -518,8 +547,31 @@ export function useSessionActions({ const isCurrentResume = () => resumeRequestRef.current === requestId && selectedStoredSessionIdRef.current === storedSessionId + // Paint the click before the profile-resolve / gateway-swap awaits below, + // so there's zero dead air: highlight the row instantly (the sidebar reads + // $selectedStoredSessionId) and, for a cold target, drop the previous + // transcript so the thread shows its loader instead of the old session + // lingering until resume lands. A warm-cached target keeps its transcript — + // the cached fast-path repaints it this same tick. Setting the ref here is + // also what use-route-resume's self-heal assumes ("set synchronously at + // resume entry"). + setFreshDraftReady(false) + clearNotifications() + setSelectedStoredSessionId(storedSessionId) + selectedStoredSessionIdRef.current = storedSessionId + + const warmRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId) + + if (!warmRuntimeId || !sessionStateByRuntimeIdRef.current.get(warmRuntimeId)) { + setActiveSessionId(null) + activeSessionIdRef.current = null + setMessages([]) + } + // Swap the single live gateway to this session's profile before any // gateway call (no-op when it's already on that profile / single-profile). + // resolveStoredSession finds the row by id (cheap), so an uncached pasted + // id loads as fast as a sidebar click instead of hanging on a list scan. const storedForProfile = await resolveStoredSession(storedSessionId) const sessionProfile = storedForProfile?.profile @@ -534,6 +586,7 @@ export function useSessionActions({ if (cachedRuntimeId && cachedState) { const stored = $sessions.get().find(session => session.id === storedSessionId) + const cachedViewState = !cachedState.model && stored?.model != null ? { @@ -606,40 +659,46 @@ export function useSessionActions({ })) } + let resumedRunning = false + try { - // Load the local snapshot first, then ask the gateway to resume. - // Previously these raced: - // 1. clear messages to [] - // 2. local getSessionMessages -> 45 msgs - // 3. a second resume path cleared [] again - // 4. gateway resume -> 43 msgs - // That is the ctrl+R flash chain. Avoid showing an empty thread - // while we already have a route-scoped session id, and don't race the - // local snapshot against gateway resume. + const watchWindow = isWatchWindow() let localSnapshot = $messages.get() + // REST transcript prefetch and the gateway resume RPC are independent + // — run them concurrently so a big session's wall time is + // max(prefetch, resume) instead of their sum. The prefetch paints the + // transcript as soon as it lands; the RPC binds the runtime id. + // Watch windows skip the prefetch — lazy resume attaches the live mirror. + const prefetchPromise = watchWindow ? null : getSessionMessages(storedSessionId, sessionProfile) + + const resumePromise = requestGateway('session.resume', { + session_id: storedSessionId, + cols: 96, + ...(watchWindow ? { lazy: true } : {}), + ...(sessionProfile ? { profile: sessionProfile } : {}) + }) + // The rejection is consumed by the `await` below; this guard only + // keeps it from surfacing as unhandled while the prefetch settles. + resumePromise.catch(() => undefined) + try { - const storedMessages = await getSessionMessages(storedSessionId, sessionProfile) + if (prefetchPromise) { + const storedMessages = await prefetchPromise - if (isCurrentResume()) { - localSnapshot = preserveLocalAssistantErrors(toChatMessages(storedMessages.messages), $messages.get()) + if (isCurrentResume()) { + localSnapshot = preserveLocalAssistantErrors(toChatMessages(storedMessages.messages), $messages.get()) - if (!chatMessageArraysEquivalent($messages.get(), localSnapshot)) { - setMessages(localSnapshot) + if (!chatMessageArraysEquivalent($messages.get(), localSnapshot)) { + setMessages(localSnapshot) + } } } } catch { // Non-fatal: gateway resume below can still hydrate the session. } - const resumed = await requestGateway('session.resume', { - session_id: storedSessionId, - cols: 96, - // Owning profile: in app-global remote mode one backend serves every - // profile, so the gateway opens this profile's state.db + home to - // resume + persist the right session (no-op for single/launch profile). - ...(sessionProfile ? { profile: sessionProfile } : {}) - }) + const resumed = await resumePromise if (!isCurrentResume()) { return @@ -647,25 +706,22 @@ export function useSessionActions({ const currentMessages = $messages.get() - const resumedMessages = preserveLocalAssistantErrors( - reconcileResumeMessages(toChatMessages(resumed.messages), currentMessages), - currentMessages - ) - // Avoid a second visible transcript rebuild on resume/switch. - // `getSessionMessages()` is the stable stored transcript snapshot and - // paints first; `session.resume` can return a slightly different - // runtime-shaped projection (e.g. tool/system coalescing), which was - // causing a second full message-list replacement a second later. - // Keep the already-painted local snapshot for the view/cache when it - // exists; use gateway messages only as a fallback when no local - // snapshot was available. - + // Keep the local snapshot when resume would only reshuffle runtime + // projection. When the REST prefetch already hydrated the transcript, + // skip converting/reconciling the resume payload entirely — on a + // 1000+-message session that second conversion plus the deep + // equivalence compare costs over a second of main-thread time. const preferredMessages = localSnapshot.length > 0 ? localSnapshot - : chatMessageArraysEquivalent(currentMessages, resumedMessages) - ? currentMessages - : resumedMessages + : (() => { + const resumedMessages = preserveLocalAssistantErrors( + reconcileResumeMessages(toChatMessages(resumed.messages), currentMessages), + currentMessages + ) + + return chatMessageArraysEquivalent(currentMessages, resumedMessages) ? currentMessages : resumedMessages + })() const messagesForView = preserveLocalAssistantErrors(preferredMessages, currentMessages) @@ -675,14 +731,16 @@ export function useSessionActions({ patchSessionWorkspace(storedSessionId, runtimeInfo?.cwd) + resumedRunning = Boolean((resumed as { running?: boolean }).running) + updateSessionState( resumed.session_id, state => ({ ...state, ...(runtimeInfo ?? {}), messages: messagesForView, - busy: false, - awaitingResponse: false + busy: resumedRunning, + awaitingResponse: resumedRunning }), storedSessionId ) @@ -701,9 +759,9 @@ export function useSessionActions({ notifyError(err, copy.resumeFailed) } finally { if (isCurrentResume()) { - busyRef.current = false - setBusy(false) - setAwaitingResponse(false) + busyRef.current = resumedRunning + setBusy(resumedRunning) + setAwaitingResponse(resumedRunning) } } }, diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx index c4cb31c0c01..80b74090f33 100644 --- a/apps/desktop/src/app/settings/appearance-settings.tsx +++ b/apps/desktop/src/app/settings/appearance-settings.tsx @@ -9,6 +9,7 @@ import { Check, Download, Loader2, Palette, Trash2 } from '@/lib/icons' import { cn } from '@/lib/utils' import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile' import { $toolViewMode, setToolViewMode } from '@/store/tool-view' +import { $translucency, setTranslucency } from '@/store/translucency' import { useTheme } from '@/themes/context' import { installVscodeThemeFromMarketplace } from '@/themes/install' import { isUserTheme, removeUserTheme, resolveTheme } from '@/themes/user-themes' @@ -135,6 +136,7 @@ export function AppearanceSettings() { const { t, isSavingLocale } = useI18n() const { themeName, mode, availableThemes, setTheme, setMode } = useTheme() const toolViewMode = useStore($toolViewMode) + const translucency = useStore($translucency) const profiles = useStore($profiles) const activeProfileKey = normalizeProfileKey(useStore($activeGatewayProfile)) const a = t.settings.appearance @@ -183,6 +185,32 @@ export function AppearanceSettings() { title={a.colorMode} /> + + { + triggerHaptic('selection') + setTranslucency(Number(event.target.value)) + }} + step={5} + style={{ accentColor: 'var(--dt-primary)' }} + type="range" + value={translucency} + /> + + {translucency}% + +
+ } + description={a.translucencyDesc} + title={a.translucencyTitle} + /> + diff --git a/apps/desktop/src/app/settings/index.tsx b/apps/desktop/src/app/settings/index.tsx index 93ab0c8ecca..6c832799eb2 100644 --- a/apps/desktop/src/app/settings/index.tsx +++ b/apps/desktop/src/app/settings/index.tsx @@ -5,7 +5,7 @@ import { Tip } from '@/components/ui/tooltip' import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' -import { Archive, Globe, Info, KeyRound, Settings2, Sparkles, Wrench, Zap } from '@/lib/icons' +import { Archive, Bell, Globe, Info, KeyRound, Settings2, Sparkles, Wrench, Zap } from '@/lib/icons' import { notifyError } from '@/store/notifications' import { useRouteEnumParam } from '../hooks/use-route-enum-param' @@ -20,6 +20,7 @@ import { SECTIONS } from './constants' import { GatewaySettings } from './gateway-settings' import { KEYS_VIEWS, KeysSettings, type KeysView } from './keys-settings' import { McpSettings } from './mcp-settings' +import { NotificationsSettings } from './notifications-settings' import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings' import { SessionsSettings } from './sessions-settings' import type { SettingsPageProps, SettingsView as SettingsViewId } from './types' @@ -30,6 +31,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [ 'gateway', 'keys', 'mcp', + 'notifications', 'sessions', 'about' ] @@ -101,6 +103,12 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang /> ) })} + setActiveView('notifications')} + />
) : activeView === 'mcp' ? ( + ) : activeView === 'notifications' ? ( + ) : ( )} diff --git a/apps/desktop/src/app/settings/notifications-settings.tsx b/apps/desktop/src/app/settings/notifications-settings.tsx new file mode 100644 index 00000000000..8f23eecd60f --- /dev/null +++ b/apps/desktop/src/app/settings/notifications-settings.tsx @@ -0,0 +1,150 @@ +import { useStore } from '@nanostores/react' +import type { ReactNode } from 'react' + +import { Button } from '@/components/ui/button' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Switch } from '@/components/ui/switch' +import { useI18n } from '@/i18n' +import { COMPLETION_SOUND_VARIANTS, previewCompletionSound } from '@/lib/completion-sound' +import { triggerHaptic } from '@/lib/haptics' +import { Bell, Play } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { $completionSoundVariantId, setCompletionSoundVariantId } from '@/store/completion-sound' +import { + $nativeNotifyPrefs, + NATIVE_NOTIFICATION_KINDS, + sendTestNativeNotification, + setNativeNotifyEnabled, + setNativeNotifyKind +} from '@/store/native-notifications' +import { notify } from '@/store/notifications' + +import { CONTROL_TEXT } from './constants' +import { ListRow, SectionHeading, SettingsContent } from './primitives' + +const CAPTION = 'text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)' + +function Caption({ children, className }: { children: ReactNode; className?: string }) { + return

{children}

+} + +function ToggleRow(props: { + checked: boolean + description: string + disabled?: boolean + label: string + onChange: (on: boolean) => void +}) { + return ( + { + triggerHaptic('selection') + props.onChange(on) + }} + /> + } + description={props.description} + title={props.label} + /> + ) +} + +export function NotificationsSettings() { + const { t } = useI18n() + const prefs = useStore($nativeNotifyPrefs) + const completionSoundVariantId = useStore($completionSoundVariantId) + const copy = t.settings.notifications + + const runTest = async () => { + triggerHaptic('open') + const ok = await sendTestNativeNotification(copy.testTitle, copy.testBody) + notify({ kind: ok ? 'info' : 'error', message: ok ? copy.testSent : copy.testUnsupported }) + } + + return ( + + + {copy.intro} + + + +
+ + {NATIVE_NOTIFICATION_KINDS.map(kind => ( + setNativeNotifyKind(kind, on)} + /> + ))} + +
+ + + + + +
+ } + description={copy.completionSoundDesc} + title={copy.completionSoundTitle} + /> + +
+ + {copy.focusedHint} +
+ + ) +} diff --git a/apps/desktop/src/app/settings/providers-settings.test.tsx b/apps/desktop/src/app/settings/providers-settings.test.tsx new file mode 100644 index 00000000000..8379d203f6c --- /dev/null +++ b/apps/desktop/src/app/settings/providers-settings.test.tsx @@ -0,0 +1,100 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { atom } from 'nanostores' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { OAuthProvider } from '@/types/hermes' + +const listOAuthProviders = vi.fn() +const disconnectOAuthProvider = vi.fn() +const getEnvVars = vi.fn() +const startManualProviderOAuth = vi.fn() +const onboarding = atom({ manual: false }) + +vi.mock('@/hermes', () => ({ + disconnectOAuthProvider: (providerId: string) => disconnectOAuthProvider(providerId), + getEnvVars: () => getEnvVars(), + listOAuthProviders: () => listOAuthProviders() +})) + +vi.mock('@/store/onboarding', () => ({ + $desktopOnboarding: onboarding, + startManualProviderOAuth: (providerId: string) => startManualProviderOAuth(providerId) +})) + +function provider(id: string, loggedIn: boolean, patch: Partial = {}): OAuthProvider { + return { + cli_command: `hermes auth add ${id}`, + disconnectable: true, + docs_url: '', + flow: 'device_code', + id, + name: id === 'nous' ? 'Nous Portal' : 'MiniMax', + status: { + logged_in: loggedIn + }, + ...patch + } +} + +beforeEach(() => { + onboarding.set({ manual: false }) + getEnvVars.mockResolvedValue({}) + disconnectOAuthProvider.mockResolvedValue({ ok: true, provider: 'nous' }) + listOAuthProviders.mockResolvedValue({ + providers: [provider('nous', true), provider('minimax-oauth', false)] + }) + vi.spyOn(window, 'confirm').mockReturnValue(true) +}) + +afterEach(() => { + cleanup() + vi.restoreAllMocks() + vi.clearAllMocks() +}) + +async function renderProvidersSettings() { + const { ProvidersSettings } = await import('./providers-settings') + + return render() +} + +describe('ProvidersSettings', () => { + it('disconnects a connected provider account and refreshes the accounts list', async () => { + await renderProvidersSettings() + + const remove = await screen.findByRole('button', { name: 'Remove Nous Portal' }) + fireEvent.click(remove) + + await waitFor(() => expect(disconnectOAuthProvider).toHaveBeenCalledWith('nous')) + expect(listOAuthProviders).toHaveBeenCalledTimes(2) + }) + + it('keeps provider selection separate from account removal', async () => { + await renderProvidersSettings() + + fireEvent.click(await screen.findByText('Nous Portal')) + + expect(startManualProviderOAuth).toHaveBeenCalledWith('nous') + expect(disconnectOAuthProvider).not.toHaveBeenCalled() + }) + + it('does not offer removal for externally managed providers', async () => { + listOAuthProviders.mockResolvedValue({ + providers: [ + provider('qwen-oauth', true, { + cli_command: 'hermes auth add qwen-oauth', + disconnect_hint: 'Use `hermes auth add qwen-oauth` or that provider\'s CLI to remove it.', + disconnectable: false, + flow: 'external', + name: 'Qwen (via Qwen CLI)' + }) + ] + }) + + await renderProvidersSettings() + + expect(await screen.findByText('Qwen Code')).toBeTruthy() + expect(screen.queryByRole('button', { name: 'Remove Qwen Code' })).toBeNull() + expect(screen.getByText(/managed outside Hermes/)).toBeTruthy() + }) +}) diff --git a/apps/desktop/src/app/settings/providers-settings.tsx b/apps/desktop/src/app/settings/providers-settings.tsx index 7f803eff321..f1132e6c33d 100644 --- a/apps/desktop/src/app/settings/providers-settings.tsx +++ b/apps/desktop/src/app/settings/providers-settings.tsx @@ -1,18 +1,20 @@ import { useStore } from '@nanostores/react' -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { FEATURED_ID, FeaturedProviderRow, KeyProviderRow, ProviderRow, + providerTitle, sortProviders } from '@/components/desktop-onboarding-overlay' import { Button } from '@/components/ui/button' -import { listOAuthProviders } from '@/hermes' +import { disconnectOAuthProvider, listOAuthProviders } from '@/hermes' import { useI18n } from '@/i18n' -import { ChevronDown, KeyRound } from '@/lib/icons' +import { Check, ChevronDown, ChevronRight, KeyRound, Loader2, Terminal, Trash2 } from '@/lib/icons' import { cn } from '@/lib/utils' +import { notify, notifyError } from '@/store/notifications' import { $desktopOnboarding, startManualProviderOAuth } from '@/store/onboarding' import type { EnvVarInfo, OAuthProvider } from '@/types/hermes' @@ -85,7 +87,17 @@ function buildProviderKeyGroups(vars: Record): ProviderKeyGr // Selecting a provider hands off to the shared onboarding overlay, which runs // that provider's real sign-in flow; the key affordances open the API-key // catalog below. -function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; providers: OAuthProvider[] }) { +function OAuthPicker({ + disconnecting, + onDisconnect, + onWantApiKey, + providers +}: { + disconnecting: null | string + onDisconnect: (provider: OAuthProvider) => void + onWantApiKey: () => void + providers: OAuthProvider[] +}) { const { t } = useI18n() const p = t.settings.providers const [showAll, setShowAll] = useState(false) @@ -97,7 +109,7 @@ function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; pr const select = (p: OAuthProvider) => startManualProviderOAuth(p.id) - const featured = ordered.find(p => p.id === FEATURED_ID) ?? null + const featured = ordered.find(p => p.id === FEATURED_ID && !p.status?.logged_in) ?? null const rest = featured ? ordered.filter(p => p.id !== FEATURED_ID) : ordered // Keep connected accounts grouped and always visible; only the unconnected // providers hide behind the disclosure, so the page leads with what's set up. @@ -130,7 +142,13 @@ function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; pr {p.connected}

{connected.map(p => ( - + ))} )} @@ -158,6 +176,63 @@ function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; pr ) } +function ConnectedProviderRow({ + disconnecting, + onDisconnect, + onSelect, + provider +}: { + disconnecting: boolean + onDisconnect: (provider: OAuthProvider) => void + onSelect: (provider: OAuthProvider) => void + provider: OAuthProvider +}) { + const { t } = useI18n() + const title = providerTitle(provider) + const Trail = provider.flow === 'external' ? Terminal : ChevronRight + const canDisconnect = provider.disconnectable ?? provider.flow !== 'external' + + const disconnectHint = provider.flow === 'external' + ? t.settings.providers.removeExternal(title, provider.cli_command) + : t.settings.providers.removeKeyManaged(title) + + return ( +
+ +
+ + {canDisconnect && ( + + )} +
+
+ ) +} + function NoProviderKeys() { const { t } = useI18n() @@ -173,20 +248,26 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps const { rowProps, vars } = useEnvCredentials() const [oauthProviders, setOauthProviders] = useState([]) const [openProvider, setOpenProvider] = useState(null) + const [disconnecting, setDisconnecting] = useState(null) // The onboarding overlay owns the OAuth flow. Watch its `manual` flag so we // re-read connection state when the user finishes (or dismisses) a sign-in // they launched from this page — otherwise the cards keep their stale status. const onboardingActive = useStore($desktopOnboarding).manual - useEffect(() => { - if (onboardingActive) { - return - } + const refreshOAuthProviders = useCallback(async () => { + // OAuth providers are best-effort — a failure here just hides the panel. + const { providers } = await listOAuthProviders() + setOauthProviders(providers) + }, []) + useEffect(() => { let cancelled = false - // OAuth providers are best-effort — a failure here just hides the panel. void (async () => { + if (onboardingActive) { + return + } + try { const { providers } = await listOAuthProviders() @@ -201,6 +282,26 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps return () => void (cancelled = true) }, [onboardingActive]) + async function handleDisconnect(provider: OAuthProvider) { + const name = providerTitle(provider) + + if (!window.confirm(t.settings.providers.removeConfirm(name))) { + return + } + + setDisconnecting(provider.id) + + try { + await disconnectOAuthProvider(provider.id) + notify({ durationMs: 3_000, kind: 'success', title: t.settings.providers.removedTitle, message: t.settings.providers.removedMessage(name) }) + await refreshOAuthProviders().catch(() => undefined) + } catch (err) { + notifyError(err, t.settings.providers.failedRemove(name)) + } finally { + setDisconnecting(null) + } + } + if (!vars) { return } @@ -237,7 +338,12 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps return ( - onViewChange('keys')} providers={oauthProviders} /> + void handleDisconnect(provider)} + onWantApiKey={() => onViewChange('keys')} + providers={oauthProviders} + /> ) } diff --git a/apps/desktop/src/app/settings/types.ts b/apps/desktop/src/app/settings/types.ts index 33c88e761c1..fba38a23c19 100644 --- a/apps/desktop/src/app/settings/types.ts +++ b/apps/desktop/src/app/settings/types.ts @@ -4,7 +4,15 @@ import type { HermesGateway } from '@/hermes' import type { IconComponent } from '@/lib/icons' import type { EnvVarInfo } from '@/types/hermes' -export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'providers' | 'sessions' | `config:${string}` +export type SettingsView = + | 'about' + | 'gateway' + | 'keys' + | 'mcp' + | 'notifications' + | 'providers' + | 'sessions' + | `config:${string}` export type EnvPatch = Partial> export interface SettingsPageProps { diff --git a/apps/desktop/src/app/shell/keybind-panel.tsx b/apps/desktop/src/app/shell/keybind-panel.tsx index 81d292862ac..ff0b7b27ff1 100644 --- a/apps/desktop/src/app/shell/keybind-panel.tsx +++ b/apps/desktop/src/app/shell/keybind-panel.tsx @@ -5,6 +5,7 @@ import { useState } from 'react' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { DisclosureCaret } from '@/components/ui/disclosure-caret' +import { Kbd, KbdCombo } from '@/components/ui/kbd' import { useI18n } from '@/i18n' import { KEYBIND_ACTIONS, @@ -166,15 +167,11 @@ function KeybindRow({ action }: { action: KeybindActionMeta }) { type="button" > {capturing ? ( - {k.pressKey} + {k.pressKey} ) : combos.length > 0 ? ( - combos.map(combo => ( - - {formatCombo(combo)} - - )) + combos.map(combo => ) ) : ( - {k.set} + {k.set} )} @@ -209,9 +206,7 @@ function ReadonlyRow({ shortcut }: { shortcut: KeybindReadonly }) { {label}
{shortcut.keys.map(key => ( - - {formatCombo(key)} - + ))}
diff --git a/apps/desktop/src/app/shell/titlebar.ts b/apps/desktop/src/app/shell/titlebar.ts index 1e56a5f9c48..20fbba17367 100644 --- a/apps/desktop/src/app/shell/titlebar.ts +++ b/apps/desktop/src/app/shell/titlebar.ts @@ -19,7 +19,10 @@ export const titlebarButtonClass = 'text-muted-foreground/85 hover:bg-(--ui-control-hover-background) hover:text-foreground' export const titlebarHeaderBaseClass = - 'pointer-events-none relative z-3 flex h-(--titlebar-height) shrink-0 items-center justify-start gap-3 border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-[max(0.75rem,var(--titlebar-content-inset,0rem))]' + 'pointer-events-none relative z-3 flex h-(--titlebar-height) w-full min-w-0 shrink-0 items-center justify-start gap-3 overflow-hidden border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-[max(0.75rem,var(--titlebar-content-inset,0rem))] pr-[calc(var(--titlebar-tools-right,0.75rem)+var(--titlebar-tools-width,0px)+0.75rem)]' + +// Title row inside the header — must stay in the flex truncate chain. +export const titlebarHeaderTitleClass = 'min-w-0 flex-1 overflow-hidden' export const titlebarHeaderShadowClass = "after:pointer-events-none after:absolute after:left-0 after:right-0 after:top-full after:h-4 after:bg-linear-to-b after:from-(--ui-chat-surface-background) after:to-transparent after:content-['']" diff --git a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx index 7b8dd8d6a41..655ff389ec2 100644 --- a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx +++ b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx @@ -2,10 +2,11 @@ import { type ToolCallMessagePartProps } from '@assistant-ui/react' import { useStore } from '@nanostores/react' -import { type FormEvent, type KeyboardEvent, useCallback, useMemo, useRef, useState } from 'react' +import { type FormEvent, type KeyboardEvent, useCallback, useMemo, useRef, useState, type ComponentProps } from 'react' import { ToolFallback } from '@/components/assistant-ui/tool-fallback' import { Button } from '@/components/ui/button' +import { KbdCombo } from '@/components/ui/kbd' import { Textarea } from '@/components/ui/textarea' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' @@ -35,14 +36,30 @@ function readClarifyArgs(args: unknown): ClarifyArgs { } // Choice and "Other" rows share a layout; only color/hover differs. -const OPTION_ROW_CLASS = 'flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors' +const OPTION_ROW_CLASS = 'flex w-full items-start gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors' + +const CLARIFY_SHELL_CLASS = + 'relative mb-3 mt-2 rounded-[0.5rem] border border-border/70 bg-card/40 text-sm shadow-[inset_0_1px_0_color-mix(in_srgb,var(--foreground)_3%,transparent)]' + +function ClarifyShell({ + children, + className, + ...props +}: ComponentProps<'div'>) { + return ( +
+ + {children} +
+ ) +} function RadioDot({ selected }: { selected: boolean }) { return ( @@ -98,9 +115,11 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) { const textareaRef = useRef(null) // Race: tool.start fires a tick before clarify.request, so request_id - // arrives slightly after the tool block mounts. Show the question (from - // args) but disable submit until we have the request id from the gateway. + // arrives slightly after the tool block mounts. Hold the whole panel on a + // spinner until the gateway request is wired — showing disabled choices or + // a "loading question" stub is worse than a brief wait. const ready = Boolean(matchingRequest?.requestId) + const loading = !ready && !submitting const respond = useCallback( async (answer: string) => { @@ -137,7 +156,11 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) { const handleTextareaKey = useCallback( (event: KeyboardEvent) => { - if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { + if (event.nativeEvent.isComposing) { + return + } + + if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault() const trimmed = draft.trim() @@ -161,12 +184,20 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) { [draft, respond] ) + if (loading) { + return ( + + + + ) + } + return ( -
- +
- - {question || {copy.loadingQuestion}} - + {question}
{!typing && hasChoices && ( @@ -189,7 +218,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) { selectedChoice === choice && 'bg-accent/60' )} data-choice - disabled={!ready || submitting} + disabled={submitting} key={`${index}-${choice}`} onClick={() => { setSelectedChoice(choice) @@ -199,7 +228,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) { > {choice} - {selectedChoice === choice && } + {selectedChoice === choice && } ))} -
@@ -266,7 +293,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
)} -
+ ) } diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.tsx index 8ec734bf8b6..2c87f6d0c33 100644 --- a/apps/desktop/src/components/assistant-ui/markdown-text.tsx +++ b/apps/desktop/src/components/assistant-ui/markdown-text.tsx @@ -2,12 +2,22 @@ import { TextMessagePartProvider, useMessagePartText } from '@assistant-ui/react' import { + parseMarkdownIntoBlocks, type StreamdownTextComponents, StreamdownTextPrimitive, type SyntaxHighlighterProps } from '@assistant-ui/react-streamdown' import { code } from '@streamdown/code' -import { type ComponentProps, memo, type ReactNode, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' +import { + type ComponentProps, + memo, + type ReactNode, + useDeferredValue, + useEffect, + useMemo, + useRef, + useState +} from 'react' import { PreviewAttachment } from '@/components/chat/preview-attachment' import { SyntaxHighlighter } from '@/components/chat/shiki-highlighter' @@ -26,6 +36,7 @@ import { mediaStreamUrl } from '@/lib/media' import { previewTargetFromMarkdownHref } from '@/lib/preview-targets' +import { tailBoundedRemend } from '@/lib/remend-tail' import { cn } from '@/lib/utils' // Math rendering plugin (KaTeX). Configured once at module scope — the @@ -42,6 +53,51 @@ import { cn } from '@/lib/utils' // LLM convention). The default false-setting only accepts `$$...$$`. const mathPlugin = createMemoizedMathPlugin({ singleDollarTextMath: true }) +// Replaces Streamdown's `parseIncompleteMarkdown` (full-text remend per +// flush) with a tail-bounded repair — see lib/remend-tail.ts. Must stay +// module-scope so the prop identity is stable across renders. +function preprocessWithTailRepair(text: string): string { + return tailBoundedRemend(preprocessMarkdown(text)) +} + +// Memoized block splitter. Streamdown calls `parseMarkdownIntoBlocks` (a full +// `marked` lex of the entire message, ~1.6ms per 28KB) inside a useMemo keyed +// on the text — but the same text is re-lexed every time a message REMOUNTS +// (virtualizer scroll, session switch) and whenever multiple surfaces render +// the same content (deferred + smooth reveal republish). A small module-level +// LRU keyed by the exact source string removes all of those repeat parses +// with zero correctness risk (same input → same output). Streaming tail +// growth misses the cache by design (every flush is a new string) — that +// single lex is the irreducible cost. +const BLOCK_CACHE_MAX = 64 +const BLOCK_CACHE_MIN_LENGTH = 1024 +const blockCache = new Map() + +function parseMarkdownIntoBlocksCached(markdown: string): string[] { + if (markdown.length < BLOCK_CACHE_MIN_LENGTH) { + return parseMarkdownIntoBlocks(markdown) + } + + const hit = blockCache.get(markdown) + + if (hit) { + // Refresh recency (Map iteration order is insertion order). + blockCache.delete(markdown) + blockCache.set(markdown, hit) + + return hit + } + + const blocks = parseMarkdownIntoBlocks(markdown) + blockCache.set(markdown, blocks) + + if (blockCache.size > BLOCK_CACHE_MAX) { + blockCache.delete(blockCache.keys().next().value as string) + } + + return blocks +} + async function mediaSrc(path: string): Promise { if (/^(?:https?|data):/i.test(path)) { return path @@ -241,6 +297,13 @@ function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>) // keeps draining its tail instead of snapping. const REVEAL_DRAIN_MS = 500 const REVEAL_MAX_CHARS_PER_FRAME = 30 +// Floor between reveal commits. Each commit republishes the text context and +// re-runs the whole Streamdown pipeline (preprocess → remend → lex → micromark +// on the open block) over the full accumulated text — at raw rAF cadence +// that's 60 full parses/second and was the dominant streaming cost for +// reasoning text. ~33ms keeps the reveal visually fluid (2 frames) while +// halving the parse work. +const REVEAL_MIN_COMMIT_MS = 33 function useSmoothReveal(text: string, isRunning: boolean): string { const [displayed, setDisplayed] = useState(isRunning ? '' : text) @@ -273,10 +336,27 @@ function useSmoothReveal(text: string, isRunning: boolean): string { const tick = () => { const now = performance.now() const dt = now - lastTickRef.current + + // Skip this frame if the floor hasn't elapsed — the backlog math below + // is dt-proportional, so delayed commits reveal proportionally more. + if (dt < REVEAL_MIN_COMMIT_MS) { + frameRef.current = requestAnimationFrame(tick) + + return + } + lastTickRef.current = now const remaining = targetRef.current.length - shownRef.current.length - const add = Math.min(remaining, REVEAL_MAX_CHARS_PER_FRAME, Math.max(1, Math.ceil((remaining * dt) / REVEAL_DRAIN_MS))) + + const add = Math.min( + remaining, + // dt-scaled so the per-commit cap stays equivalent to the old + // per-frame cap at any commit cadence. + Math.ceil((REVEAL_MAX_CHARS_PER_FRAME * dt) / 16.7), + Math.max(1, Math.ceil((remaining * dt) / REVEAL_DRAIN_MS)) + ) + shownRef.current = targetRef.current.slice(0, shownRef.current.length + add) setDisplayed(shownRef.current) @@ -460,17 +540,20 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex containerProps={containerProps} lineNumbers={false} mode="streaming" - // Always auto-close incomplete fences — even during streaming. - // Without this, an unclosed ```python ... ``` whose body contains - // `$` (very common: shell snippets, JS template strings, dollar - // amounts) leaks those dollars out to the math parser and they - // get rendered as broken inline math until the closing fence - // arrives. Shiki is independently deferred via `defer={isStreaming}` - // on the SyntaxHighlighter component, so we don't pay code-block - // tokenization on every token even with this set. - parseIncompleteMarkdown + // Incomplete-markdown repair is handled by `preprocessWithTailRepair` + // below (tail-bounded remend) instead of Streamdown's built-in pass, + // which re-runs remend over the ENTIRE message on every flush — ~18% + // of streaming script time on 50KB+ messages. The repair itself stays + // always-on (even between flushes / for completed messages): an + // unclosed ```python ... ``` whose body contains `$` (shell snippets, + // JS template strings, dollar amounts) would otherwise leak those + // dollars to the math parser and render broken inline math. Shiki is + // independently deferred via `defer={isStreaming}` on the + // SyntaxHighlighter component. + parseIncompleteMarkdown={false} + parseMarkdownIntoBlocksFn={parseMarkdownIntoBlocksCached} plugins={plugins} - preprocess={preprocessMarkdown} + preprocess={preprocessWithTailRepair} /> ) } diff --git a/apps/desktop/src/components/assistant-ui/streaming.test.tsx b/apps/desktop/src/components/assistant-ui/streaming.test.tsx index 08dba733ae1..a1383172655 100644 --- a/apps/desktop/src/components/assistant-ui/streaming.test.tsx +++ b/apps/desktop/src/components/assistant-ui/streaming.test.tsx @@ -58,9 +58,9 @@ Element.prototype.animate = function animate() { } as unknown as Animation } -// jsdom returns 0 for offset*; the virtualizer reads those to size its +// jsdom returns 0 for offset*; some layout code reads those to size the // viewport. Fall through to client* (which tests can override) or a sane -// default so virtualized items render. +// default so message rows render with non-zero dimensions. function stubOffsetDimension( prop: 'offsetHeight' | 'offsetWidth', clientProp: 'clientHeight' | 'clientWidth', @@ -216,25 +216,21 @@ function assistantTodoMessage( } as ThreadMessage } -function assistantReasoningTodoMessage( - todos: Array<{ content: string; id: string; status: 'cancelled' | 'completed' | 'in_progress' | 'pending' }> -): ThreadMessage { +function assistantImageMessage(running = false): ThreadMessage { return { - id: 'assistant-reasoning-todo-1', + id: `assistant-image-${running ? 'running' : 'done'}`, role: 'assistant', content: [ - { type: 'reasoning', text: 'Let me make a quick todo list.' }, { type: 'tool-call', - toolCallId: 'todo-1', - toolName: 'todo', - args: { todos }, - argsText: JSON.stringify({ todos }), - result: { todos } - }, - { type: 'text', text: 'Done — fake list created.' } + toolCallId: 'image-1', + toolName: 'image_generate', + args: { prompt: 'draw a cat' }, + argsText: JSON.stringify({ prompt: 'draw a cat' }), + ...(running ? {} : { result: { image: 'https://cdn.example/cat.png', success: true } }) + } ], - status: { type: 'complete', reason: 'stop' }, + status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' }, createdAt, metadata: { unstable_state: null, @@ -284,20 +280,6 @@ function StreamingHarness() { ) } -function StaticThreadHarness() { - const runtime = useExternalStoreRuntime({ - messages: [userMessage(), assistantMessage('complete response', false)], - isRunning: false, - onNew: async () => {} - }) - - return ( - - - - ) -} - function TodoHarness({ message }: { message: ThreadMessage }) { const runtime = useExternalStoreRuntime({ messages: [message], @@ -439,222 +421,11 @@ describe('assistant-ui streaming renderer', () => { expect(screen.getByRole('alert').textContent).toContain('OpenRouter rejected the request (403).') }) - it('does not pull the viewport back down after the user scrolls up during streaming', async () => { - const { container } = render() - - const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement - const viewport = content.parentElement as HTMLDivElement - let scrollHeight = 1_000 - - Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 }) - Object.defineProperty(viewport, 'scrollHeight', { - configurable: true, - get: () => scrollHeight - }) - - await wait(80) - - await act(async () => { - viewport.scrollTop = 800 - fireEvent.scroll(viewport) - }) - await wait(0) - - await act(async () => { - fireEvent.wheel(viewport, { deltaY: -120 }) - viewport.scrollTop = 420 - fireEvent.scroll(viewport) - }) - - scrollHeight = 1_200 - - await act(async () => { - for (const observer of resizeObservers) { - observer.trigger(1_200) - } - }) - await wait(0) - - expect(viewport.scrollTop).toBe(420) - }) - - it('does not auto-follow idle layout shifts', async () => { - const { container } = render() - - const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement - const viewport = content.parentElement as HTMLDivElement - let scrollHeight = 1_000 - - Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 }) - Object.defineProperty(viewport, 'scrollHeight', { - configurable: true, - get: () => scrollHeight - }) - - await wait(80) - - await act(async () => { - viewport.scrollTop = 420 - fireEvent.scroll(viewport) - }) - - scrollHeight = 1_200 - - await act(async () => { - for (const observer of resizeObservers) { - observer.trigger(1_200) - } - }) - await wait(0) - - expect(viewport.scrollTop).toBe(420) - }) - - it('does not follow streaming content growth even while parked at the bottom', async () => { - const { container } = render() - - const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement - const viewport = content.parentElement as HTMLDivElement - let clientHeight = 200 - let scrollHeight = 1_000 - - Object.defineProperty(viewport, 'clientHeight', { - configurable: true, - get: () => clientHeight - }) - Object.defineProperty(viewport, 'scrollHeight', { - configurable: true, - get: () => scrollHeight - }) - - await wait(80) - - // Park the user at the bottom of the current content. - await act(async () => { - viewport.scrollTop = 800 - fireEvent.scroll(viewport) - }) - - clientHeight = 240 - - await act(async () => { - viewport.scrollTop = 760 - fireEvent.scroll(viewport) - }) - - // Content grows as tokens stream in. Streaming auto-follow is removed, so - // the viewport must NOT chase the new bottom — it stays where the user - // last left it. - scrollHeight = 1_200 - - await act(async () => { - for (const observer of resizeObservers) { - observer.trigger(1_200) - } - }) - await wait(0) - - expect(viewport.scrollTop).toBe(760) - }) - - it('honors the first upward wheel scroll even when a programmatic bottom-pin scroll event is still pending', async () => { - const { container } = render() - - const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement - const viewport = content.parentElement as HTMLDivElement - let scrollHeight = 1_000 - - Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 }) - Object.defineProperty(viewport, 'scrollHeight', { - configurable: true, - get: () => scrollHeight - }) - - await wait(80) - await wait(0) - - await act(async () => { - fireEvent.wheel(viewport, { deltaY: -120 }) - viewport.scrollTop = 420 - fireEvent.scroll(viewport) - }) - - scrollHeight = 1_200 - - await act(async () => { - for (const observer of resizeObservers) { - observer.trigger(1_200) - } - }) - await wait(0) - - expect(viewport.scrollTop).toBe(420) - }) - - it('does not snap to the bottom on final code-highlight growth after a run completes', async () => { - const { container } = render() - - const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement - const viewport = content.parentElement as HTMLDivElement - let scrollHeight = 1_000 - - Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 }) - Object.defineProperty(viewport, 'scrollHeight', { - configurable: true, - get: () => scrollHeight - }) - - await wait(80) - - await act(async () => { - viewport.scrollTop = 800 - fireEvent.scroll(viewport) - }) - - await wait(650) - - // Completion re-measures (Shiki highlight) and grows the content. The - // post-run bottom lock is removed, so the viewport stays put instead of - // snapping to the new bottom. - scrollHeight = 1_700 - await wait(0) - - expect(viewport.scrollTop).toBe(800) - }) - - it('does not restart bottom-follow after completion when the user scrolled up', async () => { - const { container } = render() - - const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement - const viewport = content.parentElement as HTMLDivElement - let scrollHeight = 1_000 - - Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 }) - Object.defineProperty(viewport, 'scrollHeight', { - configurable: true, - get: () => scrollHeight - }) - - await wait(80) - - await act(async () => { - viewport.scrollTop = 800 - fireEvent.scroll(viewport) - }) - - await act(async () => { - fireEvent.wheel(viewport, { deltaY: -120 }) - viewport.scrollTop = 420 - fireEvent.scroll(viewport) - }) - - await wait(650) - - scrollHeight = 1_700 - await wait(0) - - expect(viewport.scrollTop).toBe(420) - }) + // Scroll behavior (follow-at-bottom, escape-on-scroll-up, re-engage) is owned + // by the use-stick-to-bottom library and covered by its own test suite. We + // don't re-assert its scrollTop mechanics here — doing so in jsdom (no real + // layout, spring animation via rAF) only produces brittle change-detector + // tests. The rendering/streaming-content tests below remain the contract. it('renders an incomplete streaming fenced code block as a code card', async () => { const { container } = render() @@ -670,14 +441,19 @@ describe('assistant-ui streaming renderer', () => { it('renders an incomplete streaming reasoning fenced code block as a code card', async () => { const { container } = render() const ui = within(container) + const thinkingToggle = ui.getByRole('button', { name: /thinking/i }) - fireEvent.click(ui.getByRole('button', { name: /thinking/i })) + if (thinkingToggle.getAttribute('aria-expanded') !== 'true') { + fireEvent.click(thinkingToggle) + } await waitFor(() => { expect(container.querySelector('[data-slot="code-card"]')).toBeTruthy() }) - expect(container.querySelector('[data-slot="aui_reasoning-text"]')?.textContent).toContain('const answer = 42') + await waitFor(() => { + expect(container.querySelector('[data-slot="aui_reasoning-text"]')?.textContent).toContain('const answer = 42') + }) expect(container.textContent).not.toContain('```ts') }) @@ -718,7 +494,7 @@ describe('assistant-ui streaming renderer', () => { expect(container.textContent).toContain('Interim answer.') }) - it('renders live todo rows during a running turn', () => { + it('does not render an inline todo panel — todos live in the composer status stack', () => { const { container } = render( { /> ) - const ui = within(container) - - expect(container.querySelector('[data-slot="aui_todo-hoisted"]')).toBeTruthy() - expect(ui.getAllByText('Boil water').length).toBeGreaterThan(0) - expect(ui.getByText('Gather ingredients')).toBeTruthy() - expect(ui.queryByText(/pending/i)).toBeNull() - expect(ui.queryByRole('button', { name: /todo/i })).toBeNull() + expect(container.querySelector('[data-slot="aui_todo-hoisted"]')).toBeNull() }) - it('renders archived todos after turn completion regardless of pending state', () => { - const first = render( - - ) + it('renders completed image generation results in the tool slot', async () => { + const { container } = render() - const ui = within(first.container) - - expect(ui.getAllByText('Boil water').length).toBeGreaterThan(0) - - first.unmount() - - const second = render( - - ) - - const archivedUi = within(second.container) - - expect(archivedUi.getAllByText('Serve latte').length).toBeGreaterThan(0) - }) - - it('hoists todo outside the thinking disclosure when reasoning is present', () => { - const { container } = render( - - ) - - const todoPanel = container.querySelector('[data-slot="aui_todo-hoisted"]') - const thinkingDisclosure = container.querySelector('[data-slot="aui_thinking-disclosure"]') - - expect(todoPanel).toBeTruthy() - expect(thinkingDisclosure).toBeTruthy() - expect(Boolean(thinkingDisclosure?.contains(todoPanel as Node))).toBe(false) + await waitFor(() => { + expect(screen.getByRole('img', { name: 'Generated image' }).getAttribute('src')).toBe( + 'https://cdn.example/cat.png' + ) + }) + expect(container.querySelector('[data-slot="aui_generated-image"]')).toBeTruthy() + expect(screen.queryByRole('status', { name: /rendering image/i })).toBeNull() }) }) diff --git a/apps/desktop/src/components/assistant-ui/thread-list.tsx b/apps/desktop/src/components/assistant-ui/thread-list.tsx new file mode 100644 index 00000000000..397ed2aa9bb --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread-list.tsx @@ -0,0 +1,307 @@ +import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react' +import { + type ComponentProps, + type FC, + memo, + type ReactNode, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState +} from 'react' +import { useStickToBottom } from 'use-stick-to-bottom' + +import { useI18n } from '@/i18n' +import { cn } from '@/lib/utils' +import { + onScrollToBottomRequest, + onThreadEditClose, + onThreadEditOpen, + resetThreadScroll, + setThreadAtBottom +} from '@/store/thread-scroll' + +import { MessageRenderBoundary } from './message-render-boundary' + +type ThreadMessageComponents = ComponentProps['components'] + +type MessageGroup = { id: string; weight: number } & ( + | { index: number; kind: 'standalone' } + | { indices: number[]; kind: 'turn' } +) + +// DOM is bounded by a rendered-PART budget, not a message/turn count: a single +// assistant message folds every tool call into a part, so heavy sessions are +// ~40 turns / ~100 messages but ~1000 parts — and parts are what drive node +// count. "Show earlier" prepends another page; whole turns stay intact so the +// sticky human bubble never loses its turn. This is the long-session perf lever +// WITHOUT a virtualizer — pure rendering, never touches scrollTop, so it can't +// fight use-stick-to-bottom (the single scroll owner). +const RENDER_BUDGET = 300 + +interface ThreadMessageListProps { + clampToComposer: boolean + components: ThreadMessageComponents + emptyPlaceholder?: ReactNode + loadingIndicator?: ReactNode + sessionKey?: string | null +} + +// Group each user message with the assistant turn(s) that follow it so the +// human bubble can `position: sticky` against the scroller across its whole +// turn (see StickyHumanMessageContainer in thread.tsx). +function buildGroups(signature: string): MessageGroup[] { + if (!signature) { + return [] + } + + const messages = signature.split('\n').map(row => { + const [index, id, role, weight] = row.split(':') + + return { id, index: Number(index), role, weight: Number(weight) || 1 } + }) + + const groups: MessageGroup[] = [] + + for (let i = 0; i < messages.length; i++) { + const message = messages[i] + + if (message.role !== 'user') { + groups.push({ id: message.id, index: message.index, kind: 'standalone', weight: message.weight }) + + continue + } + + const indices = [message.index] + let weight = message.weight + + while (i + 1 < messages.length && messages[i + 1].role !== 'user') { + weight += messages[++i].weight + indices.push(messages[i].index) + } + + groups.push({ id: message.id, indices, kind: 'turn', weight }) + } + + return groups +} + +const ThreadMessageListInner: FC = ({ + clampToComposer, + components, + emptyPlaceholder, + loadingIndicator, + sessionKey +}) => { + const messageSignature = useAuiState(s => + s.thread.messages + .map((message, index) => `${index}:${message.id}:${message.role}:${message.content?.length ?? 1}`) + .join('\n') + ) + + const { t } = useI18n() + const groups = buildGroups(messageSignature) + const renderEmpty = groups.length === 0 && Boolean(emptyPlaceholder) + + // use-stick-to-bottom owns scrollTop (single writer): follow while locked, + // escape on user scroll-up, re-lock at bottom. Snap instantly, not spring — a + // spring can't tell live-token growth from a session-switch bulk relayout, and + // chasing the latter reads as the view scrolling to random spots before + // settling. Its refs hang off our own DOM so the sticky human bubbles survive. + const { scrollRef, contentRef, isAtBottom, scrollToBottom, stopScroll } = useStickToBottom({ + initial: 'instant', + resize: 'instant' + }) + + const [renderBudget, setRenderBudget] = useState(RENDER_BUDGET) + + // Walk turns newest-first, summing their part weights until the budget is met; + // everything before that first kept turn is hidden. + let firstVisible = groups.length + + for (let i = groups.length - 1, weight = 0; i >= 0; i--) { + weight += groups[i].weight + firstVisible = i + + if (weight >= renderBudget) { + break + } + } + + const hiddenCount = firstVisible + const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups + const restoreFromBottomRef = useRef(null) + + useEffect(() => setThreadAtBottom(isAtBottom), [isAtBottom]) + useEffect(() => () => resetThreadScroll(), []) + + // Floating jump button (outside this subtree) → return to the bottom. + useEffect(() => onScrollToBottomRequest(() => void scrollToBottom()), [scrollToBottom]) + + const endEditHold = useCallback(() => { + scrollRef.current?.removeAttribute('data-editing') + }, [scrollRef]) + + // Inline edit grows a sticky bubble. Escape before focus/layout so the + // resize-follow can't snap scrollTop; native anchoring holds the viewport. + const beginEditHold = useCallback(() => { + const el = scrollRef.current + + if (!el) { + return + } + + endEditHold() + stopScroll() + el.setAttribute('data-editing', 'true') + }, [endEditHold, scrollRef, stopScroll]) + + useEffect(() => onThreadEditOpen(beginEditHold), [beginEditHold]) + useEffect(() => onThreadEditClose(endEditHold), [endEditHold]) + useEffect(() => () => endEditHold(), [endEditHold]) + // New run → snap to the latest turn. + useAuiEvent('thread.runStart', () => void scrollToBottom()) + + // Reset the cap and pin to bottom on mount + every session switch (messages + // swap in place on a long-lived runtime, so sessionKey is the only signal). + // The swap is multi-step and lays out over many frames; letting the library + // follow re-pins every frame to a moving target — visible as ~10 scroll jumps. + // Instead: quiet it, glue to the true bottom until the height holds steady, + // then hand back locked. Live streaming afterward uses the normal resize follow. + useLayoutEffect(() => { + setRenderBudget(RENDER_BUDGET) + + const el = scrollRef.current + + if (!el) { + return + } + + stopScroll() + el.scrollTop = el.scrollHeight + + let frame = 0 + let stableFrames = 0 + let lastHeight = el.scrollHeight + + const settle = () => { + const node = scrollRef.current + + if (!node) { + return + } + + const height = node.scrollHeight + + stableFrames = height === lastHeight ? stableFrames + 1 : 0 + lastHeight = height + node.scrollTop = height + + // ~5 steady frames ≈ layout has settled; the frame cap bounds slow loads. + if (stableFrames >= 5 || ++frame > 90) { + void scrollToBottom('instant') + + return + } + + rafId = requestAnimationFrame(settle) + } + + let rafId = requestAnimationFrame(settle) + + return () => cancelAnimationFrame(rafId) + }, [scrollRef, scrollToBottom, sessionKey, stopScroll]) + + // Prepend an older page while preserving the on-screen position. The user is + // scrolled up (reading history) so the stick-to-bottom lock is escaped and + // won't fight this manual restore. + const showEarlier = useCallback(() => { + const el = scrollRef.current + + restoreFromBottomRef.current = el ? el.scrollHeight - el.scrollTop : null + setRenderBudget(budget => budget + RENDER_BUDGET) + }, [scrollRef]) + + useLayoutEffect(() => { + const el = scrollRef.current + + if (el && restoreFromBottomRef.current != null) { + el.scrollTop = el.scrollHeight - restoreFromBottomRef.current + restoreFromBottomRef.current = null + } + }, [scrollRef, renderBudget]) + + return ( +
+
} + > + {renderEmpty ? ( +
+ {emptyPlaceholder} +
+ ) : ( +
} + > + {hiddenCount > 0 && ( + + )} + {visibleGroups.map(group => ( +
+ + {group.kind === 'turn' ? ( +
+ {group.indices.map(index => ( + + ))} +
+ ) : ( + + )} +
+
+ ))} + {loadingIndicator} + {clampToComposer && ( + + )} +
+
+ ) +} + +export const ThreadMessageList = memo(ThreadMessageListInner) diff --git a/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx b/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx deleted file mode 100644 index 0b3a5c824af..00000000000 --- a/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx +++ /dev/null @@ -1,469 +0,0 @@ -import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react' -import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual' -import { - type ComponentProps, - type FC, - memo, - type ReactNode, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef -} from 'react' - -import { setMutableRef } from '@/lib/mutable-ref' -import { cn } from '@/lib/utils' -import { setThreadScrolledUp } from '@/store/thread-scroll' - -import { MessageRenderBoundary } from './message-render-boundary' - -const ESTIMATED_ITEM_HEIGHT = 220 -const OVERSCAN = 4 -const AT_BOTTOM_THRESHOLD = 4 - -type ThreadMessageComponents = ComponentProps['components'] - -type MessageGroup = { id: string; index: number; kind: 'standalone' } | { id: string; indices: number[]; kind: 'turn' } - -interface VirtualizedThreadProps { - clampToComposer: boolean - components: ThreadMessageComponents - emptyPlaceholder?: ReactNode - loadingIndicator?: ReactNode - sessionKey?: string | null -} - -function buildGroups(signature: string): MessageGroup[] { - if (!signature) { - return [] - } - - const messages = signature.split('\n').map(row => { - const [index, id, role] = row.split(':') - - return { id, index: Number(index), role } - }) - - const groups: MessageGroup[] = [] - - for (let i = 0; i < messages.length; i++) { - const message = messages[i] - - if (message.role !== 'user') { - groups.push({ id: message.id, index: message.index, kind: 'standalone' }) - - continue - } - - const indices = [message.index] - - while (i + 1 < messages.length && messages[i + 1].role !== 'user') { - indices.push(messages[++i].index) - } - - groups.push({ id: message.id, indices, kind: 'turn' }) - } - - return groups -} - -const VirtualizedThreadInner: FC = ({ - clampToComposer, - components, - emptyPlaceholder, - loadingIndicator, - sessionKey -}) => { - const messageSignature = useAuiState(s => - s.thread.messages.map((message, index) => `${index}:${message.id}:${message.role}`).join('\n') - ) - - const isRunning = useAuiState(s => s.thread.isRunning) - - const groups = useMemo(() => buildGroups(messageSignature), [messageSignature]) - const renderEmpty = groups.length === 0 && Boolean(emptyPlaceholder) - const scrollerRef = useRef(null) - - // Shared ref so scrollToFn can check whether the user is parked at the - // bottom without needing a ref from inside useThreadScrollAnchor. - const stickyBottomRef = useRef(true) - - const virtualizer = useVirtualizer({ - count: groups.length, - estimateSize: () => ESTIMATED_ITEM_HEIGHT, - getItemKey: index => groups[index]?.id ?? index, - getScrollElement: () => scrollerRef.current, - // Seed the rect so the initial range mounts something before - // `observeElementRect` reports the real layout (it overrides this). - initialRect: { height: 600, width: 800 }, - overscan: OVERSCAN, - // When the virtualizer adjusts scroll due to item measurement changes, - // skip the adjustment if the user is at the bottom. Our ResizeObserver + - // pinToBottom loop handles scroll anchoring; letting the virtualizer also - // adjust creates a feedback loop where the two fight each other, - // producing visible rubber-banding (the view snaps to the composer - // then jumps back up). - scrollToFn: (offset, _options, instance) => { - const el = instance.scrollElement - - if (!el) { - return - } - - if (stickyBottomRef.current) { - const maxScroll = el.scrollHeight - el.clientHeight - const distFromBottom = maxScroll - el.scrollTop - - if (distFromBottom <= AT_BOTTOM_THRESHOLD && offset < maxScroll) { - return - } - } - - ;(el as HTMLElement).scrollTo(0, offset) - } - }) - - useThreadScrollAnchor({ - enabled: !renderEmpty, - groupCount: groups.length, - isRunning, - scrollerRef, - sessionKey: sessionKey ?? null, - stickyBottomRef, - virtualizer - }) - - const virtualItems = virtualizer.getVirtualItems() - const totalSize = virtualizer.getTotalSize() - const paddingTop = virtualItems[0]?.start ?? 0 - const paddingBottom = Math.max(0, totalSize - (virtualItems.at(-1)?.end ?? 0)) - - return ( -
-
- {renderEmpty ? ( -
- {emptyPlaceholder} -
- ) : ( -
- {/* Natural-flow virtualization: mounted items render as normal - flex siblings so `position: sticky` on the human bubble - resolves against the scroller without transform interference. - Padding spacers reserve scroll space for unmounted items. */} -
- {virtualItems.map(virtualItem => { - const group = groups[virtualItem.index] - - if (!group) { - return null - } - - return ( -
- - {group.kind === 'turn' ? ( -
- {group.indices.map(index => ( - - ))} -
- ) : ( - - )} -
-
- ) - })} -
- {loadingIndicator} - {clampToComposer && ( - - )} -
-
- ) -} - -export const VirtualizedThread = memo(VirtualizedThreadInner) - -function scrollElementToBottom(el: HTMLDivElement) { - el.scrollTop = el.scrollHeight -} - -interface ScrollAnchorOptions { - enabled: boolean - groupCount: number - isRunning: boolean - scrollerRef: React.RefObject - sessionKey: string | null - stickyBottomRef: React.MutableRefObject - virtualizer: Virtualizer -} - -function useThreadScrollAnchor({ - enabled, - groupCount, - isRunning, - scrollerRef, - sessionKey, - stickyBottomRef, - virtualizer -}: ScrollAnchorOptions) { - // `stickyBottomRef` = parked at bottom, content growth should follow. Cleared on - // user-driven upward scroll; re-armed when they reach bottom again. - // This is a shared ref — scrollToFn reads it to prevent the virtualizer's - // measurement adjustments from fighting our pinToBottom. - const lastTopRef = useRef(0) - const lastHeightRef = useRef(0) - const lastClientHeightRef = useRef(0) - // Counter that tracks how many scroll events we expect to be ours rather - // than the user's. `pinToBottom` writes `el.scrollTop`, which fires an - // async `scroll` event; without this guard the on-scroll handler can race - // with the programmatic write (because content also grew, the *resulting* - // scrollTop can be lower than `lastTopRef` from the previous frame) and - // misread the programmatic pin as the user scrolling up — which disarms - // sticky-bottom and the user's just-submitted message slides above the - // fold. See `apps/desktop/scripts/measure-jump.mjs` for the repro - // (distFromBottom 0 → 49 within one frame, sticking forever). - const programmaticScrollPendingRef = useRef(0) - const prevSessionKeyRef = useRef(sessionKey) - const prevGroupCountRef = useRef(0) - - const pinToBottom = useCallback(() => { - const el = scrollerRef.current - - if (!el) { - return - } - - // Already parked at the bottom: writing `scrollTop` is a no-op and the - // browser fires NO scroll event, so arming the programmatic gate here would - // leave it permanently set. Repeated pins (streaming heartbeats, the - // post-run lock loop) then accumulate the gate, and the next genuine user - // scroll-up is misread as one of our programmatic scrolls — re-arming - // sticky-bottom and yanking the viewport back down. Refresh trackers, bail. - const distFromBottom = el.scrollHeight - (el.scrollTop + el.clientHeight) - - if (distFromBottom <= AT_BOTTOM_THRESHOLD) { - lastTopRef.current = el.scrollTop - lastHeightRef.current = el.scrollHeight - lastClientHeightRef.current = el.clientHeight - - return - } - - // Hold the disarm gate across the scroll event the next line will fire. - // Set to 1 rather than incrementing: coalesced writes within a frame fire a - // single scroll event, so a counter > 1 can never drain and would swallow a - // later real user scroll. - programmaticScrollPendingRef.current = 1 - scrollElementToBottom(el) - lastTopRef.current = el.scrollTop - lastHeightRef.current = el.scrollHeight - lastClientHeightRef.current = el.clientHeight - }, [scrollerRef]) - - const jumpToBottom = useCallback(() => { - setMutableRef(stickyBottomRef, true) - - if (groupCount > 0) { - virtualizer.scrollToIndex(groupCount - 1, { align: 'end', behavior: 'auto' }) - } - - requestAnimationFrame(() => { - if (stickyBottomRef.current) { - pinToBottom() - } - }) - }, [groupCount, pinToBottom, stickyBottomRef, virtualizer]) - - useEffect(() => () => setThreadScrolledUp(false), []) - - // Track at-bottom state, dim composer when scrolled up, disarm on user - // scroll/wheel/touch. - useEffect(() => { - const el = scrollerRef.current - - if (!el) { - return undefined - } - - const disarm = () => { - setMutableRef(stickyBottomRef, false) - programmaticScrollPendingRef.current = 0 - } - - const onScroll = () => { - const top = el.scrollTop - - // If this scroll event is the consequence of `pinToBottom` writing - // `el.scrollTop`, treat it as ours: don't disarm. The RO + rAF pin - // loop will re-pin on the next frame if the browser clamped us - // short of bottom (because content grew in the same frame). - // Without this guard the post-pin scrollTop gets misread as the - // user scrolling up, disarming sticky-bottom permanently and - // leaving the just-submitted message below the fold. - if (programmaticScrollPendingRef.current > 0) { - programmaticScrollPendingRef.current -= 1 - lastTopRef.current = top - lastHeightRef.current = el.scrollHeight - lastClientHeightRef.current = el.clientHeight - // Always re-arm — sticky-bottom should hold through clamp races. - setMutableRef(stickyBottomRef, true) - const atBottom = el.scrollHeight - (top + el.clientHeight) <= AT_BOTTOM_THRESHOLD - setThreadScrolledUp(!atBottom) - - return - } - - // Disarm only when `scrollTop` decreases while both content height and - // viewport height are stable. A bare `top < lastTopRef.current` check is - // unsafe: virtualizer measurement, streaming markdown, composer resizing, - // window resizing, and toolbar/status updates can all move scrollTop as a - // layout side effect. Wheel-up and touchmove still disarm immediately via - // their own listeners below, so real user intent remains covered. - const heightGrew = el.scrollHeight > lastHeightRef.current - const clientHeightChanged = Math.abs(el.clientHeight - lastClientHeightRef.current) > 1 - - if (!heightGrew && !clientHeightChanged && top + 1 < lastTopRef.current) { - setMutableRef(stickyBottomRef, false) - } - - lastTopRef.current = top - lastHeightRef.current = el.scrollHeight - lastClientHeightRef.current = el.clientHeight - - const atBottom = el.scrollHeight - (top + el.clientHeight) <= AT_BOTTOM_THRESHOLD - - if (atBottom) { - setMutableRef(stickyBottomRef, true) - } - - setThreadScrolledUp(!atBottom) - } - - const onWheel = (event: WheelEvent) => { - if (event.deltaY < 0) { - disarm() - } - } - - el.addEventListener('scroll', onScroll, { passive: true }) - el.addEventListener('wheel', onWheel, { passive: true }) - el.addEventListener('touchmove', disarm, { passive: true }) - - return () => { - el.removeEventListener('scroll', onScroll) - el.removeEventListener('wheel', onWheel) - el.removeEventListener('touchmove', disarm) - } - }, [scrollerRef, stickyBottomRef]) - - // Intentionally NO streaming auto-follow. Earlier builds ran a - // ResizeObserver here that re-pinned the viewport to the bottom on every - // content growth while a turn was running, so the chat tracked tokens as - // they streamed. That behavior is removed by request: once a turn is in - // flight the viewport stays exactly where the user left it. The viewport - // is still moved to the bottom ONCE per user submit / new turn / session - // change (see the layout effect and the session-change effect below) so a - // freshly submitted message lands in view — but it does not chase the - // stream afterward. - - // Jump to bottom on session change OR when an empty thread first gets - // content. Both share the same intent and the same effect. - useEffect(() => { - const sessionChanged = prevSessionKeyRef.current !== sessionKey - const becameNonEmpty = prevGroupCountRef.current === 0 && groupCount > 0 - - prevSessionKeyRef.current = sessionKey - prevGroupCountRef.current = groupCount - - if (enabled && (sessionChanged || becameNonEmpty)) { - jumpToBottom() - } - }, [enabled, groupCount, jumpToBottom, sessionKey]) - - // Pre-paint pin: when groupCount increases while armed (a new turn arriving - // from the user submit or assistant turn start), pin BEFORE the browser - // commits the layout to screen. Using useLayoutEffect rather than useEffect - // so this runs synchronously after React commits the DOM mutation but before - // the browser paints. Without this, there's a ~50ms visual window where the - // new message sits below the fold. - // - // We pin TWICE in this critical path — once synchronously, then once on - // the next rAF. The second pin catches the case where React mounts the - // new message in the second commit (after our layout effect ran), which - // grows scrollHeight again; without the rAF pin the user briefly sees a - // ~15 px gap below the new message. This fires once per user submit / new - // turn arrival — it is NOT streaming-token follow (that path is removed - // above), so a turn that streams a long response after this initial jump - // will not chase the bottom. - const prevGroupCountForLayoutRef = useRef(groupCount) - useLayoutEffect(() => { - if (!enabled) { - return - } - - if (groupCount > prevGroupCountForLayoutRef.current && stickyBottomRef.current) { - // Defer to rAF so that browser scroll/wheel events from the current - // frame are processed first. Without this deferral, a trackpad - // scroll-up during streaming can race with this effect: the wheel - // event hasn't fired yet so stickyBottomRef is still true, and the - // immediate pinToBottom() would snap the viewport back to bottom - // against the user's intent. - requestAnimationFrame(() => { - if (stickyBottomRef.current) { - pinToBottom() - } - }) - } - - prevGroupCountForLayoutRef.current = groupCount - }, [enabled, groupCount, pinToBottom, stickyBottomRef]) - - // Intentionally NO post-run bottom lock. Earlier builds kept pinning to - // the bottom for POST_RUN_BOTTOM_LOCK_MS after `isRunning` flipped false to - // chase final Shiki re-highlight measurement. With streaming follow gone, - // re-pinning at completion would yank the viewport back to the bottom even - // though the user is reading earlier content — the opposite of what's - // wanted. The one-time submit / new-turn jump already covers landing a - // fresh message in view. - const prevIsRunningForLayoutRef = useRef(isRunning) - useLayoutEffect(() => { - prevIsRunningForLayoutRef.current = isRunning - }, [isRunning]) - - useAuiEvent('thread.runStart', jumpToBottom) -} diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx index 375ba5d8c48..80e9c33ec2e 100644 --- a/apps/desktop/src/components/assistant-ui/thread.tsx +++ b/apps/desktop/src/components/assistant-ui/thread.tsx @@ -7,7 +7,8 @@ import { MessagePrimitive, type ToolCallMessagePartProps, useAui, - useAuiState + useAuiState, + useMessageRuntime } from '@assistant-ui/react' import { useStore } from '@nanostores/react' import { IconPlayerStopFilled } from '@tabler/icons-react' @@ -52,24 +53,28 @@ import { } from '@/app/chat/composer/rich-editor' import { detectTrigger, textBeforeCaret, type TriggerState } from '@/app/chat/composer/text-utils' import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover' -import { extractDroppedFiles, HERMES_PATHS_MIME, isImagePath, partitionDroppedFiles } from '@/app/chat/hooks/use-composer-actions' +import { + extractDroppedFiles, + HERMES_PATHS_MIME, + isImagePath, + partitionDroppedFiles +} from '@/app/chat/hooks/use-composer-actions' import { uploadComposerAttachment } from '@/app/session/hooks/use-prompt-actions' import { ClarifyTool } from '@/components/assistant-ui/clarify-tool' import { DirectiveContent, hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text' import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/markdown-text' -import { VirtualizedThread } from '@/components/assistant-ui/thread-virtualizer' -import { HoistedTodoPanel, todosFromMessageContent } from '@/components/assistant-ui/todo-tool' +import { ThreadMessageList } from '@/components/assistant-ui/thread-list' import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool-fallback' import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button' import { UserMessageText } from '@/components/assistant-ui/user-message-text' import { useElapsedSeconds } from '@/components/chat/activity-timer' import { ActivityTimerText } from '@/components/chat/activity-timer-text' import { DisclosureRow } from '@/components/chat/disclosure-row' -import { GeneratedImageProvider, useGeneratedImageContext } from '@/components/chat/generated-image-context' -import { ImageGenerationPlaceholder } from '@/components/chat/image-generation-placeholder' +import { GeneratedImage } from '@/components/chat/generated-image-result' import { Intro, type IntroProps } from '@/components/chat/intro' import { PreviewAttachment } from '@/components/chat/preview-attachment' import { Codicon } from '@/components/ui/codicon' +import { ConfirmDialog } from '@/components/ui/confirm-dialog' import { CopyButton } from '@/components/ui/copy-button' import { DropdownMenu, @@ -91,16 +96,22 @@ import { extractPreviewTargets } from '@/lib/preview-targets' import { useEnterAnimation } from '@/lib/use-enter-animation' import { cn } from '@/lib/utils' import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback' +import { $compactionActive } from '@/store/compaction' import type { ComposerAttachment } from '@/store/composer' import { notifyError } from '@/store/notifications' import { $connection } from '@/store/session' +import { notifyThreadEditClose, notifyThreadEditOpen } from '@/store/thread-scroll' import { $voicePlayback } from '@/store/voice-playback' type ThreadLoadingState = 'response' | 'session' interface MessageActionProps { messageId: string - messageText: string + /** Lazy accessor — reads the live message text at action time. Passing the + * text itself as a prop forces the whole footer to re-render on every + * streaming delta flush (the text changes ~30×/s), which profiling showed + * was a large slice of per-token script time on long transcripts. */ + getMessageText: () => string onBranchInNewChat?: (messageId: string) => void } @@ -128,6 +139,28 @@ function messageContentText(content: unknown): string { return Array.isArray(content) ? content.map(partText).join('').trim() : '' } +// Cheap streaming-stable "does this message have visible text" check: returns +// on the first non-whitespace text part without concatenating the whole +// message. Used as a useAuiState selector so its boolean output stays stable +// across token flushes (flips false→true once per turn). +function contentHasVisibleText(content: unknown): boolean { + if (typeof content === 'string') { + return content.trim().length > 0 + } + + if (!Array.isArray(content)) { + return false + } + + for (const part of content) { + if (partText(part).trim().length > 0) { + return true + } + } + + return false +} + export const Thread: FC<{ clampToComposer?: boolean cwd?: string | null @@ -136,6 +169,7 @@ export const Thread: FC<{ loading?: ThreadLoadingState onBranchInNewChat?: (messageId: string) => void onCancel?: () => Promise | void + onRestoreToMessage?: (messageId: string) => Promise | void sessionId?: string | null sessionKey?: string | null }> = ({ @@ -146,6 +180,7 @@ export const Thread: FC<{ loading, onBranchInNewChat, onCancel, + onRestoreToMessage, sessionId = null, sessionKey }) => { @@ -154,9 +189,9 @@ export const Thread: FC<{ AssistantMessage: () => , SystemMessage, UserEditComposer: () => , - UserMessage: () => + UserMessage: () => }), - [cwd, gateway, onBranchInNewChat, onCancel, sessionId] + [cwd, gateway, onBranchInNewChat, onCancel, onRestoreToMessage, sessionId] ) const emptyPlaceholder = intro ? ( @@ -166,18 +201,16 @@ export const Thread: FC<{ ) : undefined return ( - -
- : null} - sessionKey={sessionKey} - /> - {loading === 'session' && } -
-
+
+ : null} + sessionKey={sessionKey} + /> + {loading === 'session' && } +
) } @@ -214,21 +247,36 @@ const CenteredThreadSpinner: FC = () => { const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => { const messageId = useAuiState(s => s.message.id) - const content = useAuiState(s => s.message.content) - const messageText = messageContentText(content) - const hoistedTodos = useMemo(() => todosFromMessageContent(content), [content]) + const messageRuntime = useMessageRuntime() + + // PERF: this component must NOT subscribe to the streaming text. Every + // selector here returns a value that stays referentially stable across + // token flushes (booleans, status strings, '' while running), so the + // 30 Hz delta stream only re-renders the markdown part and the tiny + // StreamStallIndicator leaf — not the footer/preview/root subtree. + const messageStatus = useAuiState(s => s.message.status?.type) + const isRunning = messageStatus === 'running' + const isPlaceholder = useAuiState(s => s.message.status?.type === 'running' && s.message.content.length === 0) + const hasVisibleText = useAuiState(s => contentHasVisibleText(s.message.content)) + + // Preview targets only materialize once the turn completes — while running + // the selector returns '' (stable), so per-token flushes skip the regex + // scan and the re-render it would cause. + const completedText = useAuiState(s => + s.message.status?.type === 'running' ? '' : messageContentText(s.message.content) + ) const previewTargets = useMemo(() => { - if (!messageText || !/(https?:\/\/|file:\/\/)/i.test(messageText)) { + if (!completedText || !/(https?:\/\/|file:\/\/)/i.test(completedText)) { return [] } - return pickPrimaryPreviewTarget(extractPreviewTargets(messageText)) - }, [messageText]) + return pickPrimaryPreviewTarget(extractPreviewTargets(completedText)) + }, [completedText]) - const messageStatus = useAuiState(s => s.message.status?.type) - const isPlaceholder = messageStatus === 'running' && content.length === 0 - const enterRef = useEnterAnimation(messageStatus === 'running', `assistant-message:${messageId}`) + const getMessageText = useCallback(() => messageContentText(messageRuntime.getState().content), [messageRuntime]) + + const enterRef = useEnterAnimation(isRunning, `assistant-message:${messageId}`) if (isPlaceholder) { return null @@ -239,16 +287,16 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> className="group flex w-full min-w-0 max-w-full flex-col gap-0 self-start overflow-hidden" data-role="assistant" data-slot="aui_assistant-message-root" - data-streaming={messageStatus === 'running' ? 'true' : undefined} + data-streaming={isRunning ? 'true' : undefined} ref={enterRef} >
- {hoistedTodos.length > 0 && } + {/* Todos render in the composer status stack now, not inline. */} - {messageStatus === 'running' && } + {isRunning && } {previewTargets.length > 0 && (
{previewTargets.map(target => ( @@ -265,8 +313,8 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
- {messageText.trim().length > 0 && ( - + {hasVisibleText && ( + )} ) @@ -289,13 +337,25 @@ const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentProp
) +// Fixed label while auto-compaction runs — decoupled from backend status text. +const COMPACTION_LABEL = 'Summarizing thread' + +const CompactionHint: FC = () => ( + {COMPACTION_LABEL} +) + const ResponseLoadingIndicator: FC = () => { const { t } = useI18n() const elapsed = useElapsedSeconds() + const compacting = useStore($compactionActive) return ( - + ) @@ -307,11 +367,30 @@ const STREAM_STALL_S = 2 // Tail "still thinking" indicator: the pre-first-token spinner goes away once // text flows, but if the stream then goes quiet mid-turn (tool think-time, -// provider stall) nothing signals that work continues. Watch a per-render +// provider stall) nothing signals that work continues. Watch a per-flush // activity signal; when it hasn't changed for STREAM_STALL_S, re-show the // dither + a timer counting from the last activity. -const StreamStallIndicator: FC<{ activity: string }> = ({ activity }) => { +// +// Subscribes to the activity signal ITSELF (rather than taking it as a prop) +// so that per-token updates re-render only this leaf, not the whole +// AssistantMessage subtree. +const StreamStallIndicator: FC = () => { + const activity = useAuiState(s => { + let textLength = 0 + + for (const part of s.message.content) { + const text = (part as { text?: unknown }).text + + if (typeof text === 'string') { + textLength += text.length + } + } + + return `${s.message.content.length}:${textLength}` + }) + const [stalled, setStalled] = useState(false) + const compacting = useStore($compactionActive) useEffect(() => { setStalled(false) @@ -320,35 +399,32 @@ const StreamStallIndicator: FC<{ activity: string }> = ({ activity }) => { return () => window.clearTimeout(id) }, [activity]) - const elapsed = useElapsedSeconds(stalled) + const active = stalled || compacting + const elapsed = useElapsedSeconds(active) - if (!stalled) { + if (!active) { return null } return ( - + ) } -const ImageGenerateTool: FC = ({ result }) => { - const generatedImage = useGeneratedImageContext() - const running = result === undefined - - useEffect(() => { - generatedImage?.setPending(running) - }, [generatedImage, running]) - - if (!running) { - return null - } +const ImageGenerateTool: FC = ({ args, result }) => { + const aspectRatio = typeof args?.aspect_ratio === 'string' ? args.aspect_ratio : undefined return (
- +
) } @@ -512,10 +588,7 @@ const ReasoningTextPart: FC<{ text: string; status?: { type: string } }> = ({ te return ( } isRunning={isRunning} text={displayText} @@ -578,7 +651,7 @@ function formatMessageTimestamp( return SHORT_FMT.format(date) } -const AssistantActionBar: FC = ({ messageId, messageText, onBranchInNewChat }) => { +const AssistantActionBar: FC = ({ messageId, getMessageText, onBranchInNewChat }) => { const { t } = useI18n() const copy = t.assistant.thread const [menuOpen, setMenuOpen] = useState(false) @@ -599,7 +672,7 @@ const AssistantActionBar: FC = ({ messageId, messageText, on )} data-slot="aui_msg-actions" > - + triggerHaptic('submit')} tooltip={copy.refresh}> @@ -617,7 +690,7 @@ const AssistantActionBar: FC = ({ messageId, messageText, on {copy.branchNewChat} - + @@ -625,7 +698,7 @@ const AssistantActionBar: FC = ({ messageId, messageText, on ) } -const ReadAloudItem: FC<{ messageId: string; text: string }> = ({ messageId, text }) => { +const ReadAloudItem: FC<{ getText: () => string; messageId: string }> = ({ getText, messageId }) => { const { t } = useI18n() const copy = t.assistant.thread const voicePlayback = useStore($voicePlayback) @@ -639,6 +712,8 @@ const ReadAloudItem: FC<{ messageId: string; text: string }> = ({ messageId, tex const Icon = isPreparing ? Loader2Icon : isSpeaking ? VolumeXIcon : Volume2Icon const read = useCallback(async () => { + const text = getText() + if (!text || $voicePlayback.get().status !== 'idle') { return } @@ -648,11 +723,11 @@ const ReadAloudItem: FC<{ messageId: string; text: string }> = ({ messageId, tex } catch (error) { notifyError(error, copy.readAloudFailed) } - }, [copy.readAloudFailed, messageId, text]) + }, [copy.readAloudFailed, getText, messageId]) return ( { e.preventDefault() void (isSpeaking ? stopVoicePlayback() : read()) @@ -706,15 +781,22 @@ function messageAttachmentRefs(value: unknown): string[] { return value.every(ref => typeof ref === 'string') ? value : EMPTY_ATTACHMENT_REFS } -function StickyHumanMessageContainer({ children }: { children: ReactNode }) { +function StickyHumanMessageContainer({ attachments, children }: { attachments?: ReactNode; children: ReactNode }) { return ( -
- {children} -
+ // Fragment, not a wrapper: a wrapping element becomes the sticky's + // containing block (it'd stick within its own height = never). The bubble + // and attachments are flow siblings so the bubble pins against the scroller + // while attachments below it scroll away. + <> +
+ {children} +
+ {attachments} + ) } @@ -737,11 +819,46 @@ const USER_ACTION_ICON_BUTTON_CLASS = const USER_ACTION_ICON_SIZE = '0.6875rem' const StopGlyph = +// Background-process notifications are injected into the conversation as user +// messages (the agent must react to them, and message-role alternation forbids +// a synthetic system row mid-loop). They are NOT something the human typed, so +// render them as a compact system-style notice instead of a user bubble. +// Shape: see tools/process_registry.py format_process_notification(). +const PROCESS_NOTIFICATION_RE = /^\[IMPORTANT: Background process [\s\S]*\]$/ + +const ProcessNotificationNote: FC<{ text: string }> = ({ text }) => { + const body = text.replace(/^\[IMPORTANT:\s*/, '').replace(/\]$/, '') + const newline = body.indexOf('\n') + const headline = (newline === -1 ? body : body.slice(0, newline)).trim() + const detail = newline === -1 ? '' : body.slice(newline + 1).trim() + + return ( +
+ + + {headline} + + {detail && ( +
+ + output + +
+            {detail}
+          
+
+ )} +
+ ) +} + const UserMessage: FC<{ onCancel?: () => Promise | void -}> = ({ onCancel }) => { + onRestoreToMessage?: (messageId: string) => Promise | void +}> = ({ onCancel, onRestoreToMessage }) => { const { t } = useI18n() const copy = t.assistant.thread + const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false) const messageId = useAuiState(s => s.message.id) const content = useAuiState(s => s.message.content) const messageText = messageContentText(content) @@ -772,8 +889,10 @@ const UserMessage: FC<{ // changes, not on every frame while the outer max-height animates open. const clampInnerRef = useRef(null) const [bodyClamped, setBodyClamped] = useState(false) + const lastClampHeightRef = useRef(-1) + const lineHeightRef = useRef(0) - const measureClamp = useCallback(() => { + const measureClamp = useCallback((entries: readonly ResizeObserverEntry[]) => { const inner = clampInnerRef.current const outer = inner?.parentElement @@ -781,68 +900,105 @@ const UserMessage: FC<{ return } - const styles = getComputedStyle(inner) - const lineHeight = parseFloat(styles.lineHeight) || 1.5 * parseFloat(styles.fontSize) || 20 - const fullHeight = inner.scrollHeight + // Prefer the size the ResizeObserver already computed — reading + // `scrollHeight` outside RO timing forces a synchronous layout, and with + // many user bubbles observed at once those reads interleave with the + // style write below into a read-write-read reflow cascade. + const entryHeight = entries.find(entry => entry.target === inner)?.borderBoxSize?.[0]?.blockSize + const fullHeight = Math.ceil(entryHeight ?? inner.scrollHeight) + + if (fullHeight === lastClampHeightRef.current) { + return + } + + lastClampHeightRef.current = fullHeight + + // Line-height is stable for the life of the bubble (font settings don't + // change under it) — resolve the computed style once. + if (!lineHeightRef.current) { + const styles = getComputedStyle(inner) + lineHeightRef.current = parseFloat(styles.lineHeight) || 1.5 * parseFloat(styles.fontSize) || 20 + } outer.style.setProperty('--human-msg-full', `${fullHeight}px`) - setBodyClamped(fullHeight > lineHeight * 2 + 1) + setBodyClamped(fullHeight > lineHeightRef.current * 2 + 1) }, []) useResizeObserver(measureClamp, clampInnerRef) + // Injected background-process notification, not a human prompt — render the + // compact system-style notice (after all hooks above have run). + if (PROCESS_NOTIFICATION_RE.test(messageText.trim())) { + return ( + + + + ) + } + const hasBody = messageText.trim().length > 0 const isLatestUser = messageId === latestUserId const showStop = isLatestUser && threadRunning && Boolean(onCancel) - const showRestore = !isLatestUser && !threadRunning + // Restore (re-run this exact prompt) is available everywhere the Stop button + // isn't — including mid-stream on older prompts, since the action interrupts + // the live turn before rewinding. + const showRestore = !showStop && Boolean(onRestoreToMessage) && hasBody const bubbleClassName = cn( USER_BUBBLE_BASE_CLASS, - 'border-(--ui-stroke-tertiary) pr-9 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 transition-colors', - !threadRunning && 'cursor-pointer hover:border-(--ui-stroke-secondary)' + 'cursor-pointer pr-9 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 transition-colors', + 'border-(--ui-stroke-tertiary) hover:border-(--ui-stroke-secondary)' ) - const bubbleContent = ( - <> - {attachmentRefs.length > 0 && ( - - - - )} - {hasBody && ( - // Render the user's text through a minimal markdown pipeline: - // backtick `code` and ``` fenced ``` blocks, with directive chips - // (`@file:` etc.) still resolved inside the plain-text spans. -
-
- -
-
- )} - + const bubbleContent = hasBody && ( + // Render the user's text through a minimal markdown pipeline: + // backtick `code` and ``` fenced ``` blocks, with directive chips + // (`@file:` etc.) still resolved inside the plain-text spans. +
+ {/* Match the edit composer's collapsed line box (min-h-[1.25rem]) so + clicking to edit can't grow the bubble by a sub-pixel and reflow the + turn 1px. */} +
+ +
+
) return ( - + 0 ? ( +
+ +
+ ) : null + } + >
- {threadRunning ? ( -
{bubbleContent}
- ) : ( - - - - )} + {/* Always editable — clicking opens the edit composer even while a + turn streams; sending the edit reverts (interrupt + rewind). */} + + + {(showStop || showRestore) && (
{showStop ? ( @@ -860,13 +1016,20 @@ const UserMessage: FC<{ {StopGlyph} ) : ( - + )}
)} @@ -894,6 +1057,17 @@ const UserMessage: FC<{
+ {showRestore && ( + setRestoreConfirmOpen(false)} + onConfirm={() => onRestoreToMessage?.(messageId)} + open={restoreConfirmOpen} + title={copy.restoreTitle} + /> + )} ) @@ -1008,6 +1182,8 @@ const UserEditComposer: FC = ({ cwd, gateway, sessionId } const at = useAtCompletions({ cwd, gateway, sessionId }) const slash = useSlashCompletions({ gateway }) + useEffect(() => () => notifyThreadEditClose(), []) + const focusEditor = useCallback(() => { const editor = editorRef.current @@ -1261,7 +1437,10 @@ const UserEditComposer: FC = ({ cwd, gateway, sessionId } } const remote = $connection.get()?.mode === 'remote' - const requestGateway = (method: string, params?: Record) => gateway.request(method, params) + + const requestGateway = (method: string, params?: Record) => + gateway.request(method, params) + const refs: InlineRefInput[] = [] for (const candidate of osDrops) { @@ -1530,9 +1709,8 @@ const UserEditComposer: FC = ({ cwd, gateway, sessionId } aria-label={copy.editMessage} autoCapitalize="off" autoCorrect="off" - autoFocus className={cn( - 'ui-prompt-input-editor__input max-h-48 w-full resize-none bg-transparent p-0 pr-7 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 outline-none', + 'ui-prompt-input-editor__input max-h-48 w-full resize-none bg-transparent p-0 pr-7 text-[length:var(--conversation-text-font-size)] text-foreground/95 outline-none', 'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60', '**:data-ref-text:cursor-default', expanded ? 'min-h-16' : 'min-h-[1.25rem]' diff --git a/apps/desktop/src/components/assistant-ui/todo-tool.tsx b/apps/desktop/src/components/assistant-ui/todo-tool.tsx deleted file mode 100644 index 549c8c3bd9d..00000000000 --- a/apps/desktop/src/components/assistant-ui/todo-tool.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { type FC } from 'react' - -import { Checkbox } from '@/components/ui/checkbox' -import { Loader2Icon } from '@/lib/icons' -import { parseTodos, type TodoItem, type TodoStatus } from '@/lib/todos' -import { cn } from '@/lib/utils' - -export function todosFromMessageContent(content: unknown): TodoItem[] { - if (!Array.isArray(content)) { - return [] - } - - let latest: null | TodoItem[] = null - - for (const part of content) { - if (!part || typeof part !== 'object') { - continue - } - - const row = part as Record - - if (row.type !== 'tool-call' || row.toolName !== 'todo') { - continue - } - - const parsed = parseTodos(row.result) ?? parseTodos(row.args) - - if (parsed !== null) { - latest = parsed - } - } - - return latest ?? [] -} - -const headerLabel = (todos: readonly TodoItem[]): string => - todos.find(t => t.status === 'in_progress')?.content ?? - todos.find(t => t.status === 'pending')?.content ?? - todos.at(-1)?.content ?? - 'Tasks' - -const Checkmark: FC<{ status: TodoStatus; label: string }> = ({ status, label }) => { - if (status === 'in_progress') { - return ( - - - - ) - } - - const checked = status === 'completed' - - return ( - - ) -} - -export const HoistedTodoPanel: FC<{ todos: TodoItem[] }> = ({ todos }) => { - if (!todos.length) { - return null - } - - const label = headerLabel(todos) - - return ( -
-
- - {label} - -
-
    - {todos.map(todo => ( -
  • - - {todo.content} -
  • - ))} -
-
- ) -} diff --git a/apps/desktop/src/components/assistant-ui/tool-approval-group.test.tsx b/apps/desktop/src/components/assistant-ui/tool-approval-group.test.tsx index 0f897e54d75..79c07ea4dbf 100644 --- a/apps/desktop/src/components/assistant-ui/tool-approval-group.test.tsx +++ b/apps/desktop/src/components/assistant-ui/tool-approval-group.test.tsx @@ -1,9 +1,10 @@ import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react' -import { cleanup, render, waitFor } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { clearAllPrompts, setApprovalRequest } from '@/store/prompts' import { $activeSessionId } from '@/store/session' +import { clearDismissedToolRows } from '@/store/tool-dismiss' import { $toolDisclosureStates } from '@/store/tool-view' import { Thread } from './thread' @@ -104,6 +105,84 @@ function groupedPendingMessage(): ThreadMessage { } as ThreadMessage } +function pendingOnlyMessage(): ThreadMessage { + return { + id: 'assistant-pending-only', + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'term-only', + toolName: 'terminal', + args: { command: 'sleep 10' }, + argsText: JSON.stringify({ command: 'sleep 10' }) + } + ], + status: { type: 'running' }, + createdAt, + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {} + } + } as ThreadMessage +} + +function completedOnlyMessage(): ThreadMessage { + return { + id: 'assistant-completed-only', + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'read-only', + toolName: 'read_file', + args: { path: '/etc/hosts' }, + argsText: JSON.stringify({ path: '/etc/hosts' }), + result: { content: '127.0.0.1 localhost' } + } + ], + status: { type: 'complete', reason: 'stop' }, + createdAt, + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {} + } + } as ThreadMessage +} + +function failedOnlyMessage(): ThreadMessage { + return { + id: 'assistant-failed-only', + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'term-failed', + toolName: 'terminal', + args: { command: 'exit 1' }, + argsText: JSON.stringify({ command: 'exit 1' }), + isError: true, + result: { stderr: 'boom' } + } + ], + status: { type: 'complete', reason: 'stop' }, + createdAt, + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {} + } + } as ThreadMessage +} + function GroupHarness({ message }: { message: ThreadMessage }) { const runtime = useExternalStoreRuntime({ messages: [message], @@ -122,12 +201,14 @@ beforeEach(() => { clearAllPrompts() $activeSessionId.set('sess-1') $toolDisclosureStates.set({}) + clearDismissedToolRows() }) afterEach(() => { cleanup() clearAllPrompts() $activeSessionId.set(null) + clearDismissedToolRows() }) describe('flat tool list approval surfacing', () => { @@ -155,4 +236,64 @@ describe('flat tool list approval surfacing', () => { expect(bar?.closest('[hidden]')).toBeNull() }) }) + + it('lets completed tool rows be dismissed', async () => { + const { container } = render() + + const dismiss = await screen.findByLabelText('Dismiss') + + expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(1) + + fireEvent.click(dismiss) + + await waitFor(() => { + expect(screen.queryByLabelText('Dismiss')).toBeNull() + }) + }) + + it('keeps a dismissed row hidden after a remount (virtualization)', async () => { + // The thread virtualizes, so a row's component unmounts/remounts as it + // scrolls. Dismissal must persist across that — component-local state would + // forget it and the row would pop back. Simulate the remount by unmounting + // and rendering the same message fresh. + const first = render() + + fireEvent.click(await screen.findByLabelText('Dismiss')) + + await waitFor(() => { + expect(screen.queryByLabelText('Dismiss')).toBeNull() + }) + + first.unmount() + + const { container } = render() + + await waitFor(() => { + expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(0) + }) + + expect(screen.queryByLabelText('Dismiss')).toBeNull() + }) + + it('lets failed tool rows be dismissed', async () => { + render() + + const dismiss = await screen.findByLabelText('Dismiss') + + fireEvent.click(dismiss) + + await waitFor(() => { + expect(screen.queryByLabelText('Dismiss')).toBeNull() + }) + }) + + it('does not show dismiss for pending tool rows', async () => { + const { container } = render() + + await waitFor(() => { + expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(0) + }) + + expect(screen.queryByLabelText('Dismiss')).toBeNull() + }) }) diff --git a/apps/desktop/src/components/assistant-ui/tool-approval.test.tsx b/apps/desktop/src/components/assistant-ui/tool-approval.test.tsx index 0d13371afee..007eeff831b 100644 --- a/apps/desktop/src/components/assistant-ui/tool-approval.test.tsx +++ b/apps/desktop/src/components/assistant-ui/tool-approval.test.tsx @@ -84,6 +84,19 @@ describe('PendingToolApproval', () => { expect($approvalRequest.get()).toBeNull() }) + it('reveals the full command inline when the Command toggle is clicked', () => { + const longCommand = 'python -c "' + 'x'.repeat(400) + '"' + setRequest(longCommand) + render() + + // Collapsed by default: the full command is not in the DOM yet. + expect(screen.queryByText(longCommand)).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: /Command/ })) + + expect(screen.getByText(longCommand)).toBeTruthy() + }) + it('sends choice "deny" on Reject', async () => { const request = mockGateway() setRequest() diff --git a/apps/desktop/src/components/assistant-ui/tool-approval.tsx b/apps/desktop/src/components/assistant-ui/tool-approval.tsx index 6a3dc6c0d9c..d355fda77fc 100644 --- a/apps/desktop/src/components/assistant-ui/tool-approval.tsx +++ b/apps/desktop/src/components/assistant-ui/tool-approval.tsx @@ -16,6 +16,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' import { ChevronDown, Loader2 } from '@/lib/icons' +import { cn } from '@/lib/utils' import { $gateway } from '@/store/gateway' import { notifyError } from '@/store/notifications' import { $approvalRequest, type ApprovalRequest, clearApprovalRequest } from '@/store/prompts' @@ -60,9 +61,15 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => { // "Always allow" persists the pattern to ~/.hermes/config.yaml permanently, so // it goes through a confirm step rather than firing straight from the menu. const [confirmAlways, setConfirmAlways] = useState(false) + // The pending tool row only shows a single truncated line of the command, and + // a pending row can't be expanded (no result yet), so the full command was + // previously only reachable via the "Always allow" modal. Let the user reveal + // it inline instead — "expand, Run" (2 clicks) rather than the modal dance. + const [showCommand, setShowCommand] = useState(false) const busy = submitting !== null // false when the backend won't honor a permanent allow (tirith warning) → hide "Always allow". const allowPermanent = request.allowPermanent !== false + const hasCommand = request.command.trim().length > 0 const respond = useCallback( async (choice: ApprovalChoice) => { @@ -119,70 +126,89 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => { }, [confirmAlways, respond]) return ( -
-
+
+
+
+ + + + + + + + void respond('session')}>{copy.allowSession} + {allowPermanent && ( + { + // Defer one tick so the menu fully unmounts before the dialog + // mounts — otherwise Radix's focus-return races the dialog and + // dismisses it via onInteractOutside. + setTimeout(() => setConfirmAlways(true), 0) + }} + > + {copy.alwaysAllowMenu} + + )} + void respond('deny')} variant="destructive"> + {copy.reject} + + + +
+ - - - - - - - void respond('session')}>{copy.allowSession} - {allowPermanent && ( - { - // Defer one tick so the menu fully unmounts before the dialog - // mounts — otherwise Radix's focus-return races the dialog and - // dismisses it via onInteractOutside. - setTimeout(() => setConfirmAlways(true), 0) - }} - > - {copy.alwaysAllowMenu} - - )} - void respond('deny')} variant="destructive"> - {copy.reject} - - - + + {hasCommand && ( + + )}
- + {showCommand && hasCommand && ( +
+          {request.command.trim()}
+        
+ )} {copy.alwaysTitle} - - {copy.alwaysDescription(request.description)} - + {copy.alwaysDescription(request.description)} {request.command.trim() && ( diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx index b5d65b5571e..e93eabe1557 100644 --- a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx @@ -12,16 +12,20 @@ import { DiffLines } from '@/components/chat/diff-lines' import { DisclosureRow } from '@/components/chat/disclosure-row' import { PreviewAttachment } from '@/components/chat/preview-attachment' import { ZoomableImage } from '@/components/chat/zoomable-image' -import { BrailleSpinner } from '@/components/ui/braille-spinner' +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' import { CopyButton } from '@/components/ui/copy-button' import { FadeText } from '@/components/ui/fade-text' +import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { ToolIcon } from '@/components/ui/tool-icon' +import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link' import { AlertCircle, CheckCircle2 } from '@/lib/icons' import { useEnterAnimation } from '@/lib/use-enter-animation' import { cn } from '@/lib/utils' import { $toolInlineDiffs } from '@/store/tool-diffs' +import { $toolRowDismissed, dismissToolRow } from '@/store/tool-dismiss' import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view' import { PendingToolApproval } from './tool-approval' @@ -100,7 +104,7 @@ function rawTechnicalTrace(args: unknown, result: unknown): string { function statusGlyph(status: ToolStatus, copy: ToolStatusCopy): ReactNode { if (status === 'running') { return ( - + ) } @@ -196,13 +197,16 @@ function useDisclosureOpen(disclosureId: string, fallbackOpen = false): boolean function ToolEntry({ part }: ToolEntryProps) { const { t } = useI18n() const copy = t.assistant.tool + const statusCopy = t.statusStack const messageId = useAuiState(s => s.message.id) const messageRunning = useAuiState(selectMessageRunning) const embedded = useContext(ToolEmbedContext) const toolViewMode = useStore($toolViewMode) const disclosureId = `tool-entry:${messageId}:${toolPartDisclosureId(part)}` + const dismissed = useStore($toolRowDismissed(disclosureId)) const open = useDisclosureOpen(disclosureId) const isPending = messageRunning && part.result === undefined + const canDismiss = !isPending && !embedded // Only animate entries that mount while their message is actively // streaming — historical sessions mount with `messageRunning === false`, // so they paint statically without a settle cascade. The wrapping group @@ -285,9 +289,33 @@ function ToolEntry({ part }: ToolEntryProps) { // the disclosure caret hard to hit. Copy now lives in the expanded body's // top-right, where it can't fight the caret for the right edge. const trailing = - isPending && !embedded ? ( - - ) : undefined + isPending && !embedded ? : undefined + + // Once a turn has settled, a hover/focus-revealed dismiss lets the user clear + // a completed/failed row that would otherwise sit at the tail of the chat. + // It goes in the in-flow `action` slot (not `trailing`) so it can't overlap + // the disclosure caret's hit-target — see the comment above `trailing`. + const dismissAction = canDismiss ? ( + + + + ) : undefined + + if (dismissed) { + return null + } return (
setToolDisclosureOpen(disclosureId, !open) : undefined} open={open} trailing={trailing} diff --git a/apps/desktop/src/components/chat/composer-dock.ts b/apps/desktop/src/components/chat/composer-dock.ts new file mode 100644 index 00000000000..8eb2b24e7ee --- /dev/null +++ b/apps/desktop/src/components/chat/composer-dock.ts @@ -0,0 +1,31 @@ +import { cn } from '@/lib/utils' + +/** + * The composer surface and everything docked to it (slash·@ popover, `?` help) + * paint ONE shared `--composer-fill` var. The state ladder (rest / scrolled / + * focused / drawer-open) lives in styles.css on `[data-slot='composer-root']`, + * so the two layers can never disagree — drawer-open forces an opaque fill via + * `:has()`, because translucent glass sampling different backdrops (thread vs + * fade gradient) renders as different colors even with identical tints. + */ +export const composerFill = 'bg-(--composer-fill)' + +/** Backdrop treatment for the composer input surface. Harmless when the fill + * goes opaque (drawer open) — nothing shows through to blur. */ +export const composerSurfaceGlass = cn( + 'backdrop-blur-[0.75rem] backdrop-saturate-[1.12] [-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]', + 'transition-[background-color] duration-150 ease-out' +) + +const composerDockEdge = (edge: 'bottom' | 'top') => + cn('border border-border/65', edge === 'top' ? 'rounded-t-2xl border-b-0' : 'rounded-b-2xl border-t-0') + +/** Glassy docked card — the status stack / queue. Paints the SAME + * `--composer-fill` as the surface, so rest / scrolled / focused / drawer-open + * all match the composer by construction. */ +export const composerDockCard = (edge: 'bottom' | 'top' = 'top') => + cn(composerDockEdge(edge), composerFill, composerSurfaceGlass) + +/** Fused docked card — completion drawers. Shares `--composer-fill` with the + * composer surface, which goes opaque while a drawer is open. */ +export const composerFusedDockCard = (edge: 'bottom' | 'top' = 'top') => cn(composerDockEdge(edge), composerFill) diff --git a/apps/desktop/src/components/chat/disclosure-row.tsx b/apps/desktop/src/components/chat/disclosure-row.tsx index e0555fceb06..56cd6d9a3dd 100644 --- a/apps/desktop/src/components/chat/disclosure-row.tsx +++ b/apps/desktop/src/components/chat/disclosure-row.tsx @@ -14,12 +14,19 @@ import { cn } from '@/lib/utils' // title text, NOT the full row — and reaches just past the chevron with // `-mx-1.5 px-1.5` so it reads as a soft hit-target rather than a slab // stretching to the message edge. +// - `trailing` overlays the right edge (absolute) and must stay +// non-interactive (e.g. a duration timer) — an opacity-0-but-clickable +// control there steals clicks from the caret. Interactive controls go in +// `action`, which lays out *in flow* at the far right so it never sits on +// top of the caret's hit-target, no matter how long the title is. export function DisclosureRow({ + action, children, onToggle, open, trailing }: { + action?: ReactNode children: ReactNode onToggle?: () => void open: boolean @@ -55,6 +62,11 @@ export function DisclosureRow({ )} + {action && ( + + {action} + + )} {trailing && ( {trailing} )} diff --git a/apps/desktop/src/components/chat/generated-image-context.tsx b/apps/desktop/src/components/chat/generated-image-context.tsx deleted file mode 100644 index 8b020bb7db6..00000000000 --- a/apps/desktop/src/components/chat/generated-image-context.tsx +++ /dev/null @@ -1,19 +0,0 @@ -'use client' - -import { createContext, type ReactNode, useContext, useMemo, useState } from 'react' - -type Value = { - isPending: boolean - setPending: (pending: boolean) => void -} - -const Ctx = createContext(null) - -export function GeneratedImageProvider({ children }: { children: ReactNode }) { - const [isPending, setPending] = useState(false) - const value = useMemo(() => ({ isPending, setPending }), [isPending]) - - return {children} -} - -export const useGeneratedImageContext = () => useContext(Ctx) diff --git a/apps/desktop/src/components/chat/generated-image-result.tsx b/apps/desktop/src/components/chat/generated-image-result.tsx new file mode 100644 index 00000000000..e4313d20c51 --- /dev/null +++ b/apps/desktop/src/components/chat/generated-image-result.tsx @@ -0,0 +1,174 @@ +'use client' + +import { type FC, useEffect, useState } from 'react' + +import { DiffusionCanvas } from '@/components/chat/image-generation-placeholder' +import { ImageActionButton, ImageLightbox } from '@/components/chat/zoomable-image' +import { useImageDownload } from '@/hooks/use-image-download' +import { useI18n } from '@/i18n' +import { generatedImageFromResult } from '@/lib/generated-images' +import { filePathFromMediaPath, gatewayMediaDataUrl, isRemoteGateway, mediaExternalUrl, mediaName } from '@/lib/media' +import { cn } from '@/lib/utils' + +// Aspect hint from the tool args sizes the frame *before* the image loads, so +// the placeholder and the resolved image occupy the same box — no layout shift. +const ASPECT_HINTS: Record = { + landscape: 16 / 9, + square: 1, + portrait: 9 / 16 +} + +function hintedRatio(aspectRatio?: string): number { + return ASPECT_HINTS[String(aspectRatio ?? '').toLowerCase().trim()] ?? ASPECT_HINTS.landscape +} + +function isInlineSrc(path: string): boolean { + return /^(?:https?|data):/i.test(path) +} + +async function resolveImageSrc(path: string): Promise { + if (isInlineSrc(path)) { + return path + } + + if (window.hermesDesktop && isRemoteGateway()) { + return gatewayMediaDataUrl(path) + } + + if (!window.hermesDesktop?.readFileDataUrl) { + return mediaExternalUrl(path) + } + + return window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path)) +} + +export const GeneratedImage: FC<{ aspectRatio?: string; result?: unknown }> = ({ aspectRatio, result }) => { + const { t } = useI18n() + const copy = t.desktop + const image = result === undefined ? null : generatedImageFromResult(result) + const pending = result === undefined + + const [ratio, setRatio] = useState(() => hintedRatio(aspectRatio)) + const [src, setSrc] = useState(() => (image && isInlineSrc(image) ? image : '')) + const [loaded, setLoaded] = useState(false) + const [canvasGone, setCanvasGone] = useState(false) + const [failed, setFailed] = useState(false) + const [lightboxOpen, setLightboxOpen] = useState(false) + const { download, saving } = useImageDownload(src) + + useEffect(() => setRatio(hintedRatio(aspectRatio)), [aspectRatio]) + + // Resolve the deliverable path (local read / gateway proxy / remote URL). The + // stays mounted under the placeholder and only fades in once it decodes, + // so the frame keeps its hinted size and never jumps. + useEffect(() => { + let cancelled = false + setFailed(false) + setLoaded(false) + setCanvasGone(false) + setSrc(image && isInlineSrc(image) ? image : '') + + if (!image || isInlineSrc(image)) { + return + } + + void resolveImageSrc(image) + .then(resolved => !cancelled && setSrc(resolved)) + .catch(() => !cancelled && setFailed(true)) + + return () => { + cancelled = true + } + }, [image]) + + // Completed but no usable image (generation failed): the agent's prose carries + // the explanation, so render nothing here. + if (!pending && !image) { + return null + } + + if (failed && image) { + return ( + { + event.preventDefault() + void window.hermesDesktop?.openExternal(mediaExternalUrl(image)) + }} + > + {copy.openImage}: {mediaName(image)} + + ) + } + + return ( + <> + + {!canvasGone && ( +
loaded && setCanvasGone(true)} + > + +
+ )} + {src && ( + + )} + {loaded && src && ( + + )} +
+ {src && ( + + )} + + ) +} diff --git a/apps/desktop/src/components/chat/image-generation-placeholder.tsx b/apps/desktop/src/components/chat/image-generation-placeholder.tsx index 972c3aaf961..228f183f652 100644 --- a/apps/desktop/src/components/chat/image-generation-placeholder.tsx +++ b/apps/desktop/src/components/chat/image-generation-placeholder.tsx @@ -1,7 +1,6 @@ import { type FC, useCallback, useEffect, useRef } from 'react' import { useResizeObserver } from '@/hooks/use-resize-observer' -import { useI18n } from '@/i18n' type Rgb = { r: number; g: number; b: number } @@ -24,19 +23,26 @@ const smoothstep = (edge0: number, edge1: number, value: number) => { } const parseColor = (value: string, fallback: Rgb): Rgb => { - const hex = value.trim().match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i) + const v = value.trim() + + const hex = v.match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i) if (hex) { - return { - r: Number.parseInt(hex[1], 16), - g: Number.parseInt(hex[2], 16), - b: Number.parseInt(hex[3], 16) - } + return { r: Number.parseInt(hex[1], 16), g: Number.parseInt(hex[2], 16), b: Number.parseInt(hex[3], 16) } } - const rgb = value.trim().match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i) + const rgb = v.match(/rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/i) - return rgb ? { r: Number(rgb[1]), g: Number(rgb[2]), b: Number(rgb[3]) } : fallback + if (rgb) { + return { r: Number(rgb[1]), g: Number(rgb[2]), b: Number(rgb[3]) } + } + + // Chromium serialises `color-mix(in srgb, …)` as `color(srgb r g b / a)` with 0–1 floats. + const srgb = v.match(/color\(\s*srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/i) + + return srgb + ? { r: Math.round(Number(srgb[1]) * 255), g: Math.round(Number(srgb[2]) * 255), b: Math.round(Number(srgb[3]) * 255) } + : fallback } const mix = (a: Rgb, b: Rgb, amount: number): Rgb => ({ @@ -82,17 +88,22 @@ const fbm = (x: number, y: number) => { return value } -const readTheme = () => { - const styles = getComputedStyle(document.documentElement) +type Theme = Record - return { - card: parseColor(styles.getPropertyValue('--dt-card'), FALLBACKS.card), - muted: parseColor(styles.getPropertyValue('--dt-muted'), FALLBACKS.muted), - foreground: parseColor(styles.getPropertyValue('--dt-foreground'), FALLBACKS.foreground), - primary: parseColor(styles.getPropertyValue('--dt-primary'), FALLBACKS.primary), - ring: parseColor(styles.getPropertyValue('--dt-ring'), FALLBACKS.ring) - } -} +const TOKENS = Object.keys(FALLBACKS) as (keyof typeof FALLBACKS)[] + +// `--dt-*` resolve through `var()` chains into `color-mix()`, which +// getPropertyValue hands back verbatim — unreadable. Bouncing each token through +// a probe's `color` lets the browser compute it to a concrete color we can +// parse, so the canvas tracks the live theme instead of a hardcoded fallback. +const readTheme = (probe: HTMLElement): Theme => + Object.fromEntries( + TOKENS.map(key => { + probe.style.color = `var(--dt-${key})` + + return [key, parseColor(getComputedStyle(probe).color, FALLBACKS[key])] + }) + ) as Theme const fitCanvas = (canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) => { const rect = canvas.getBoundingClientRect() @@ -107,8 +118,13 @@ const fitCanvas = (canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) => return { width, height } } -const drawAsciiDiffusion = (ctx: CanvasRenderingContext2D, width: number, height: number, time: number) => { - const theme = readTheme() +const drawAsciiDiffusion = ( + ctx: CanvasRenderingContext2D, + theme: Theme, + width: number, + height: number, + time: number +) => { const bg = ctx.createLinearGradient(0, 0, width, height) bg.addColorStop(0, rgba(mix(theme.card, theme.primary, 0.08), 1)) bg.addColorStop(0.54, rgba(mix(theme.card, theme.muted, 0.68), 1)) @@ -125,6 +141,9 @@ const drawAsciiDiffusion = (ctx: CanvasRenderingContext2D, width: number, height const cellHeight = fontSize * 1.28 const cols = Math.ceil(width / cellWidth) const rows = Math.ceil(height / cellHeight) + // Normalise both axes by the shorter side so the radial bloom stays a circle + // (not a squished ellipse) when the frame isn't landscape. + const short = Math.min(width, height) const centerX = 0.53 + Math.sin(time * 0.055) * 0.02 const centerY = 0.5 + Math.cos(time * 0.048) * 0.02 const timestep = Math.floor(time * 1.15) @@ -138,10 +157,10 @@ const drawAsciiDiffusion = (ctx: CanvasRenderingContext2D, width: number, height for (let col = -1; col <= cols + 1; col += 1) { const x = col * cellWidth + cellWidth * 0.5 const y = row * cellHeight + cellHeight * 0.5 - const nx = x / width - const ny = y / height - const dx = (nx - centerX) * 1.2 - const dy = (ny - centerY) * 0.95 + const sx = (x - centerX * width) / short + const sy = (y - centerY * height) / short + const dx = sx * 1.2 + const dy = sy * 0.95 const radius = Math.hypot(dx, dy) const angle = Math.atan2(dy, dx) @@ -152,7 +171,7 @@ const drawAsciiDiffusion = (ctx: CanvasRenderingContext2D, width: number, height const contour = Math.exp(-((Math.sin(angle * 3 + radius * 17 - time * 0.17) * 0.5 + 0.5 - radius) ** 2) / 0.016) * 0.38 - const stem = Math.exp(-((nx - centerX + 0.05) ** 2 / 0.004 + (ny - centerY - 0.25) ** 2 / 0.08)) * 0.46 + const stem = Math.exp(-((sx + 0.05) ** 2 / 0.004 + (sy - 0.25) ** 2 / 0.08)) * 0.46 const latent = clamp(bloom + contour + stem, 0, 1) const staticA = hash2(col + timestep * 19, row - timestep * 11) @@ -224,9 +243,10 @@ const drawAsciiDiffusion = (ctx: CanvasRenderingContext2D, width: number, height ctx.fillRect(0, 0, width, height) } -const DiffusionCanvas: FC = () => { +export const DiffusionCanvas: FC = () => { const canvasRef = useRef(null) const sizeRef = useRef({ width: 0, height: 0 }) + const themeRef = useRef(FALLBACKS) const fitToContainer = useCallback(() => { const canvas = canvasRef.current @@ -241,6 +261,28 @@ const DiffusionCanvas: FC = () => { useResizeObserver(fitToContainer, canvasRef) + useEffect(() => { + const probe = document.createElement('span') + probe.style.cssText = 'position:absolute;width:0;height:0;visibility:hidden;pointer-events:none' + document.documentElement.appendChild(probe) + + const sync = () => { + themeRef.current = readTheme(probe) + } + + sync() + + // Re-resolve when the theme repaints (`applyTheme` toggles `.dark` and + // rewrites inline custom props on the root) instead of per animation frame. + const observer = new MutationObserver(sync) + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style', 'data-hermes-mode'] }) + + return () => { + observer.disconnect() + probe.remove() + } + }, []) + useEffect(() => { const canvas = canvasRef.current const ctx = canvas?.getContext('2d') @@ -254,7 +296,7 @@ const DiffusionCanvas: FC = () => { let frame = requestAnimationFrame(function draw(now) { const { width, height } = sizeRef.current ctx.clearRect(0, 0, width, height) - drawAsciiDiffusion(ctx, width, height, now / 1000) + drawAsciiDiffusion(ctx, themeRef.current, width, height, now / 1000) frame = requestAnimationFrame(draw) }) @@ -265,15 +307,3 @@ const DiffusionCanvas: FC = () => { return } - -export const ImageGenerationPlaceholder: FC = () => { - const { t } = useI18n() - - return ( -
-
- -
-
- ) -} diff --git a/apps/desktop/src/components/chat/status-row.tsx b/apps/desktop/src/components/chat/status-row.tsx new file mode 100644 index 00000000000..ad4769c458f --- /dev/null +++ b/apps/desktop/src/components/chat/status-row.tsx @@ -0,0 +1,70 @@ +import { type ReactNode } from 'react' + +import { cn } from '@/lib/utils' + +interface StatusRowProps { + children: ReactNode + className?: string + /** Leading glyph slot (spinner / status dot / selection circle). */ + leading?: ReactNode + /** Makes the whole row activatable (adds `cursor-pointer` + keyboard a11y). + * Trailing-slot buttons should `stopPropagation` so they don't also fire it. */ + onActivate?: () => void + /** Right-aligned actions. Revealed on row hover/focus unless `trailingVisible`. */ + trailing?: ReactNode + trailingVisible?: boolean +} + +/** + * Shared row chrome for everything in the composer status stack — status items + * (subagents, background) AND queued prompts. Fixed height, a leading glyph + * slot, flexible content, and a trailing actions slot that reveals on hover. + * Hover background matches the session sidebar. Consumers fill the three slots; + * they never re-implement the row container. + */ +export function StatusRow({ + children, + className, + leading, + onActivate, + trailing, + trailingVisible = false +}: StatusRowProps) { + return ( +
{ + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + onActivate() + } + } + : undefined + } + role={onActivate ? 'button' : undefined} + tabIndex={onActivate ? 0 : undefined} + > + {leading !== undefined && ( + {leading} + )} +
{children}
+ {trailing && ( +
+ {trailing} +
+ )} +
+ ) +} diff --git a/apps/desktop/src/components/chat/status-section.tsx b/apps/desktop/src/components/chat/status-section.tsx new file mode 100644 index 00000000000..161cc6f6a69 --- /dev/null +++ b/apps/desktop/src/components/chat/status-section.tsx @@ -0,0 +1,42 @@ +import { type ReactNode, useState } from 'react' + +import { DisclosureCaret } from '@/components/ui/disclosure-caret' + +interface StatusSectionProps { + /** Optional right-aligned actions (text links / micro buttons). Pass + * `Button` with `size="micro"` + `variant="text"` or `"link"`. */ + accessory?: ReactNode + children: ReactNode + defaultCollapsed?: boolean + /** Optional glyph between the caret and the label (e.g. a `Codicon`). */ + icon?: ReactNode + label: ReactNode +} + +/** + * One collapsible group inside the composer status stack. Pure chrome — header + * (caret + label) + body — styled to match the queue exactly so every status + * (queue, subagents, background) reads as one piece. The stack supplies the + * outer card and the dividers between groups; this owns only its own collapse. + */ +export function StatusSection({ accessory, children, defaultCollapsed = true, icon, label }: StatusSectionProps) { + const [collapsed, setCollapsed] = useState(defaultCollapsed) + + return ( +
+
+ + {accessory &&
{accessory}
} +
+ {!collapsed &&
{children}
} +
+ ) +} diff --git a/apps/desktop/src/components/chat/terminal-output.tsx b/apps/desktop/src/components/chat/terminal-output.tsx new file mode 100644 index 00000000000..946ec2386be --- /dev/null +++ b/apps/desktop/src/components/chat/terminal-output.tsx @@ -0,0 +1,50 @@ +import { useEffect, useLayoutEffect, useRef } from 'react' + +import { cn } from '@/lib/utils' + +interface TerminalOutputProps { + className?: string + text: string +} + +const NEAR_BOTTOM_PX = 24 + +/** + * Tiny read-only terminal viewer: monospace, non-wrapping (long lines scroll + * horizontally), vertical scroll past `max-h`. Jumps to the bottom on mount, + * then tails — sticking to the bottom as `text` grows, but only when the user + * is already near the bottom so scrolling up to read earlier output isn't + * interrupted. + * + * Self-contained so any surface (status rows, tool calls, inspectors) can drop + * in a stdout/stderr box without re-implementing the scroll logic. + */ +export function TerminalOutput({ className, text }: TerminalOutputProps) { + const ref = useRef(null) + + // On open: jump straight to the latest output (no animation, before paint). + useLayoutEffect(() => { + const el = ref.current + + if (el) { + el.scrollTop = el.scrollHeight + } + }, []) + + // On growth: tail only when already pinned near the bottom. + useEffect(() => { + const el = ref.current + + if (el && el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_PX) { + el.scrollTop = el.scrollHeight + } + }, [text]) + + return ( +
+
+        {text}
+      
+
+ ) +} diff --git a/apps/desktop/src/components/chat/zoomable-image.tsx b/apps/desktop/src/components/chat/zoomable-image.tsx index c73068050ea..243f9f3415a 100644 --- a/apps/desktop/src/components/chat/zoomable-image.tsx +++ b/apps/desktop/src/components/chat/zoomable-image.tsx @@ -3,55 +3,17 @@ import { type ComponentProps, useState } from 'react' import { Dialog, DialogContent } from '@/components/ui/dialog' +import { useImageDownload } from '@/hooks/use-image-download' import { useI18n } from '@/i18n' import { Download } from '@/lib/icons' import { cn } from '@/lib/utils' -import { notify, notifyError } from '@/store/notifications' - -function imageFilename(src?: string): string { - if (!src) { - return 'image' - } - - try { - const { pathname } = new URL(src, window.location.href) - - return pathname.split('/').filter(Boolean).pop() || 'image' - } catch { - return src.split(/[\\/]/).filter(Boolean).pop() || 'image' - } -} - -function isMissingIpcHandler(error: unknown): boolean { - const message = error instanceof Error ? error.message : typeof error === 'string' ? error : '' - - return message.includes("No handler registered for 'hermes:saveImageFromUrl'") -} - -async function startBrowserDownload(src: string) { - const response = await fetch(src) - - if (!response.ok) { - throw new Error(`Could not fetch image: ${response.status}`) - } - - const blobUrl = URL.createObjectURL(await response.blob()) - const link = document.createElement('a') - link.href = blobUrl - link.download = imageFilename(src) - link.rel = 'noopener noreferrer' - document.body.appendChild(link) - link.click() - link.remove() - window.setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000) -} export interface ZoomableImageProps extends ComponentProps<'img'> { containerClassName?: string slot?: string } -interface ImageActionCopy { +export interface ImageActionCopy { downloadImage: string savingImage: string } @@ -59,70 +21,10 @@ interface ImageActionCopy { export function ZoomableImage({ className, containerClassName, src, alt, slot, ...props }: ZoomableImageProps) { const { t } = useI18n() const copy = t.desktop - const [saving, setSaving] = useState(false) + const { download, saving } = useImageDownload(src) const [lightboxOpen, setLightboxOpen] = useState(false) const canOpen = Boolean(src) - async function handleDownload() { - if (!src || saving) { - return - } - - setSaving(true) - - try { - if (window.hermesDesktop?.saveImageFromUrl) { - const saved = await window.hermesDesktop.saveImageFromUrl(src) - - if (saved) { - notify({ kind: 'success', title: copy.imageSaved, message: imageFilename(src) }) - } - - return - } - - await startBrowserDownload(src) - } catch (error) { - if (isMissingIpcHandler(error)) { - try { - await startBrowserDownload(src) - notify({ - kind: 'info', - title: copy.downloadStarted, - message: copy.restartToUseSaveImage - }) - } catch (fallbackError) { - notifyError(fallbackError, copy.restartToSaveImages) - } - - return - } - - notifyError(error, copy.imageDownloadFailed) - } finally { - setSaving(false) - } - } - - const lightbox = src ? ( - - -
- {alt setLightboxOpen(false)} - src={src} - /> - -
-
-
- ) : null - return ( <> {alt - {src && } + {src && ( + + )} - {lightbox} + {src && ( + + )} ) } -function ImageActionButton({ +export function ImageLightbox({ + alt, copy, onClick, + onOpenChange, + open, saving, - variant + src }: { + alt?: string + copy: ImageActionCopy + onClick: () => void + onOpenChange: (open: boolean) => void + open: boolean + saving: boolean + src: string +}) { + return ( + + +
+ {alt onOpenChange(false)} + src={src} + /> + +
+
+
+ ) +} + +export function ImageActionButton({ + className, + copy, + onClick, + saving +}: { + className?: string copy: ImageActionCopy onClick: () => void saving: boolean - variant: 'inline' | 'lightbox' }) { return (