diff --git a/.github/actions/detect-changes/action.yml b/.github/actions/detect-changes/action.yml index 967e566878..b16500346f 100644 --- a/.github/actions/detect-changes/action.yml +++ b/.github/actions/detect-changes/action.yml @@ -24,6 +24,12 @@ outputs: docker_meta: description: Docker setup and meta files have changed. value: ${{ steps.classify.outputs.docker_meta }} + docker: + description: Files included in the docker image have changed. + value: ${{ steps.classify.outputs.docker }} + nix: + description: Run `nix flake check` (flake inputs, or any product Python change). + value: ${{ steps.classify.outputs.nix }} site: description: Build the Docusaurus docs site. value: ${{ steps.classify.outputs.site }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yaml similarity index 100% rename from .github/workflows/ci.yml rename to .github/workflows/ci.yaml diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index a39c4150da..8c259fe872 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -52,14 +52,14 @@ jobs: - name: Decide whether to build id: gate env: - # python_prod (not python): the image copies installed code, never - # tests/, so tests-only PRs skip the build. - PYTHON_PROD: ${{ steps.classify.outputs.python_prod }} - FRONTEND: ${{ steps.classify.outputs.frontend }} - DOCKER_META: ${{ steps.classify.outputs.docker_meta }} + # The docker lane derives from python_prod (not python: the image + # copies installed code, never tests/, so tests-only PRs skip the + # build), frontend and docker_meta. classify_changes.py owns the + # formula so this gate and the nix lane cannot drift apart. + DOCKER: ${{ steps.classify.outputs.docker }} run: | set -euo pipefail - if [ "$PYTHON_PROD" = "true" ] || [ "$FRONTEND" = "true" ] || [ "$DOCKER_META" = "true" ]; then + if [ "$DOCKER" = "true" ]; then echo "build=true" >> "$GITHUB_OUTPUT" else echo "build=false" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/label-rerun.yml b/.github/workflows/label-rerun.yml index fa98c3917f..6eb1a4445e 100644 --- a/.github/workflows/label-rerun.yml +++ b/.github/workflows/label-rerun.yml @@ -44,7 +44,7 @@ jobs: RUN_INFO=$(gh run list \ --repo "$REPO" \ --commit "$HEAD_SHA" \ - --workflow ci.yml \ + --workflow ci.yaml \ --limit 1 \ --json databaseId,status \ --jq '.[0] | "\(.databaseId) \(.status)"' 2>/dev/null || true) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml new file mode 100644 index 0000000000..23627cbc9f --- /dev/null +++ b/.github/workflows/nix.yml @@ -0,0 +1,117 @@ +name: Nix flake check + +# Builds every output of the flake: the package, the devShell, and the 21 +# checks under nix/checks.nix — module evaluation, option parity, .env +# assembly, service argv, and the rest. +# +# This workflow owns its triggers and ci.yml does not call it, for the reason +# docker.yml gives: a reusable-workflow call holds the caller run in progress +# for the full build, and GitHub refuses `gh run rerun` on a run that is still +# in progress. One slow advisory job in the CI lane blocks every rerun of the +# fast required jobs beside it. A separate run reruns and cancels on its own. + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +# PR runs collapse to the newest commit. A push to main is never cancelled: +# each one saves the store cache that later PRs restore from, so cancelling a +# merge would leave the next PR to build from nothing. +concurrency: + group: nix-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + # A `paths:` filter cannot gate this workflow correctly. The flake packages + # the product, and nine of the checks then run the built binary, so a change + # to hermes_cli/ alone can fail `nix flake check` without touching one file + # under nix/. The `nix` lane therefore follows python_prod as well as the + # flake inputs. On push the classifier fails open and every lane is true. + detect: + name: Detect affected areas + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + nix: ${{ steps.classify.outputs.nix }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Detect affected areas + id: classify + uses: ./.github/actions/detect-changes + with: + github-token: ${{ github.token }} + + flake-check: + name: nix flake check + needs: [detect] + if: needs.detect.outputs.nix == 'true' + # The build compiles the package and its whole dependency closure, so this + # is minutes and not seconds when the cache misses. + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install Nix + uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + with: + extra_nix_config: | + experimental-features = nix-command flakes + # A store path that does not substitute is a cache miss and not a + # build failure. Build it here instead. + fallback = true + # Each source archive is fetched one time in a run, and not one + # time for each evaluation. + tarball-ttl = 3600 + + # Restores /nix/store from the GitHub Actions cache. The store holds the + # whole dependency closure, so a hit turns a build of several minutes + # into a short evaluation. + # + # The Magic Nix Cache is not an option here. Its free tier ended in + # February 2025 with the GitHub cache API that it was built on. This + # action uses the current API and needs no account and no secret. + - name: Restore and save the Nix store + uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7 + with: + # The closure changes when the flake inputs change or when the + # dependencies of the project change. The key hashes both, so an + # edit to the source alone keeps the hit. + primary-key: nix-${{ runner.os }}-${{ hashFiles('flake.lock', 'nix/**', 'pyproject.toml', 'uv.lock') }} + # On a miss, restore the newest store for this runner. Most of the + # closure — Python, node, each transitive library — survives a bump + # of the lockfile, so an old store still removes most of the work. + restore-prefixes-first-match: nix-${{ runner.os }}- + + # Save from main only. A cache that a PR writes is visible to that + # PR alone and never to another branch, so a save there spends the + # 10 GB quota of the repository and helps no later run. A PR still + # restores: it reads the cache that the merge to main wrote. This is + # the same rule that docker.yml applies to `cache-to`. + save: ${{ github.event_name != 'pull_request' }} + + # Collect garbage before the save, so the store stays inside the + # 10 GB quota of the repository. Without a limit the store grows at + # each merge until GitHub removes the entry, and the next PR then + # gets nothing. This number is the size of the store and not the + # size of the compressed archive. + gc-max-store-size-linux: 5G + + # Delete the caches that this key replaces. GitHub removes caches by + # least recent use across the whole repository, so a Nix store that + # is never purged pushes out the caches of the other workflows. + purge: true + purge-prefixes: nix-${{ runner.os }}- + purge-created: 0 + purge-primary-key: never + + - name: nix flake check + # --print-build-logs: a check that fails then prints the assertion + # that failed, and not only the derivation that failed to build. + run: nix flake check --print-build-logs diff --git a/AGENTS.md b/AGENTS.md index b8a22cf7eb..93705624e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1342,6 +1342,47 @@ while the agent is blocked (e.g. approval prompts) MUST bypass BOTH guards and be dispatched inline, not via `_process_message_background()` (which races session lifecycle). +### Streaming delivery contract (stream-is-the-message adapters) — duplicate-final class +Adapters with `draft_stream_is_message = True` (relay Slack native streaming) +keep ONE cumulative native stream per turn; the stream IS the final message. +Four invariants, each learned from a live duplicate-final incident (NS-658 +canary ledger, hermes#85796 / gateway-gateway#210). Violating any of them +re-creates a duplicate or a frozen stream: + +1. **Draft frames must be prefix-stable.** The connector computes append-only + deltas: frame N must be a string prefix of frame N+1. NEVER mutate draft + frames per-tick — no fence-closing (`ensure_closed_code_fences`), no cursor + suffix, no segment-state resets at tool boundaries, no mrkdwn conversion. + Any non-prefix frame triggers a whole-snapshot re-append on the platform + ("stacked copies"). The finalize path may still transform the real final. +2. **The consumer declares the final; the adapter never guesses.** + `finish(final_text)` carries the completed `final_response` (verifier + footer, completion explainer included) as the authoritative finalize + payload. New post-stream response augmentation MUST ride this payload — + if it mutates `final_response` after the stream sealed, it re-opens the + #11 bug (`delivered_final_matches` mismatch → corrective duplicate send). +3. **Interim sends must carry `_interim_send` metadata.** Any consumer-side + `adapter.send()` that is NOT the turn-final (commentary, segment-tail + flushes) must set `metadata["_interim_send"] = True`, or the relay + adapter's seal-interception will seal the live stream with interim text. + Seal-interception exists at BOTH egress doors (`send()` AND + `send_for_platform()`); a new egress door needs the same two checks. +4. **Reconcile by edit, never by plain send.** Any lane that delivers a final + beside an already-sealed stream (queued follow-ups, media-accompanied + finals, future lanes) must first try `edit_message` on the consumer's + `message_id`; plain `send()` is the fallback only when no editable message + exists. A sealed native stream is a regular message — `chat.update` on it + works (live-verified). + +Contract tests: `tests/gateway/test_stream_final_contract.py` (all four +invariants, mutation-checked). Slack streaming API ground truth (live-probed, +also encoded in connector comments/tests): `chat.*Stream` speaks STANDARD +markdown, not mrkdwn; `stopStream.markdown_text` APPENDS (never replaces); +`startStream`/`stopStream` are rate-limit Tier 2 (~20/min). + +Guard style note: check `draft_stream_is_message` with `is True` — MagicMock +adapters in older tests auto-create truthy attributes. + ### Squash merges from stale branches silently revert recent fixes Before squash-merging a PR, ensure the branch is up to date with `main` (`git fetch origin main && git reset --hard origin/main` in the worktree, diff --git a/CONTRIBUTING.es.md b/CONTRIBUTING.es.md index 78c80113c6..0ef0023da0 100644 --- a/CONTRIBUTING.es.md +++ b/CONTRIBUTING.es.md @@ -582,7 +582,7 @@ test(tools): añadir tests unitarios para file_operations ## Reportar Issues - Usa [GitHub Issues](https://github.com/NousResearch/hermes-agent/issues) -- Incluye: SO, versión de Python, versión de Hermes (`hermes version`), traza de error completa +- Incluye: SO, versión de Python, versión de Hermes (`hermes --version`), traza de error completa - Incluye pasos para reproducir - Verifica los issues existentes antes de crear duplicados - Para vulnerabilidades de seguridad, por favor reporta de forma privada diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bb157cc8f8..61bacafbd8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -973,7 +973,7 @@ test(tools): add unit tests for file_operations ## Reporting Issues - Use [GitHub Issues](https://github.com/NousResearch/hermes-agent/issues) -- Include: OS, Python version, Hermes version (`hermes version`), full error traceback +- Include: OS, Python version, Hermes version (`hermes --version`), full error traceback - Include steps to reproduce - Check existing issues before creating duplicates - For security vulnerabilities, please report privately diff --git a/SECURITY.es.md b/SECURITY.es.md index 086656d4f7..1328c11609 100644 --- a/SECURITY.es.md +++ b/SECURITY.es.md @@ -16,7 +16,7 @@ Un informe útil incluye: - Una descripción concisa y evaluación de severidad. - El componente afectado, identificado por ruta de archivo y rango de líneas (ej. `path/to/file.py:120-145`). -- Detalles del entorno (`hermes version`, SHA del commit, SO, versión de Python). +- Detalles del entorno (`hermes --version`, SHA del commit, SO, versión de Python). - Una reproducción contra `main` o el último release. - Una declaración de qué límite de confianza del §2 se cruza. diff --git a/SECURITY.md b/SECURITY.md index 7f5fd42db7..cea2a9a2e8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -16,7 +16,7 @@ A useful report includes: - A concise description and severity assessment. - The affected component, identified by file path and line range (e.g. `path/to/file.py:120-145`). -- Environment details (`hermes version`, commit SHA, OS, Python +- Environment details (`hermes --version`, commit SHA, OS, Python version). - A reproduction against `main` or the latest release. - A statement of which trust boundary in §2 is crossed. diff --git a/agent/agent_init.py b/agent/agent_init.py index 67fc38bf3f..b839b35576 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -490,6 +490,25 @@ def _merge_custom_provider_extra_body(agent, custom_providers: List[Dict[str, An agent.request_overrides = overrides +def _normalize_run_budget_seconds(value) -> Optional[float]: + """Normalize a wall-clock run budget value to a positive float or None. + + None / absent / non-numeric / non-positive all resolve to ``None`` + (feature off) so a malformed config value can never activate the + deadline machinery, only leave it dormant. ``bool`` is rejected because + YAML ``true`` would otherwise become a 1-second budget. + """ + if value is None or isinstance(value, bool): + return None + try: + seconds = float(value) + except (TypeError, ValueError): + return None + if seconds != seconds or seconds <= 0: # NaN or non-positive + return None + return seconds + + def init_agent( agent, base_url: str = None, @@ -527,8 +546,10 @@ def init_agent( clarify_callback: callable = None, read_terminal_callback: callable = None, read_preview_callback: callable = None, + drive_preview_callback: callable = None, read_window_below_callback: callable = None, setup_mcp_callback: callable = None, + tour_callback: callable = None, step_callback: callable = None, stream_delta_callback: callable = None, interim_assistant_callback: callable = None, @@ -559,6 +580,7 @@ def init_agent( session_db=None, parent_session_id: str = None, iteration_budget: "IterationBudget" = None, + run_budget_seconds: Optional[float] = None, fallback_model: Dict[str, Any] = None, credential_pool=None, checkpoints_enabled: bool = False, @@ -822,8 +844,10 @@ def init_agent( agent.clarify_callback = clarify_callback agent.read_terminal_callback = read_terminal_callback agent.read_preview_callback = read_preview_callback + agent.drive_preview_callback = drive_preview_callback agent.read_window_below_callback = read_window_below_callback agent.setup_mcp_callback = setup_mcp_callback + agent.tour_callback = tour_callback agent.step_callback = step_callback agent.stream_delta_callback = stream_delta_callback agent.interim_assistant_callback = interim_assistant_callback @@ -911,6 +935,10 @@ def init_agent( # Model response configuration agent.max_tokens = max_tokens # None = use model default agent.reasoning_config = reasoning_config # None = use default (medium for OpenRouter) + # Per-provider reasoning_content echo opt-in (see _reasoning_echo_opt_in). + # Read once at init; switch_model / try_activate_fallback / restore + # keep it in sync with the active provider. + agent._reasoning_echo_flag = agent._read_reasoning_echo_from_config() agent.service_tier = service_tier agent.request_overrides = dict(request_overrides or {}) agent.prefill_messages = prefill_messages or [] # Prefilled conversation turns @@ -966,6 +994,17 @@ def init_agent( agent._budget_exhausted_injected = False agent._budget_grace_call = False + # Optional wall-clock run budget (seconds per run_conversation turn). + # Explicit constructor arg wins; else resolved from config.yaml + # (agent.run_budget_seconds) further below. None = feature fully off: + # no clock reads, no injection, no stale-timeout capping. + agent.run_budget_seconds = _normalize_run_budget_seconds(run_budget_seconds) + # Wall-clock start of the CURRENT run_conversation turn. Set by + # turn_context.prepare_turn when a run budget is active; None otherwise. + agent._run_budget_started_at = None + # One-shot latch for the 80% wrap-up notice (reset each turn). + agent._run_budget_wrapup_injected = False + # Activity tracking — updated on each API call, tool execution, and # stream chunk. Used by the gateway timeout handler to report what the # agent was doing when it was killed, and by the "still working" @@ -1890,6 +1929,20 @@ def init_agent( _agent_section = {} agent._tool_use_enforcement = _agent_section.get("tool_use_enforcement", "auto") + # Execution-discipline guidance gate: "auto" (default — matches + # EXECUTION_GUIDANCE_MODELS), true (always), false (never), or list of + # model-name substrings. Independent of tool_use_enforcement — see + # agent/system_prompt.py for the injection gate. + agent._execution_guidance = _agent_section.get("execution_guidance", "auto") + + # Wall-clock run budget from config (agent.run_budget_seconds) — only + # consulted when the constructor arg was not given. Absent/None/invalid + # keeps the feature fully off (zero behavior change in the default path). + if agent.run_budget_seconds is None: + agent.run_budget_seconds = _normalize_run_budget_seconds( + _agent_section.get("run_budget_seconds") + ) + # Empty-response retry guard config (NS-503): additive # ``agent.empty_response_guard`` subsection. Resolution is tolerant — # a malformed section falls back to the schema defaults (guard on, @@ -1906,6 +1959,11 @@ def init_agent( # conversation loop's intent-ack block. agent._intent_ack_continuation = _agent_section.get("intent_ack_continuation", "auto") + # Runtime anti-stall guards (identical-call loop-breaker notice on tool + # results + continue-intent extension of the empty-response recovery). + # Single boolean gate, default True. Notice-only — never blocks a call. + agent._stall_guards = bool(_agent_section.get("stall_guards", True)) + # Universal task-completion guidance toggle. Default True. Surfaced # as a separate flag from tool_use_enforcement because the guidance # applies to ALL models, not just the model families enforcement @@ -2949,6 +3007,7 @@ def init_agent( "client_kwargs": dict(agent._client_kwargs), "use_prompt_caching": agent._use_prompt_caching, "use_native_cache_layout": agent._use_native_cache_layout, + "reasoning_echo_flag": getattr(agent, "_reasoning_echo_flag", False), # Context engine state that _try_activate_fallback() overwrites. # Use getattr for model/base_url/api_key/provider since plugin # engines may not have these (they're ContextCompressor-specific). diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 96f7bb821a..2744c1b72d 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -99,7 +99,7 @@ def _ra(): AGENT_RUNTIME_POST_HOOK_TOOL_NAMES = frozenset( - {"todo", "session_search", "memory", "clarify", "read_terminal", "read_preview", "read_window_below", "setup_mcp", "delegate_task"} + {"todo", "session_search", "memory", "clarify", "read_terminal", "read_preview", "drive_preview", "annotate_preview", "read_window_below", "setup_mcp", "tour", "delegate_task"} ) @@ -1349,6 +1349,7 @@ def try_recover_primary_transport( if hasattr(agent, "_transport_cache"): agent._transport_cache.clear() agent.api_key = rt["api_key"] + agent._reasoning_echo_flag = rt.get("reasoning_echo_flag", False) if agent.api_mode == "anthropic_messages": from agent.anthropic_adapter import build_anthropic_client @@ -1579,6 +1580,7 @@ def restore_primary_runtime(agent) -> bool: if hasattr(agent, "_transport_cache"): agent._transport_cache.clear() agent.api_key = rt["api_key"] + agent._reasoning_echo_flag = rt.get("reasoning_echo_flag", False) agent._client_kwargs = dict(rt["client_kwargs"]) agent._use_prompt_caching = rt["use_prompt_caching"] # Default to native layout when the restored snapshot predates the @@ -2652,6 +2654,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo "_anthropic_base_url", "_is_anthropic_oauth", "_config_context_length", + "_reasoning_echo_flag", ) } # _client_kwargs is a dict — snapshot a shallow copy so mutating the @@ -2685,6 +2688,9 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo agent.model = new_model agent.provider = new_provider agent.requested_provider = new_provider + # Re-read reasoning_echo from config so the flag reflects the new + # primary model's setting (see _reasoning_echo_opt_in). + agent._reasoning_echo_flag = agent._read_reasoning_echo_from_config() # Use the new base_url when provided. When it's empty AND the # provider is actually changing, do NOT fall back to the current # (old provider's) URL — that silently pairs the new provider label @@ -2970,6 +2976,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo "use_prompt_caching": agent._use_prompt_caching, "use_native_cache_layout": agent._use_native_cache_layout, "reasoning_config": dict(agent.reasoning_config) if getattr(agent, "reasoning_config", None) else None, + "reasoning_echo_flag": getattr(agent, "_reasoning_echo_flag", False), "compressor_model": getattr(_cc, "model", agent.model) if _cc else agent.model, "compressor_base_url": getattr(_cc, "base_url", agent.base_url) if _cc else agent.base_url, "compressor_api_key": getattr(_cc, "api_key", "") if _cc else "", @@ -3200,6 +3207,7 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i question=next_args.get("question", ""), choices=next_args.get("choices"), multi_select=next_args.get("multi_select", False), + questions=next_args.get("questions"), callback=agent.clarify_callback, ), next_args, @@ -3226,6 +3234,37 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i ), next_args, ) + elif function_name == "drive_preview": + def _execute(next_args: dict) -> Any: + from tools.drive_preview_tool import drive_preview_tool as _drive_preview_tool + return _finish_agent_tool( + _drive_preview_tool( + action=next_args.get("action", ""), + ref=next_args.get("ref"), + selector=next_args.get("selector"), + text=next_args.get("text"), + key=next_args.get("key"), + submit=next_args.get("submit"), + amount=next_args.get("amount"), + to=next_args.get("to"), + limit=next_args.get("max"), + callback=getattr(agent, "drive_preview_callback", None), + ), + next_args, + ) + elif function_name == "annotate_preview": + def _execute(next_args: dict) -> Any: + from tools.annotate_preview_tool import annotate_preview_tool as _annotate_preview_tool + return _finish_agent_tool( + _annotate_preview_tool( + action=next_args.get("action", "add"), + ref=next_args.get("ref"), + selector=next_args.get("selector"), + label=next_args.get("label"), + callback=getattr(agent, "drive_preview_callback", None), + ), + next_args, + ) elif function_name == "read_window_below": def _execute(next_args: dict) -> Any: from tools.read_window_tool import read_window_below_tool as _read_window_below_tool @@ -3235,6 +3274,23 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i ), next_args, ) + elif function_name == "tour": + def _execute(next_args: dict) -> Any: + from tools.tour_tool import tour_tool as _tour_tool + return _finish_agent_tool( + _tour_tool( + action=next_args.get("action", ""), + surface=next_args.get("surface"), + selector=next_args.get("selector"), + title=next_args.get("title"), + text=next_args.get("text"), + side=next_args.get("side"), + steps=next_args.get("steps"), + step_index=next_args.get("step_index"), + callback=getattr(agent, "tour_callback", None), + ), + next_args, + ) elif function_name == "setup_mcp": def _execute(next_args: dict) -> Any: from tools.setup_mcp_tool import setup_mcp_tool as _setup_mcp_tool @@ -3838,6 +3894,37 @@ def looks_like_codex_intermediate_ack( return user_targets_workspace or assistant_targets_workspace +# Conservative "trailing continue-intent" detector for the said-continue-but- +# stopped stall guard (agent.stall_guards). Matches only when the message TAIL +# announces an immediate next action ("Let me now…", "I will now…", +# "Next, I…"), which is the observed stall shape: the model narrates the next +# step and then ends the turn with no tool call. Kept deliberately narrow so +# ordinary answers that merely contain "I will" mid-sentence never trip it. +_TRAILING_CONTINUE_INTENT_RE = re.compile( + r"(?:\blet me now\b|\bi(?:['\u2019])?ll now\b|\bi will now\b" + r"|\bnow i(?:['\u2019]ll| will)\b|\bnext[,:] i\b)" + r"[^.!?\n]{0,100}[.:\u2026]?\s*$", + re.IGNORECASE, +) + +# Content longer than this is a substantive reply, not a dangling ack. +_TRAILING_CONTINUE_INTENT_MAX_CHARS = 400 + + +def trailing_continue_intent(text: str) -> bool: + """Whether ``text`` is a short reply ENDING on an announced next action. + + Used by the stall-guard extension of the intent-ack continuation path in + ``agent.conversation_loop``: when a turn is about to end with this shape + (no tool calls, short content, trailing intent), the loop re-prompts via + the existing bounded continuation mechanism instead of stopping. + """ + t = (text or "").strip() + if not t or len(t) > _TRAILING_CONTINUE_INTENT_MAX_CHARS: + return False + return bool(_TRAILING_CONTINUE_INTENT_RE.search(t[-160:])) + + def intent_ack_continuation_mode(agent) -> str: """Classify the resolved intent-ack continuation mode for this turn. diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index d1a513c7ec..b4796b2da6 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1551,10 +1551,16 @@ class _CodexCompletionsAdapter: # Codex backend, which rejects e.g. {"effort": null} # with a 400. effort = reasoning_cfg.get("effort") or "medium" - # Codex backend rejects "minimal"; clamp to "low" to - # match the main-agent Codex transport behavior. - if effort == "minimal": - effort = "low" + # Same declared vocabulary + shared clamp as the main + # Codex transport (agent.reasoning_effort): per-model — + # "max" is gpt-5.6-only, "minimal"/"ultra" always + # rejected (live-verified, #68365). + from agent.reasoning_effort import ( + clamp_effort, + codex_supported_efforts, + ) + + effort = clamp_effort(effort, codex_supported_efforts(model)) resp_kwargs["reasoning"] = { "effort": effort, "summary": "auto", @@ -1956,6 +1962,39 @@ class AsyncCodexAuxiliaryClient: self._real_client = sync_wrapper._real_client +def _translate_anthropic_response_format( + anthropic_kwargs: Dict[str, Any], response_format: Any, +) -> None: + """Merge an OpenAI response format into Anthropic ``output_config``.""" + if not isinstance(response_format, dict): + return + + format_type = response_format.get("type") + if format_type == "json_schema": + json_schema = response_format.get("json_schema") + if not isinstance(json_schema, dict) or "schema" not in json_schema: + return + native_format = { + "type": "json_schema", + "schema": json_schema["schema"], + } + elif format_type == "json_object": + # Anthropic SDK 0.87.0 exposes only JSONOutputFormatParam, whose + # required type is ``json_schema``; it has no schema-less JSON mode. + native_format = { + "type": "json_schema", + "schema": {"type": "object"}, + } + else: + return + + output_config = anthropic_kwargs.get("output_config") + if not isinstance(output_config, dict): + output_config = {} + anthropic_kwargs["output_config"] = output_config + output_config["format"] = native_format + + class _AnthropicCompletionsAdapter: """OpenAI-client-compatible adapter for Anthropic Messages API.""" @@ -2056,18 +2095,38 @@ class _AnthropicCompletionsAdapter: # form is the documented Anthropic SDK passthrough for non-standard # request body keys; merge on top of whatever build_anthropic_kwargs # already produced (e.g. fast-mode ``speed``) so call-time settings - # survive. Two exclusions: + # survive. Three exclusions: # - ``reasoning``: the OpenAI-shaped config dict is TRANSLATED into # the native ``thinking`` field above (build_anthropic_kwargs); # forwarding the raw field alongside would double-specify # reasoning and 400 on strict gateways. + # - ``response_format``: the OpenAI structured-output shape is + # TRANSLATED into top-level ``output_config.format`` below; + # forwarding the raw field 400s on strict Anthropic gateways. # - ``_``-prefixed keys: private Hermes plumbing (_reasoning_config # et al.), never wire fields. caller_extra_body = kwargs.get("extra_body") + # A top-level ``response_format`` kwarg (the OpenAI SDK's documented + # call shape) must get the same translation as the extra_body form. + # The adapter builds the Messages body from a fixed allow-list of + # kwargs, so before this an unrecognized top-level kwarg was dropped + # on the floor: the request succeeded but the schema contract + # silently became prompt compliance (#85626 review, point 2). When + # both shapes are present, the extra_body form wins — it is the shape + # every in-tree caller uses. + top_level_response_format = kwargs.get("response_format") + if top_level_response_format is not None: + _translate_anthropic_response_format( + anthropic_kwargs, top_level_response_format, + ) if caller_extra_body and isinstance(caller_extra_body, dict): + _translate_anthropic_response_format( + anthropic_kwargs, caller_extra_body.get("response_format"), + ) passthrough = { k: v for k, v in caller_extra_body.items() - if k != "reasoning" and not str(k).startswith("_") + if k not in {"reasoning", "response_format"} + and not str(k).startswith("_") } if passthrough: existing = anthropic_kwargs.get("extra_body") or {} @@ -4315,6 +4374,71 @@ def _is_unsupported_temperature_error(exc: Exception) -> bool: return _is_unsupported_parameter_error(exc, "temperature") +def _is_structured_output_rejection(exc: Exception) -> bool: + """Detect provider 400s that reject the structured-output request field. + + One predicate covers the field on both wires, because both come from the + same caller-supplied ``response_format``: + + - OpenAI wire: the provider rejects ``response_format`` itself. vLLM + gateways translate the field into ``guided_grammar`` and fail when the + grammar backend is absent (``compile_grammar_error: No module named + 'xgrammar'``, #82816). Other endpoints answer ``This response_format + type is unavailable now``. + - Anthropic wire: the adapter translates ``response_format`` into + ``output_config.format``. Gateways that predate structured outputs + (the documented case is the ``bedrock-mantle`` Messages endpoint) + reject that field: ``output_config: Extra inputs are not permitted``. + + Callers tolerate an unconstrained reply — the title prompt demands bare + JSON and ``_extract_title_text`` has a loose-JSON fallback — so the right + reaction is one retry without the field, not a hard failure. + """ + status = getattr(exc, "status_code", None) + if status is not None and status not in {400, 422}: + return False + err_lower = str(exc).lower() + # vLLM grammar-backend failures name the translated parameter, not ours. + if "guided_grammar" in err_lower or "xgrammar" in err_lower or ( + "compile_grammar_error" in err_lower + ): + return True + if "extra inputs are not permitted" in err_lower and ( + "response_format" in err_lower or "output_config" in err_lower + ): + return True + if "response_format" in err_lower and "unavailable" in err_lower: + return True + return ( + _is_unsupported_parameter_error(exc, "response_format") + or _is_unsupported_parameter_error(exc, "output_config") + ) + + +def _without_structured_output_format(kwargs: dict) -> Optional[dict]: + """Copy *kwargs* without any ``response_format`` request field. + + Removes the top-level kwarg and the ``extra_body`` entry. Returns None + when the kwargs carry no such field, so call sites do not retry a + request that the removal did not change. + """ + changed = False + retry_kwargs = dict(kwargs) + if retry_kwargs.pop("response_format", None) is not None: + changed = True + extra_body = retry_kwargs.get("extra_body") + if isinstance(extra_body, dict) and "response_format" in extra_body: + remaining = { + k: v for k, v in extra_body.items() if k != "response_format" + } + if remaining: + retry_kwargs["extra_body"] = remaining + else: + retry_kwargs.pop("extra_body", None) + changed = True + return retry_kwargs if changed else None + + def _is_model_not_found_error(exc: Exception) -> bool: """Detect "the requested model doesn't exist" errors (404 / invalid model). @@ -9488,6 +9612,39 @@ def _call_llm_impl( first_err = retry_err kwargs = retry_kwargs + if _is_structured_output_rejection(first_err): + retry_kwargs = _without_structured_output_format(kwargs) + if retry_kwargs is not None: + logger.info( + "Auxiliary %s: provider rejected the structured-output " + "format field; retrying once without it (schema " + "enforcement degrades to prompt compliance): %s", + task or "call", first_err, + ) + try: + return _validate_llm_response( + _relay_sync_completion( + client, + retry_kwargs, + provider=resolved_provider, + api_mode=resolved_api_mode, + ), task) + except Exception as retry_err: + # Same contract as the temperature rung: fall through to + # the max_tokens / payment / auth chains below with the + # stripped kwargs; re-raise anything those chains do not + # handle. + if not ( + _is_payment_error(retry_err) + or _is_connection_error(retry_err) + or _is_auth_error(retry_err) + or "max_tokens" in str(retry_err) + or "unsupported_parameter" in str(retry_err) + ): + raise + first_err = retry_err + kwargs = retry_kwargs + err_str = str(first_err) # ZAI vision models (glm-4v-flash etc.) return error code 1210 # ("API 调用参数有误") when max_tokens is passed on multimodal @@ -10200,6 +10357,40 @@ async def _async_call_llm_impl( first_err = retry_err kwargs = retry_kwargs + if _is_structured_output_rejection(first_err): + retry_kwargs = _without_structured_output_format(kwargs) + if retry_kwargs is not None: + logger.info( + "Auxiliary %s (async): provider rejected the " + "structured-output format field; retrying once without " + "it (schema enforcement degrades to prompt " + "compliance): %s", + task or "call", first_err, + ) + try: + return _validate_llm_response( + await _relay_async_completion( + client, + retry_kwargs, + provider=resolved_provider, + api_mode=resolved_api_mode, + ), task) + except Exception as retry_err: + # Same contract as the temperature rung: fall through to + # the max_tokens / payment / auth chains below with the + # stripped kwargs; re-raise anything those chains do not + # handle. + if not ( + _is_payment_error(retry_err) + or _is_connection_error(retry_err) + or _is_auth_error(retry_err) + or "max_tokens" in str(retry_err) + or "unsupported_parameter" in str(retry_err) + ): + raise + first_err = retry_err + kwargs = retry_kwargs + err_str = str(first_err) # ZAI vision models (glm-4v-flash etc.) return error code 1210 # ("API 调用参数有误") when max_tokens is passed on multimodal diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 4805362731..7af486dc5b 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -2652,6 +2652,10 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool agent.requested_provider = fb_provider agent.base_url = fb_base_url agent.api_mode = fb_api_mode + # Per-provider reasoning_content echo opt-in (see _reasoning_echo_opt_in). + # Read from the fallback entry so the flag travels with the active + # provider; restore_primary_runtime will revert it from the snapshot. + agent._reasoning_echo_flag = bool(fb.get("reasoning_echo", False)) if hasattr(agent, "_transport_cache"): agent._transport_cache.clear() agent._fallback_activated = True diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py index f01d042b53..6c8a811dd4 100644 --- a/agent/codex_runtime.py +++ b/agent/codex_runtime.py @@ -1305,6 +1305,62 @@ def _consume_codex_event_stream( return final +def _sanitize_consumer_codex_request( + agent: Any, + request: dict[str, Any], +) -> dict[str, Any]: + """Drop fields the ChatGPT OAuth Codex endpoint does not accept. + + This guard intentionally lives at the final wire boundary, after Relay or + other request middleware has had a chance to transform the request. The + normal transport builder already omits ``prompt_cache_retention`` for this + endpoint, but a late mutation must not be allowed to turn a valid tool + follow-up into a non-retryable HTTP 400. + + Explicit ``request_overrides`` are subject to the same endpoint contract: + unsupported retention is dropped with a warning instead of being sent and + rejected by the provider. The check covers both the top-level kwarg and a + nested ``extra_body`` entry — the OpenAI SDK merges ``extra_body`` into + the outgoing JSON body, so either shape reaches the endpoint. + """ + sanitized = dict(request) + # Resolved defensively on purpose: run_codex_stream is also driven with + # lightweight stand-in agents that carry only the attributes a given path + # needs (see tests/agent/test_codex_request_transport_diagnostics.py), so a + # bare agent._is_codex_backend() here would raise AttributeError on them. + backend_predicate = getattr(agent, "_is_codex_backend", None) + is_consumer_codex = ( + bool(backend_predicate()) if callable(backend_predicate) else False + ) + if not is_consumer_codex: + return sanitized + dropped_from: list[str] = [] + if "prompt_cache_retention" in sanitized: + sanitized.pop("prompt_cache_retention") + dropped_from.append("top-level") + # The OpenAI SDK merges ``extra_body`` into the outgoing JSON body, so a + # nested ``extra_body.prompt_cache_retention`` reaches the endpoint just + # like the top-level field would. Copy before editing — the caller's + # mapping must not be mutated — and drop the mapping when it empties. + extra_body = sanitized.get("extra_body") + if isinstance(extra_body, dict) and "prompt_cache_retention" in extra_body: + extra_body = dict(extra_body) + extra_body.pop("prompt_cache_retention") + if extra_body: + sanitized["extra_body"] = extra_body + else: + sanitized.pop("extra_body") + dropped_from.append("extra_body") + if dropped_from: + logger.warning( + "Dropped unsupported prompt_cache_retention at consumer Codex " + "wire boundary (model=%s, via %s).", + sanitized.get("model", getattr(agent, "model", "unknown")), + ", ".join(dropped_from), + ) + return sanitized + + def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta=None): """Execute one streaming Responses API request and return the final response. @@ -1347,7 +1403,10 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta writer_token = {"value": None} def _open_codex_stream(next_api_kwargs: dict[str, Any]): - stream_kwargs = dict(next_api_kwargs) + stream_kwargs = _sanitize_consumer_codex_request( + agent, + next_api_kwargs, + ) stream_kwargs["stream"] = True return active_client.responses.create(**stream_kwargs) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index d66bee1cad..4c48794fb2 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -350,6 +350,147 @@ _SUMMARY_END_MARKER = ( _MERGED_PRIOR_CONTEXT_HEADER = "[PRIOR CONTEXT — for reference only; not a new message]" _MERGED_SUMMARY_DELIMITER = "[END OF PRIOR CONTEXT — COMPACTION SUMMARY BELOW]" +_SALVAGE_SUMMARY_MAX_CHARS = 8_000 +_SALVAGE_KEEP_RECENT_TOOLS = 2 + + +def _looks_like_compaction_summary(msg: Dict[str, Any], content: str) -> bool: + # Only cap a standalone handoff. Merged carriers preserve a real tail ask + # in the same content string; truncating those could delete live user text. + if not content.rstrip().endswith(_SUMMARY_END_MARKER): + return False + if content.startswith(_MERGED_PRIOR_CONTEXT_HEADER): + return False + # Content heuristics alone must never authorize mutating a live turn. + # Compressor-generated summaries carry this private marker; ordinary + # user input — and live assistant replies or kept tool bodies that + # merely quote a summary header/marker — do not. Tool messages are + # handled exclusively by the stub/keep-recent pass, never the cap. + if msg.get("role") == "tool": + return False + if ( + msg.get("role") in ("user", "assistant") + and not msg.get(COMPRESSED_SUMMARY_METADATA_KEY) + ): + return False + head = content[:280] + return ( + bool(msg.get(COMPRESSED_SUMMARY_METADATA_KEY)) + or "CONTEXT COMPACTION" in head + or "[CONTEXT COMPACTION]" in head + or "Conversation Summary" in head + ) + + +def _salvage_reduce_todo_snapshot(out: List[Dict[str, Any]]) -> None: + """Last-resort shrink: reduce or drop the synthetic todo snapshot. + + The snapshot is the only in-transcript todo re-injection at a compaction + boundary, and since 7a16840add the pruned-skill reload notice is coupled + into the same string — so it is only touched when the cheaper shrink ops + could not get under budget. When the snapshot carries a reload notice, + keep just the notice (the coupling must survive salvage); otherwise drop + the row entirely. + """ + from agent.conversation_compression import _PRUNED_SKILL_RELOAD_NOTICE_HEADER + + for i in range(len(out) - 1, -1, -1): + msg = out[i] + if not isinstance(msg, dict): + continue + if msg.get("_todo_snapshot_synthetic") and msg.get("role") == "user": + content = msg.get("content") + notice_idx = ( + content.find(_PRUNED_SKILL_RELOAD_NOTICE_HEADER) + if isinstance(content, str) + else -1 + ) + if isinstance(content, str) and notice_idx >= 0: + msg["content"] = content[notice_idx:] + else: + del out[i] + return + + +def salvage_grown_transcript( + original: List[Dict[str, Any]], + candidate: List[Dict[str, Any]], + budget: Optional[int] = None, +) -> Optional[List[Dict[str, Any]]]: + """Mechanically shrink a compression candidate, or return ``None``. + + Already-compacted middles can be summarized slightly larger while retained + tool bodies, stale reasoning, or a synthetic todo snapshot tip the final + candidate over the input size. Work on copies and admit the salvage only + when the same rough estimator proves it is strictly smaller than the input. + + Shrink order is cheapest-information-loss first: stale reasoning keys and + codex replay sidecars, then old tool bodies, then an oversized summary cap. + The synthetic todo snapshot (which carries the pruned-skill reload notice, + see ``_salvage_reduce_todo_snapshot``) is only reduced as a LAST resort + when everything else still leaves the candidate at or over budget. + """ + if not candidate or not original: + return None + if budget is None: + budget = estimate_messages_tokens_rough(original) + if budget <= 0: + return None + + out: List[Dict[str, Any]] = [] + tool_indices: List[int] = [] + last_assistant_idx = -1 + for msg in candidate: + if not isinstance(msg, dict): + out.append(msg) + continue + copied = dict(msg) + out.append(copied) + role = copied.get("role") + if role == "tool": + tool_indices.append(len(out) - 1) + elif role == "assistant": + last_assistant_idx = len(out) - 1 + + salvage_reasoning_keys = _NEWEST_TURN_ONLY_BUDGET_KEYS + ("reasoning_details",) + keep_tools = set(tool_indices[-_SALVAGE_KEEP_RECENT_TOOLS:]) + for index, msg in enumerate(out): + if not isinstance(msg, dict): + continue + if msg.get("role") == "assistant" and index != last_assistant_idx: + for key in salvage_reasoning_keys: + msg.pop(key, None) + if msg.get("role") == "tool" and index not in keep_tools: + content = msg.get("content") + if isinstance(content, str) and len(content) > _PRUNE_MIN_CHARS: + msg["content"] = _PRUNED_TOOL_PLACEHOLDER + content = msg.get("content") + if ( + isinstance(content, str) + and len(content) > _SALVAGE_SUMMARY_MAX_CHARS + and _looks_like_compaction_summary(msg, content) + ): + msg["content"] = ( + content[:_SALVAGE_SUMMARY_MAX_CHARS].rstrip() + + "\n…[summary truncated so compaction can shrink]\n\n" + + _SUMMARY_END_MARKER + ) + # Heavier codex replay sidecars (encrypted reasoning blobs) — reuse the + # proven prune with its last-user-turn safety boundary (#71058). + _prune_stale_reasoning_replay(out) + + if estimate_messages_tokens_rough(out) >= budget: + _salvage_reduce_todo_snapshot(out) + + if not any( + isinstance(message, dict) and message.get("role") == "user" + for message in out + ): + return None + if estimate_messages_tokens_rough(out) < budget: + return out + return None + # 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 @@ -1885,6 +2026,7 @@ class ContextCompressor(ContextEngine): self._cooldown_persist_failed = False self._last_summary_error = None self._last_compress_aborted = False + self._last_compress_refused_would_grow = False self.last_real_prompt_tokens = 0 self.last_compression_rough_tokens = 0 self.last_rough_tokens_when_real_prompt_fit = 0 @@ -2167,6 +2309,7 @@ class ContextCompressor(ContextEngine): self._summary_failure_cooldown_until = 0.0 self._cooldown_persist_failed = False self._last_compress_aborted = False + self._last_compress_refused_would_grow = False self._context_probed = False self._context_probe_persistable = False self.last_real_prompt_tokens = 0 @@ -2371,6 +2514,31 @@ class ContextCompressor(ContextEngine): self._ineffective_compression_count = count self._persist_ineffective_compression_count() + def record_rejected_compaction(self) -> None: + """Record one compaction whose result was REJECTED before committing. + + The anti-growth guard in the commit layer (conversation_compression) + discards a candidate that would grow the transcript and keeps the + original. Without recording the attempt, the anti-thrash breaker + never sees a strike, so automatic compression retries the SAME + unchanged transcript on every turn — same summary request, same + refusal, same user-facing warning (#88568). This counts one + ineffective strike (persisted, so the normal >= 2 latch and its + recovery window apply) WITHOUT arming post-compaction real-usage + verification — nothing was committed, so there is no new compaction + to verify — and without touching the fallback-summary streak (no + summary was accepted). + """ + self._record_ineffective_compression_verdict( + self._ineffective_compression_count + 1 + ) + if not self.quiet_mode: + logger.warning( + "Compaction rejected before commit (would grow the " + "transcript); ineffective_compression_count=%d", + self._ineffective_compression_count, + ) + def record_completed_compaction( self, *, used_fallback: bool = False, feasibility_skip: bool = False, ) -> None: @@ -6927,6 +7095,7 @@ This compaction should PRIORITISE preserving all information related to the focu self._last_aux_model_failure_error = None self._last_aux_model_failure_model = None self._last_compress_aborted = False + self._last_compress_refused_would_grow = False self._last_compression_made_progress = False # NOTE: do NOT reset _last_summary_auth_failure or # _last_summary_network_failure here. These flags are set by diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index 97a9e3dbf2..a2b2643bf3 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -3386,6 +3386,27 @@ def compress_context( # transcript stays untouched and durable. _rough_in = estimate_messages_tokens_rough(messages) _rough_out = estimate_messages_tokens_rough(compressed) + if _rough_out > _rough_in: + # Todo refresh and user-turn anchoring happen after the + # compressor's own size check, so they can tip a break-even + # candidate over. Give it one mechanical salvage pass. + from agent.context_compressor import salvage_grown_transcript + + _salvaged = salvage_grown_transcript( + messages, compressed, budget=_rough_in + ) + if _salvaged is not None: + _salv_est = estimate_messages_tokens_rough(_salvaged) + if _salv_est < _rough_in: + logger.info( + "Compression salvage recovered a shrinking " + "transcript (session=%s, ~%s -> ~%s tokens)", + agent.session_id or "none", + f"{_rough_in:,}", + f"{_salv_est:,}", + ) + compressed = _salvaged + _rough_out = _salv_est if _rough_out > _rough_in: logger.warning( "Compression refused: compressed transcript would be " @@ -3395,6 +3416,17 @@ def compress_context( f"{_rough_in:,}", f"{_rough_out:,}", ) + # Flag the refusal on the compressor state so manual + # /compress feedback can report it honestly. Without this, + # the CLI compared the returned list against its pre-call + # snapshot, saw a difference (durable-snapshot adoption can + # legitimately change the count), and printed + # "✅ Compressed: 8 → 14 messages" directly under the + # refusal warning (Aug 2026 full-surface CLI QA sweep). + try: + agent.context_compressor._last_compress_refused_would_grow = True + except Exception: + pass try: agent._emit_warning( "⚠️ Compression refused: the generated summary " @@ -3414,6 +3446,20 @@ def compress_context( split_status="aborted", failure_class="would_grow", ) + # Record the rejected attempt as an ineffective + # compaction strike so the anti-thrash breaker latches + # after the normal threshold. Without this, the unchanged + # transcript stays over the compression threshold and + # automatic compression retries the identical summary + # request on every turn (#88568). Manual /compress keeps + # bypassing the latch (force=True skips the guards). + try: + agent.context_compressor.record_rejected_compaction() + except Exception: + logger.debug( + "could not record rejected-compaction strike", + exc_info=True, + ) _release_lock() return messages, _existing_sp diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index ea9e1ce264..951b874701 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -107,6 +107,64 @@ logger = logging.getLogger(__name__) _INTERRUPT_SCAFFOLD_MARKER = "[This response was interrupted by a user correction.]" +# One-time wrap-up notice appended when a wall-clock run budget crosses its +# 80% threshold (agent.run_budget_seconds / --run-budget). Mirrors the Codex +# CLI budget wrap-up template: stop new work, deliver from current state. +RUN_BUDGET_WRAPUP_NOTICE = ( + "[SYSTEM NOTICE — run time budget nearly exhausted] " + "Run time budget nearly exhausted. Stop new discovery/verification work " + "now. Produce the required final deliverable (answer/JSON/summary) from " + "the state you already have, completing only mandatory writes." +) + + +def _maybe_inject_run_budget_wrapup(agent: Any, messages: List[Dict[str, Any]]) -> bool: + """Inject the one-time wall-clock wrap-up notice when past 80% of budget. + + Cache-safe delivery: the notice is appended to the NEWEST ``role:"tool"`` + message (the same channel /steer uses) — no synthetic user message is + inserted mid-loop and no past context is rewritten, so role alternation + and the prompt-cache prefix survive. Latches ``_run_budget_wrapup_injected`` + only on a successful append, so a first iteration without tool results + retries on the next iteration. Returns True when the notice was injected. + + Dormant unless ``agent.run_budget_seconds`` is set AND the turn stamped + ``_run_budget_started_at`` (see ``turn_context.prepare_conversation_turn``). + """ + budget = getattr(agent, "run_budget_seconds", None) + if not budget: + return False + if getattr(agent, "_run_budget_wrapup_injected", False): + return False + started = getattr(agent, "_run_budget_started_at", None) + if not started: + return False + if (time.time() - started) < 0.8 * float(budget): + return False + for i in range(len(messages) - 1, -1, -1): + msg = messages[i] + if isinstance(msg, dict) and msg.get("role") == "tool": + existing = msg.get("content", "") + if isinstance(existing, str): + msg["content"] = existing + f"\n\n{RUN_BUDGET_WRAPUP_NOTICE}" + else: + # Multimodal content blocks — append a text block. + try: + blocks = list(existing) if existing else [] + blocks.append({"type": "text", "text": RUN_BUDGET_WRAPUP_NOTICE}) + msg["content"] = blocks + except Exception: + return False + agent._run_budget_wrapup_injected = True + logger.info( + "Run budget wrap-up notice injected (budget=%.0fs, elapsed=%.0fs)", + float(budget), + time.time() - started, + ) + return True + return False + + def _restore_user_after_reference_handoff( messages: List[Dict[str, Any]], user_message: Any ) -> bool: @@ -331,6 +389,19 @@ def _apply_active_turn_redirect(agent: Any, messages: List[Dict[str, Any]], text } if not visible: placeholder["display_kind"] = "hidden" + # Keep the transcript hidden and empty, but give the historical + # API projection a non-empty neutral assistant turn so the + # pre-call sanitizer (repair_empty_non_final_messages) does not + # re-heal this row on every later call (#88955). display_kind is + # stripped before sanitization, while api_content is projected + # back into content for historical assistant rows. Use the + # canonical neutral interruption placeholder, never + # _INTERRUPT_SCAFFOLD_MARKER: replaying the scaffold as assistant + # text made the model echo it and self-replicate ghost rows + # (#81841). + from agent.agent_runtime_helpers import _INTERRUPTED_PLACEHOLDER + + placeholder["api_content"] = _INTERRUPTED_PLACEHOLDER append_message(messages, placeholder) append_message( messages, @@ -2028,6 +2099,15 @@ def run_conversation( existing = getattr(agent, "_pending_steer", None) agent._pending_steer = (existing + "\n" + _pre_api_steer) if existing else _pre_api_steer + # ── Wall-clock run-budget wrap-up notice ─────────────────────── + # One-shot: when a run budget (agent.run_budget_seconds / + # --run-budget) is active and 80% of it has elapsed, ask the model + # to wrap up and deliver from the state it already has. Same + # cache-safe channel as /steer (appended to the newest tool + # result); dormant when no budget is set. + if getattr(agent, "run_budget_seconds", None): + _maybe_inject_run_budget_wrapup(agent, messages) + # Prepare messages for API call # If we have an ephemeral system prompt, prepend it to the messages # Note: Reasoning is embedded in content via tags for trajectory storage. @@ -2123,9 +2203,29 @@ def run_conversation( # from every outgoing copy so strict OpenAI-compatible backends # don't reject the request after a model switch or resumed typed # event row enters the live history. - api_msg.pop("display_kind", None) + _display_kind = api_msg.pop("display_kind", None) api_msg.pop("display_metadata", None) + # Legacy hidden redirect placeholders (#88955): rows persisted + # BEFORE the writer-side api_content stamp in + # _apply_active_turn_redirect are content="" with no sidecar. + # Once display_kind is stripped the pre-call sanitizer + # (repair_empty_non_final_messages) would re-heal such a row on + # every call forever, since the durable transcript is never + # mutated. Give the wire copy the same neutral payload here so + # old sessions converge too. Never the interrupt scaffold — + # replaying scaffold bytes as assistant text is #81841. + if ( + _display_kind == "hidden" + and api_msg.get("role") == "assistant" + and not _api_content + and not (api_msg.get("content") or "").strip() + and not api_msg.get("tool_calls") + ): + from agent.agent_runtime_helpers import _INTERRUPTED_PLACEHOLDER + + api_msg["content"] = _INTERRUPTED_PLACEHOLDER + # Durable row identity stamped by _rows_to_conversation so the # desktop can address a specific persisted message (reactions). # Bookkeeping, never a provider field — only the chat-completions @@ -2665,7 +2765,34 @@ def run_conversation( request_pressure_tokens, int(getattr(_compressor, "threshold_tokens", 0) or 0), ) - + elif not agent.compression_enabled and len(messages) > 1: + # Uncompressed session guard (#89297): compression is disabled, so + # nothing shrinks a growing session. Reuse the unconditionally + # computed request estimate (zero marginal cost — this site runs + # before every provider request, covering turn-start AND mid-turn + # tool-result growth) and surface a deduped, actionable warning + # when the request exceeds the model context window. The dedup is + # re-armed by the turn-context preflight once the session is back + # under the window (manual /compress works with compression + # disabled), so the guard warns again on a later re-overflow. + # context_compressor always exists (agent_init constructs it even + # when compression is disabled) and its context_length property + # hard-floors at a positive default — no metadata re-resolution + # needed here. + _ctx_len = getattr( + getattr(agent, "context_compressor", None), "context_length", None + ) + if ( + isinstance(_ctx_len, int) + and _ctx_len > 0 + and request_pressure_tokens > _ctx_len + ): + _warn_fn = getattr( + agent, "_warn_uncompressed_context_overflow", None + ) + if callable(_warn_fn): + _warn_fn(request_pressure_tokens, _ctx_len) + # Thinking spinner for quiet mode (animated during API call) thinking_spinner = None @@ -5204,6 +5331,21 @@ def run_conversation( FailoverReason.billing, FailoverReason.upstream_rate_limit, } + # Relay-wrapped output-cap errors: some gateways wrap an + # upstream "[400]: max_tokens (...) exceeds model's maximum + # output tokens (...)" as HTTP 429, which classifies as + # rate_limit. The failure is a deterministic request-shape + # problem — falling back to another provider (or burning + # generic retries) can't fix it, but the output-cap clamp + # below can, in one retry (#72281). Parse once here; the + # result gates both the eager-fallback exemption and the + # widened is_context_length_error entry, and is reused as + # available_out inside the handler. + _wrapped_output_cap_budget = ( + parse_available_output_tokens_from_error(error_msg) + if classified.reason == FailoverReason.rate_limit + else None + ) _is_transport_failure = classified.reason in { FailoverReason.timeout, FailoverReason.overloaded, @@ -5221,7 +5363,7 @@ def run_conversation( if _is_zai_coding_overload: max_retries = max(max_retries, zai_coding_overload_retry_ceiling()) _should_fallback = ( - is_rate_limited + (is_rate_limited and _wrapped_output_cap_budget is None) or (_is_transport_failure and retry_count >= 2) ) if _should_fallback and agent._fallback_index < len(agent._fallback_chain): @@ -5514,6 +5656,11 @@ def run_conversation( # server disconnect + large session pattern (#2153). is_context_length_error = ( classified.reason == FailoverReason.context_overflow + # Relay-wrapped output-cap 429s (parsed once above, where + # the eager-fallback exemption is gated) route into the + # output-cap clamp below instead of provider failover or + # generic retries (#72281). + or _wrapped_output_cap_budget is not None ) if is_context_length_error: @@ -7831,10 +7978,28 @@ def run_conversation( from agent.agent_runtime_helpers import ( intent_ack_continuation_mode, + trailing_continue_intent, ) _ack_mode = intent_ack_continuation_mode(agent) - if ( + # Said-continue-but-stopped guard (agent.stall_guards): the + # model ended the turn with no tool calls but its short reply + # TAILS with an announced next action ("Let me now…", + # "I will now…"). Unlike the intent-ack detector below, this + # fires mid-task too (after tool results), which is exactly + # where eval traces show the stall. It reuses the SAME bounded + # continuation path and counter (max 2 per turn), so the + # alternation-safe interim-assistant + user-nudge mechanism — + # not a new parallel one — carries the recovery. + _stall_continue_intent = ( + bool(getattr(agent, "_stall_guards", True)) + and agent.valid_tool_names + and codex_ack_continuations < 2 + and trailing_continue_intent( + agent._strip_think_blocks(final_response or "") + ) + ) + if _stall_continue_intent or ( _ack_mode != "off" and agent.valid_tool_names and codex_ack_continuations < 2 @@ -7845,6 +8010,12 @@ def run_conversation( require_workspace=(_ack_mode == "codex_only"), ) ): + if _stall_continue_intent: + logger.info( + "Stall guard: turn ending on trailing continue-" + "intent with no tool calls — re-prompting to act " + "(%d/2)", codex_ack_continuations + 1, + ) codex_ack_continuations += 1 interim_msg = agent._build_assistant_message(assistant_message, "incomplete") append_message(messages, interim_msg) diff --git a/agent/image_gen_registry.py b/agent/image_gen_registry.py index 82393e88db..6239bbe891 100644 --- a/agent/image_gen_registry.py +++ b/agent/image_gen_registry.py @@ -139,6 +139,18 @@ def get_active_provider() -> Optional[ImageGenProvider]: except Exception as exc: logger.debug("Could not read image_gen.provider from config: %s", exc) + # The managed "Nous Subscription" selection is serviced by the FAL + # plugin through the managed fal-queue gateway (the legacy FAL pipeline + # routes managed when the stored selection is "nous"). + if configured: + try: + from tools.tool_backend_helpers import NOUS_MANAGED_PROVIDER + + if configured.lower() == NOUS_MANAGED_PROVIDER: + configured = "fal" + except Exception: # pragma: no cover — helpers are in-repo + pass + with _lock: snapshot = dict(_providers) snapshot.update(_scoped_providers.get(hermes_home_key(), {})) diff --git a/agent/manual_compression_feedback.py b/agent/manual_compression_feedback.py index b2e12d6834..b37361e6e2 100644 --- a/agent/manual_compression_feedback.py +++ b/agent/manual_compression_feedback.py @@ -53,6 +53,11 @@ def summarize_manual_compression( compression_state is not None and getattr(compression_state, "_last_compress_aborted", False) is True ) + refused_would_grow = ( + compression_state is not None + and getattr(compression_state, "_last_compress_refused_would_grow", False) + is True + ) fallback_used = ( compression_state is not None and getattr(compression_state, "_last_summary_fallback_used", False) is True @@ -65,7 +70,12 @@ def summarize_manual_compression( if not isinstance(failure_reason, str) or not failure_reason.strip(): failure_reason = None - if aborted: + if refused_would_grow: + headline = ( + f"Compression refused (summary would grow the conversation): " + f"{before_count} messages preserved" + ) + elif aborted: headline = f"Compression aborted: {before_count} messages preserved" elif fallback_used: headline = ( @@ -78,6 +88,8 @@ def summarize_manual_compression( if noop and after_tokens == before_tokens: token_line = f"Approx request size: ~{before_tokens:,} tokens (unchanged)" + elif refused_would_grow: + token_line = f"Approx request size: ~{before_tokens:,} tokens (unchanged)" else: token_line = ( f"Approx request size: ~{before_tokens:,} → " @@ -85,7 +97,12 @@ def summarize_manual_compression( ) note = None - if aborted: + if refused_would_grow: + note = ( + "The generated summary was larger than what it would replace; " + "no messages were removed." + ) + elif aborted: note = "Summary generation failed; no messages were removed." elif fallback_used: dropped_count = getattr( @@ -113,6 +130,7 @@ def summarize_manual_compression( return { "noop": noop, "aborted": aborted, + "refused_would_grow": refused_would_grow, "fallback_used": fallback_used, "headline": headline, "token_line": token_line, diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 57168fc960..0bbbaea061 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -1659,10 +1659,28 @@ def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]: # The input itself fits — this is purely an output-cap error, so reduce # max_tokens and retry; do NOT compress. "range of max_tokens should be" in error_lower + ) or ( + # OpenAI-compatible relays may reject a request whose output cap exceeds + # the model's separate completion-token limit, e.g. + # "max_tokens (98304) exceeds model's maximum output tokens (65536)" + # This is independent of the input context window. + "exceeds model" in error_lower + and "maximum output tokens" in error_lower ) if not is_output_cap_error: return None + # Generic model-output-cap form: + # "max_tokens (98304) exceeds model's maximum output tokens (65536)" + _m_max_output = re.search( + r'exceeds model(?:\'s)? maximum output tokens\s*\(?\s*(\d+)\s*\)?', + error_lower, + ) + if _m_max_output: + _cap = int(_m_max_output.group(1)) + if _cap >= 1: + return _cap + # DashScope / Alibaba range form: "Range of max_tokens should be [1, 65536]". # The upper bound is the available output cap. _m_range = re.search( @@ -1726,11 +1744,28 @@ def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]: # Available output = window - input. When the input alone is at or over # the window this stays None, so the caller correctly falls through to # compression instead of futilely shrinking the output cap. + # + # Caveat: when max_tokens is the BINDING constraint, vLLM does not report + # the real prompt size at all. It back-computes a lower bound from the + # constraint itself -- "at least N input tokens" where + # N == window + 1 - requested_output -- so window - N is always exactly + # requested_output - 1. Subtracting the caller's safety margin then walks + # the cap down ~65 tokens per retry while the reported input walks up by + # the same amount, burning every compression attempt without ever fitting. + # Detect that degenerate case and halve the requested cap instead: it + # carries the same guarantee (strictly below what was rejected) and + # converges in one or two retries. _m_vllm_input = re.search( r'prompt contains (?:at least )?(\d+)\s*input tokens', error_lower ) if _m_ctx_tok and _m_vllm_input: _available = int(_m_ctx_tok.group(1)) - int(_m_vllm_input.group(1)) + _m_requested_out = re.search(r'requested (\d+)\s*output tokens', error_lower) + if 'at least' in error_lower and _m_requested_out: + _requested_out = int(_m_requested_out.group(1)) + if _available >= _requested_out - 1: + # The budget is derived from the constraint, not measured. + return max(1, _requested_out // 2) if _available >= 1: return _available @@ -1782,6 +1817,8 @@ def is_output_cap_error(error_msg: str) -> bool: or "should be" in error_lower # generic "max_tokens should be <= N" or "less than or equal" in error_lower or "must be" in error_lower + or ("exceeds model" in error_lower + and "maximum output tokens" in error_lower) ) if not output_cap_signal: return False diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 998e5606a0..4f26ffcf2a 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -191,6 +191,21 @@ MEMORY_GUIDANCE = ( "workflows belong in skills, not memory." ) +USER_PROFILE_GUIDANCE = ( + "You have a persistent user profile across sessions. Save durable facts about " + "the user with the memory tool (target='user'): name, role, preferences, " + "corrections, and communication style. The profile is injected into every turn, " + "so keep it compact and focused on facts that will still matter later.\n" + "The built-in memory notes store is disabled — write only to the user profile " + "(target='user'), never target='memory'.\n" + "Prioritize what reduces future user steering — the most valuable entry is one " + "that prevents the user from having to correct or remind you again.\n" + "Write entries as declarative facts, not instructions to yourself. " + "'User prefers concise responses' ✓ — 'Always respond concisely' ✗. " + "Imperative phrasing gets re-read as a directive in later sessions and can " + "cause repeated work or override the user's current request." +) + SESSION_SEARCH_GUIDANCE = ( "When the user references something from a past conversation or you suspect " "relevant cross-session context exists, use session_search to recall it before " @@ -358,6 +373,25 @@ TOOL_USE_ENFORCEMENT_GUIDANCE = ( # Add new patterns here when a model family needs explicit steering. TOOL_USE_ENFORCEMENT_MODELS = ("gpt", "codex", "gemini", "gemma", "grok", "glm", "qwen", "deepseek") +# Model name substrings whose sessions receive OPENAI_MODEL_EXECUTION_GUIDANCE +# (execution discipline: tool persistence, mandatory tool use for arithmetic, +# external-write read-back, count reconciliation, literal preservation, +# verification-gated completion) when agent.execution_guidance is "auto". +# +# gpt/codex/grok are the historical set; deepseek/kimi/qwen/glm/minimax/ +# mimo/mistral were added after Composio agentic-eval traces showed the same +# failure modes on those families (financial math in prose, no read-back after +# external writes, identifier "repair", completeness claims despite count +# mismatches). GLM's tool-calls-as-plain-text stall (#53847) and MiMo (#41874) +# are covered here too. Gemini/Gemma are excluded — they get the more specific +# GOOGLE_MODEL_OPERATIONAL_GUIDANCE block instead. Claude is excluded because +# it does not exhibit these failure modes; users can opt any model in via +# config.yaml `agent.execution_guidance: true` or a substring list. +EXECUTION_GUIDANCE_MODELS = ( + "gpt", "codex", "grok", + "deepseek", "kimi", "qwen", "glm", "minimax", "mimo", "mistral", +) + # Universal "finish the job" guidance — applied to ALL models, not gated # by model family. Addresses two cross-model failure modes: # 1. Stopping after a stub: writing a tiny file or running one command @@ -438,13 +472,22 @@ PARALLEL_TOOL_CALL_GUIDANCE = ( # without tool calls, suggests workarounds instead of using existing tools, # replies with plans/suggestions instead of executing). The body is # family-agnostic; the OPENAI_ prefix reflects origin, not exclusivity. +# +# As of the Composio agentic-eval follow-up, the block is no longer fenced to +# gpt/codex/grok: eval traces showed DeepSeek/Kimi doing financial math in +# prose, skipping read-back verification after external writes, "repairing" +# malformed identifiers, and claiming completeness despite count mismatches — +# exactly the failure modes this block targets. The injection gate lives in +# agent/system_prompt.py and is controlled by config.yaml +# ``agent.execution_guidance`` (auto/true/false/list); "auto" matches the +# EXECUTION_GUIDANCE_MODELS substring tuple below. OPENAI_MODEL_EXECUTION_GUIDANCE = ( "# Execution discipline\n" "\n" "- Use tools whenever they improve correctness, completeness, or grounding.\n" "- Do not stop early when another tool call would materially improve the result.\n" - "- If a tool returns empty or partial results, retry with a different query or " - "strategy before giving up.\n" + "- If a tool returns empty, partial, or suspiciously narrow results, retry " + "with a broader or different query or strategy before concluding.\n" "- Keep calling tools until: (1) the task is complete, AND (2) you have verified " "the result.\n" "\n" @@ -487,8 +530,30 @@ OPENAI_MODEL_EXECUTION_GUIDANCE = ( "- Formatting: does the output match the requested format or schema?\n" "- Safety: if the next step has side effects (file writes, commands, API calls), " "confirm scope before executing.\n" + "- Completion: 'done' means every named acceptance criterion is verified — " + "never a plausible subset. Completing your plan is not itself the answer; " + "the requested output must appear in your response.\n" "\n" "\n" + "\n" + "- After any state-changing write to an external system (API call, message " + "post, record update), verify the effect by reading back the exact target " + "before claiming success — a successful tool call is not a successful task. " + "Do NOT re-verify internal file edits a tool already confirmed.\n" + "- Declared totals in responses (total, reply_count, has_more, '...N more') " + "are hard assertions. If your enumerated count disagrees, re-fetch or parse " + "programmatically — never finalize on 'go with what I have'.\n" + "- When building write payloads, set fields explicitly rather than relying " + "on provider defaults that could contradict intent.\n" + "\n" + "\n" + "\n" + "- Preserve identifiers, commands, and values exactly as given — never " + "'repair' or normalize a token that fails a stated format. A successful " + "lookup does not validate a malformed source token; validate format first, " + "then look up.\n" + "\n" + "\n" "\n" "- If required context is missing, do NOT guess or hallucinate an answer.\n" "- Use the appropriate lookup tool when missing information is retrievable " diff --git a/agent/reasoning_effort.py b/agent/reasoning_effort.py new file mode 100644 index 0000000000..48b44a25ee --- /dev/null +++ b/agent/reasoning_effort.py @@ -0,0 +1,213 @@ +"""Canonical reasoning-effort vocabulary and wire clamping. + +Hermes' internal effort ladder (``hermes_constants.VALID_REASONING_EFFORTS`` +plus the ``none`` disable level) is wider than what any single provider wire +accepts. Historically every transport and provider profile hand-rolled its own +translation map, and the class of bugs that produced was constant: a new +internal level (``ultra``) leaking to a wire that rejects it with HTTP 400 +(#89503, #70058), or an unknown level being dropped to a weak default so the +strongest ask resolved *weaker* than an explicit ``high`` — a ladder +inversion (#74295, #87279). + +This module is the single source of truth both kinds of code use instead: + +- :data:`EFFORT_LADDER` — canonical low→high ordering. +- :func:`clamp_effort` — the one clamping policy: keep a supported level + verbatim, otherwise take the **nearest weaker** supported level (never + silently escalate cost above what was asked), and only when nothing weaker + exists take the weakest supported level (a provider whose minimum thinking + level is ``high`` serves ``high`` for a ``low`` ask — GLM-5.2's shape). +- Named wire-vocabulary constants for the common OpenAI-compatible surfaces, + so call sites declare *data* ("this route accepts these levels") rather + than logic. + +Rules for call sites: + +1. **Wire shape stays local.** Whether a route wants ``extra_body.reasoning``, + a top-level ``reasoning_effort`` string, or a ``thinking`` toggle is the + caller's business. Only the *vocabulary math* lives here. +2. **Unset stays unset.** ``clamp_effort`` translates an explicit request; it + does not invent one. When the user expressed no effort, prefer omitting + the field so the server default applies. +3. **Never patch a predicate.** When a provider rejects a level, fix its + declared supported set (data), never add another vendor-name special case + at the call site. +""" + +from __future__ import annotations + +import re +from typing import Optional, Sequence + +#: K3 slug detector — matches ``k3`` as a delimited token (``k3``, +#: ``k3-256k``, ``kimi-k3``, ``kimi-k3-cot``) without matching K2-era names +#: (``kimi-k2.6``). From #76427 by @ruizanthony. +_KIMI_K3_SLUG_RE = re.compile(r"(?:^|[^a-z0-9])k3(?:[^a-z0-9]|$)") + +# Canonical low→high ordering used for nearest-level clamping. Superset of +# hermes_constants.VALID_REASONING_EFFORTS ("none" included so an explicit +# disable can be clamped too when a provider publishes it as a level). +EFFORT_LADDER: tuple[str, ...] = ( + "none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", +) + +# ``ultra`` is Hermes-internal ladder vocabulary (the Codex product tier); no +# provider wire accepts it verbatim anywhere. Every declared wire set below +# therefore stops at ``max`` — ``ultra`` always clamps down. + +#: The widest OpenAI-compatible wire vocabulary (OpenRouter, Nous Portal): +#: exactly max|xhigh|high|medium|low|minimal|none. +OPENAI_COMPAT_WIRE_EFFORTS: tuple[str, ...] = ( + "none", "minimal", "low", "medium", "high", "xhigh", "max", +) + +#: OpenAI/Codex Responses backend — per-model vocabulary, live-verified +#: (Aug 2026): ``minimal`` is rejected by both generations (clamps to low); +#: ``max`` is gpt-5.6-only — gpt-5.5 rejects it with "Supported values are: +#: 'none', 'low', 'medium', 'high', 'xhigh'" (#68365's premise, confirmed). +CODEX_GPT56_EFFORTS: tuple[str, ...] = ( + "none", "low", "medium", "high", "xhigh", "max", +) +CODEX_LEGACY_EFFORTS: tuple[str, ...] = ( + "none", "low", "medium", "high", "xhigh", +) + + +def codex_supported_efforts(model: Optional[str]) -> tuple[str, ...]: + """Supported effort set for an OpenAI/Codex Responses model.""" + if "gpt-5.6" in (model or "").lower(): + return CODEX_GPT56_EFFORTS + return CODEX_LEGACY_EFFORTS + + +#: Backward-compat alias (pre-#68365-verification name). +CODEX_RESPONSES_EFFORTS: tuple[str, ...] = CODEX_GPT56_EFFORTS + +#: xAI Responses — Grok 4.6+ accepts xhigh; older Grok tops out at high. +XAI_GROK46_EFFORTS: tuple[str, ...] = ("low", "medium", "high", "xhigh") +XAI_LEGACY_EFFORTS: tuple[str, ...] = ("low", "medium", "high") + +#: Actual Computer relays (SGLang/vLLM): none/low/medium/high/max. +ACTUAL_RELAY_EFFORTS: tuple[str, ...] = ("none", "low", "medium", "high", "max") + +#: Moonshot/Kimi K3: low/high/max (server default high). +KIMI_K3_EFFORTS: tuple[str, ...] = ("low", "high", "max") +#: Moonshot/Kimi K2-era models: low/medium/high. +KIMI_K2_EFFORTS: tuple[str, ...] = ("low", "medium", "high") + +#: Tencent TokenHub: low/medium/high. +TOKENHUB_EFFORTS: tuple[str, ...] = ("low", "medium", "high") + +#: Kimi K3's vendor-documented translation quirks (platform.kimi.ai +#: thinking-model guide): ``high`` is K3's positional middle AND server +#: default, so ``medium`` rounds to it rather than down to ``low``; ``xhigh`` +#: rounds up to ``max`` (K3's top tier), matching the kimi-coding plugin. +KIMI_K3_OVERRIDES: dict[str, str] = {"medium": "high", "xhigh": "max"} + +#: GLM-5.2 native reasoning_effort knob: exactly two enabled levels, +#: ``high`` (its minimum thinking level) and ``max`` (per Z.AI/BigModel +#: docs). ``xhigh`` requests the top tier, not the floor. +GLM52_EFFORTS: tuple[str, ...] = ("high", "max") +GLM52_OVERRIDES: dict[str, str] = {"xhigh": "max"} + +#: DeepSeek V4 OpenAI-compat endpoint: low/medium/high/max; ``xhigh`` +#: requests the top tier (matches the shipped profile mapping). +DEEPSEEK_V4_EFFORTS: tuple[str, ...] = ("low", "medium", "high", "max") +DEEPSEEK_V4_OVERRIDES: dict[str, str] = {"xhigh": "max"} + +#: Ollama Cloud /v1/chat/completions: accepts {none, low, medium, high, max}; +#: rejects ``minimal`` with HTTP 400. ``xhigh`` requests the top tier. +OLLAMA_CLOUD_EFFORTS: tuple[str, ...] = ("none", "low", "medium", "high", "max") +OLLAMA_CLOUD_OVERRIDES: dict[str, str] = {"xhigh": "max"} + +#: Meta Model API (Muse): minimal..xhigh; rejects ``none``. +META_AI_EFFORTS: tuple[str, ...] = ("minimal", "low", "medium", "high", "xhigh") + +#: Upstage Solar Pro/Open: low/medium/high. +SOLAR_EFFORTS: tuple[str, ...] = ("low", "medium", "high") + + +def kimi_supported_efforts(model: Optional[str]) -> tuple[str, ...]: + """Supported effort set for a Moonshot/Kimi model slug. + + K3 is served as the bare slug ``k3``, plan variants like ``k3-256k``, + and the ``kimi-k3*`` aliases; its documented set is low/high/max. + Everything earlier speaks low/medium/high. Boundary-matched so K2-era + names (``kimi-k2.6``) never match (detection regex from #76427 by + @ruizanthony). + """ + m = (model or "").strip().lower().split("/")[-1] + if _KIMI_K3_SLUG_RE.search(m): + return KIMI_K3_EFFORTS + return KIMI_K2_EFFORTS + + +def clamp_effort( + effort: Optional[str], + supported: Optional[Sequence[str]], + overrides: Optional[dict[str, str]] = None, +) -> Optional[str]: + """Clamp a requested reasoning effort onto a wire's supported levels. + + ``overrides`` is an optional declared mapping consulted first, for routes + whose vendor documents a translation that differs from nearest-weaker + (Kimi K3 documents ``medium → high``: high is its positional middle and + server default). Overrides are data, not logic — a call site never adds + vendor ``if``\\ s around this function. + + Otherwise: returns the requested effort unchanged when it is supported, + when the supported set is unknown (``None``/empty), or when the effort + isn't a recognized ladder level (custom providers may use bespoke names — + pass through rather than guess). Otherwise returns the **nearest weaker** + supported level, so a clamp never silently escalates cost; when nothing + weaker exists, the weakest supported level is returned (the caller asked + for *some* thinking and the provider's floor is the closest honest match). + + The policy is monotonic: a stronger request never resolves to a weaker + wire level than a weaker request would. + """ + requested = str(effort or "").strip().lower() + if not requested or not supported: + return effort + supported_norm = [ + str(level).strip().lower() + for level in supported + if str(level).strip().lower() in EFFORT_LADDER + ] + if not supported_norm or requested in supported_norm: + return effort + if overrides: + mapped = overrides.get(requested) + if mapped in supported_norm: + return mapped + if requested not in EFFORT_LADDER: + return effort + # "none" disables reasoning — it is never a *degradation target* for an + # enabled ask (clamping "minimal" to "none" would silently switch + # thinking off). It still passes through verbatim when requested. + candidates = [level for level in supported_norm if level != "none"] + if not candidates: + return effort + requested_idx = EFFORT_LADDER.index(requested) + below = [ + level for level in candidates + if EFFORT_LADDER.index(level) < requested_idx + ] + if below: + return max(below, key=EFFORT_LADDER.index) + return min(candidates, key=EFFORT_LADDER.index) + + +def requested_effort(reasoning_config: Optional[dict]) -> Optional[str]: + """Extract the user's explicit effort from a reasoning config, or None. + + Returns ``None`` when the config is absent, malformed, carries no effort, + or reasoning is explicitly disabled — callers should then omit the wire + field entirely so the server default applies (rule 2 above). + """ + if not isinstance(reasoning_config, dict): + return None + if reasoning_config.get("enabled") is False: + return None + effort = str(reasoning_config.get("effort") or "").strip().lower() + return effort or None diff --git a/agent/relay_llm.py b/agent/relay_llm.py index ea969da43f..2d8c37a18f 100644 --- a/agent/relay_llm.py +++ b/agent/relay_llm.py @@ -434,6 +434,7 @@ class ManagedLlmStream(Iterator[Any]): self._stream: Any = None self._raw_stream_resource: Any = None self._closed = False + self._runtime_lease: relay_runtime.RelayOperationLease | None = None self._close_error: BaseException | None = None self._callback_error: BaseException | None = None self._logical: tuple[relay_runtime.RelayTurnContext, Any, str] | None = None @@ -573,7 +574,12 @@ class ManagedLlmStream(Iterator[Any]): self._callback_error = exc raise - loop = asyncio.new_event_loop() + self._runtime_lease = runtime.acquire_operation_lease() + try: + loop = asyncio.new_event_loop() + except BaseException: + self._release_runtime_lease() + raise self._loop = loop self._relay_observes_chunks = True try: @@ -613,10 +619,14 @@ class ManagedLlmStream(Iterator[Any]): model_name=self._logical_model_name, provider_name=self._logical_provider_name, response_model_name=self._logical_response_model_name, + operation_lease=self._runtime_lease, ) self._logical = None - loop.close() - self._loop = None + try: + loop.close() + finally: + self._loop = None + self._release_runtime_lease() raise def __iter__(self) -> "ManagedLlmStream": @@ -662,6 +672,7 @@ class ManagedLlmStream(Iterator[Any]): model_name=self._logical_model_name, provider_name=self._logical_provider_name, response_model_name=self._logical_response_model_name, + operation_lease=self._runtime_lease, ) self._logical = None self._close(logical_outcome="cancelled") @@ -719,8 +730,75 @@ class ManagedLlmStream(Iterator[Any]): self._stream = iter(pending) self._raw_stream_resource = None self._accept_chunk = None - if loop is not None: - close = getattr(relay_stream, "aclose", None) + try: + if loop is not None: + close = getattr(relay_stream, "aclose", None) + if callable(close): + + async def close_stream() -> None: + await close() + + try: + loop.run_until_complete(close_stream()) + except Exception: + logger.debug( + "Relay stream cleanup failed during provider fallback", + exc_info=True, + ) + loop.close() + if not self._defer_logical_completion: + _complete_logical( + self._logical, + outcome="success", + model_name=self._logical_model_name, + provider_name=self._logical_provider_name, + response_model_name=self._logical_response_model_name, + operation_lease=self._runtime_lease, + ) + self._logical = None + finally: + self._release_runtime_lease() + + def _close(self, *, logical_outcome: str) -> None: + if self._closed: + return + self._closed = True + self._prefetched_chunks.clear() + try: + loop = self._loop + self._loop = None + if loop is None: + resources = (self._stream, self._raw_stream_resource) + self._stream = None + self._raw_stream_resource = None + closed_ids: set[int] = set() + for resource in resources: + if resource is None or id(resource) in closed_ids: + continue + closed_ids.add(id(resource)) + close = getattr(resource, "close", None) + if callable(close): + try: + close() + except Exception as exc: + if self._close_error is None: + self._close_error = exc + logger.debug( + "Provider stream cleanup failed", + exc_info=True, + ) + if not self._defer_logical_completion: + _complete_logical( + self._logical, + outcome=logical_outcome, + model_name=self._logical_model_name, + provider_name=self._logical_provider_name, + response_model_name=self._logical_response_model_name, + operation_lease=self._runtime_lease, + ) + self._logical = None + return + close = getattr(self._stream, "aclose", None) if callable(close): async def close_stream() -> None: @@ -728,49 +806,9 @@ class ManagedLlmStream(Iterator[Any]): try: loop.run_until_complete(close_stream()) - except Exception: - logger.debug( - "Relay stream cleanup failed during provider fallback", - exc_info=True, - ) - loop.close() - if not self._defer_logical_completion: - _complete_logical( - self._logical, - outcome="success", - model_name=self._logical_model_name, - provider_name=self._logical_provider_name, - response_model_name=self._logical_response_model_name, - ) - self._logical = None - - def _close(self, *, logical_outcome: str) -> None: - if self._closed: - return - self._closed = True - self._prefetched_chunks.clear() - loop = self._loop - self._loop = None - if loop is None: - resources = (self._stream, self._raw_stream_resource) - self._stream = None - self._raw_stream_resource = None - closed_ids: set[int] = set() - for resource in resources: - if resource is None or id(resource) in closed_ids: - continue - closed_ids.add(id(resource)) - close = getattr(resource, "close", None) - if callable(close): - try: - close() - except Exception as exc: - if self._close_error is None: - self._close_error = exc - logger.debug( - "Provider stream cleanup failed", - exc_info=True, - ) + except Exception as exc: + if self._close_error is None: + self._close_error = exc if not self._defer_logical_completion: _complete_logical( self._logical, @@ -778,30 +816,18 @@ class ManagedLlmStream(Iterator[Any]): model_name=self._logical_model_name, provider_name=self._logical_provider_name, response_model_name=self._logical_response_model_name, + operation_lease=self._runtime_lease, ) self._logical = None - return - close = getattr(self._stream, "aclose", None) - if callable(close): + loop.close() + finally: + self._release_runtime_lease() - async def close_stream() -> None: - await close() - - try: - loop.run_until_complete(close_stream()) - except Exception as exc: - if self._close_error is None: - self._close_error = exc - if not self._defer_logical_completion: - _complete_logical( - self._logical, - outcome=logical_outcome, - model_name=self._logical_model_name, - provider_name=self._logical_provider_name, - response_model_name=self._logical_response_model_name, - ) - self._logical = None - loop.close() + def _release_runtime_lease(self) -> None: + lease = self._runtime_lease + self._runtime_lease = None + if lease is not None: + lease.release() def __del__(self) -> None: self._close(logical_outcome="cancelled") @@ -941,6 +967,7 @@ def _complete_logical( model_name: str | None = None, provider_name: str | None = None, response_model_name: str | None = None, + operation_lease: relay_runtime.RelayOperationLease | None = None, ) -> None: if logical is None: return @@ -960,7 +987,10 @@ def _complete_logical( output.update({"model": model_name, "provider": provider_name}) if response_model_name is not None: output["response_model"] = response_model_name - lease.host.run_in_session( + callback = lease.host.run_in_session + if operation_lease is not None: + callback = operation_lease.run_in_session + callback( lease.session, relay_runtime.pop_relay_scope, lease.host.relay, diff --git a/agent/relay_runtime.py b/agent/relay_runtime.py index 003213d87d..3dbbd397a0 100644 --- a/agent/relay_runtime.py +++ b/agent/relay_runtime.py @@ -8,13 +8,21 @@ import contextvars import importlib import inspect import logging +import os import threading +import tomllib import uuid from concurrent.futures import TimeoutError as FuturesTimeoutError from dataclasses import dataclass, field +from enum import Enum, auto +from pathlib import Path from typing import Any, Callable from hermes_constants import get_hermes_home +from hermes_cli.relay_plugin_cutover import ( + RELAY_PLUGINS_CONFIG_ENV, + configured_legacy_relay_env_vars, +) logger = logging.getLogger(__name__) @@ -24,6 +32,7 @@ LOGICAL_LLM_SCOPE = "hermes.logical_llm_call" RUNTIME_SCHEMA_KEY = "hermes.relay.schema_version" RUNTIME_SCHEMA_VERSION = "hermes.relay.runtime.v1" RUNTIME_INSTANCE_KEY = "hermes.relay.runtime_instance" +RELAY_PLUGINS_EXECUTION_CONSUMER = "hermes.nemo_relay.plugins" _PROFILE_KEY_CACHE: dict[str, str] = {} # Bound for native scope lifecycle operations (push/pop/flush) that gate @@ -128,6 +137,20 @@ def pop_relay_scope( return pop(handle, **kwargs) +class _RelayPluginConfigurationState(Enum): + """Process-wide result shared by every currently hosted profile.""" + + UNINITIALIZED = auto() + DISABLED = auto() + ACTIVE = auto() + FOREIGN = auto() + FAILED = auto() + + +class _RelayPluginConfigurationLoadError(RuntimeError): + """An explicitly selected Relay plugin configuration could not be loaded.""" + + @dataclass class RelaySession: """One isolated Relay scope stack owned by a Hermes session.""" @@ -199,8 +222,232 @@ def _reset_segments_config_for_tests() -> None: _SEGMENTS_CONFIG = None +class RelayOperationLease: + """Keep process-wide Relay plugins alive across a deferred operation.""" + + def __init__(self, runtime: "RelayRuntime") -> None: + self._lock = threading.Lock() + self._runtime: RelayRuntime | None = runtime + + def run_in_session( + self, + session: RelaySession, + callback: Callable[..., Any], + *args: Any, + **kwargs: Any, + ) -> Any: + """Run cleanup while this lease still owns the runtime lifetime.""" + with self._lock: + runtime = self._runtime + if runtime is None: + raise RuntimeError("Hermes Relay operation lease is released") + return runtime._run_in_session_untracked( + session, + callback, + *args, + **kwargs, + ) + + def release(self) -> None: + """Release this lease exactly once.""" + with self._lock: + runtime = self._runtime + self._runtime = None + if runtime is not None: + runtime._end_operation() + + +class _ProcessRelayPluginConfiguration: + """Own one Relay plugin configuration across profile-scoped hosts.""" + + def __init__(self) -> None: + self._lock = threading.RLock() + self._owners: set[int] = set() + self._state = _RelayPluginConfigurationState.UNINITIALIZED + self._active = False + self._relay: Any = None + self._activation: Any = None + + def acquire( + self, + owner: Any, + relay: Any, + ) -> _RelayPluginConfigurationState: + """Join the process configuration, initializing it for the first host.""" + owner_id = id(owner) + with self._lock: + if owner_id in self._owners: + return self._state + if self._owners: + self._owners.add(owner_id) + return self._state + if self._active and not self._clear_active(): + logger.warning( + "Hermes Relay plugin cleanup is still pending; refusing to " + "replace the process-global configuration" + ) + return self._remember( + owner_id, + _RelayPluginConfigurationState.FAILED, + ) + + try: + existing_report = relay.plugin.report() + except Exception: + logger.warning( + "Hermes could not determine whether a process-global Relay " + "plugin configuration is already active; refusing to replace it", + exc_info=True, + ) + return self._remember( + owner_id, + _RelayPluginConfigurationState.FAILED, + ) + if existing_report is not None: + logger.warning( + "A process-global Relay plugin configuration is already active " + "outside Hermes native ownership; leaving it unchanged and " + "disabling Hermes-managed Relay middleware for this process" + ) + return self._remember( + owner_id, + _RelayPluginConfigurationState.FOREIGN, + ) + + try: + configured_inputs = _configured_plugin_inputs(relay) + if configured_inputs is None: + return self._remember( + owner_id, + _RelayPluginConfigurationState.DISABLED, + ) + plugin_config, dynamic_plugins = configured_inputs + if dynamic_plugins: + try: + activation = _resolve_plugin_awaitable( + relay.plugin.initialize_with_dynamic_plugins( + plugin_config, + dynamic_plugins, + ) + ) + if activation is None: + raise RuntimeError( + "NeMo Relay dynamic plugin initialization " + "returned no activation handle" + ) + self._activation = activation + except Exception as exc: + raise RuntimeError( + "Hermes Relay dynamic plugin activation failed" + ) from exc + + if self._activation is None: + # Hermes only enters Relay's initialization path after an + # explicit opt-in. Relay currently owns any subsequent ambient + # layering; a future discovery=False API can make this exact. + _resolve_plugin_awaitable(relay.plugin.initialize(plugin_config)) + except Exception as exc: + self._activation = None + logger.warning( + "Hermes Relay plugin initialization failed: %s", + exc, + exc_info=True, + ) + return self._remember( + owner_id, + _RelayPluginConfigurationState.FAILED, + ) + + self._active = True + self._relay = relay + state = self._remember( + owner_id, + _RelayPluginConfigurationState.ACTIVE, + ) + logger.info( + "Relay plugins are active process-wide and apply to all profiles " + "hosted by this Hermes process." + ) + return state + + def _remember( + self, + owner_id: int, + state: _RelayPluginConfigurationState, + ) -> _RelayPluginConfigurationState: + """Retain one process decision for all concurrently hosted profiles.""" + self._owners.add(owner_id) + self._state = state + return state + + def release(self, owner: Any) -> None: + """Release one host and clear Relay after the final host exits.""" + owner_id = id(owner) + with self._lock: + if owner_id not in self._owners: + return + self._owners.remove(owner_id) + if self._owners: + return + if self._clear_active(): + self._state = _RelayPluginConfigurationState.UNINITIALIZED + + def reset_for_tests(self) -> None: + """Clear process-global state left by directly constructed test hosts.""" + with self._lock: + self._owners.clear() + if self._clear_active(): + self._state = _RelayPluginConfigurationState.UNINITIALIZED + + def retry_pending_cleanup(self) -> None: + """Retry a failed final cleanup without disrupting live owners.""" + with self._lock: + if not self._owners: + if self._clear_active(): + self._state = _RelayPluginConfigurationState.UNINITIALIZED + + def _clear_active(self) -> bool: + relay = self._relay + activation = self._activation + active = self._active + if not active or relay is None: + return True + try: + _flush_relay_subscribers(relay) + except Exception: + logger.warning( + "Hermes Relay plugin subscriber flush failed", + exc_info=True, + ) + return False + try: + if activation is not None: + close = getattr(activation, "close", None) + if not callable(close): + raise RuntimeError( + "NeMo Relay dynamic plugin activation has no close method" + ) + _resolve_plugin_awaitable(close()) + else: + _clear_relay_plugins(relay) + except Exception: + logger.warning( + "Hermes Relay plugin configuration cleanup failed", + exc_info=True, + ) + return False + self._active = False + self._relay = None + self._activation = None + return True + + +_PLUGIN_CONFIGURATION = _ProcessRelayPluginConfiguration() +atexit.register(_PLUGIN_CONFIGURATION.retry_pending_cleanup) + + class RelayRuntime: - """Own Relay session scopes independently of any exporter or plugin.""" + """Own Relay session scopes and optional process plugin configuration.""" def __init__(self, relay: Any = None, *, profile_key: str | None = None) -> None: self.relay = relay or _load_nemo_relay() @@ -210,8 +457,24 @@ class RelayRuntime: self._sessions: dict[str, RelaySession] = {} self._subagent_parents: dict[str, str] = {} self._subagent_parent_handles: dict[str, Any] = {} + self._closing = False + self._shutdown_started = False + self._shutdown_complete = threading.Event() + self._operations_idle = threading.Event() + self._operations_idle.set() + self._active_operations = 0 self._execution_consumers_lock = threading.RLock() self._execution_consumers: set[str] = set() + self._plugin_configuration_state = _PLUGIN_CONFIGURATION.acquire( + self, + self.relay, + ) + self._plugin_configuration_registered = True + if ( + self._plugin_configuration_state + is _RelayPluginConfigurationState.ACTIVE + ): + self.retain_managed_execution(RELAY_PLUGINS_EXECUTION_CONSUMER) self._shutdown_registered = True atexit.register(self.shutdown) @@ -244,6 +507,8 @@ class RelayRuntime: if not session_id: return None with self._sessions_lock: + if self._closing: + return None session = self._sessions.get(session_id) if session is None: parent_session_id = self._subagent_parents.get(session_id, "") @@ -404,6 +669,8 @@ class RelayRuntime: ): parent_handle = turn.handle with self._sessions_lock: + if self._closing: + return None self._subagent_parents[child_session_id] = parent_session_id if parent_handle is not None: self._subagent_parent_handles[child_session_id] = parent_handle @@ -425,6 +692,8 @@ class RelayRuntime: def get_session(self, session_id: str) -> RelaySession | None: """Return an active Hermes Relay session without creating one.""" with self._sessions_lock: + if self._closing: + return None session = self._sessions.get(str(session_id or "")) if session is None: return None @@ -458,6 +727,29 @@ class RelayRuntime: span, never the agent. The abandoned daemon worker cannot block process exit (tools.daemon_pool contract). """ + self._begin_operation() + try: + return self._run_in_session_untracked( + session, + callback, + *args, + allow_closing=allow_closing, + timeout=timeout, + **kwargs, + ) + finally: + self._end_operation() + + def _run_in_session_untracked( + self, + session: RelaySession, + callback: Callable[..., Any], + *args: Any, + allow_closing: bool = False, + timeout: float | None = None, + **kwargs: Any, + ) -> Any: + """Run inside a session whose host-level lifetime is already held.""" with session.lock: if session.closing and not allow_closing: raise RuntimeError("Hermes Relay session is closing") @@ -506,26 +798,49 @@ class RelayRuntime: **kwargs: Any, ) -> Any: """Create and await an operation inside the session's saved context.""" - with session.lock: - if session.closing and not allow_closing: - raise RuntimeError("Hermes Relay session is closing") - if session.context is None or session.handle is None: - raise RuntimeError("Hermes Relay session context is unavailable") - relay_context = session.context.copy() + self._begin_operation() + try: + with session.lock: + if session.closing and not allow_closing: + raise RuntimeError("Hermes Relay session is closing") + if session.context is None or session.handle is None: + raise RuntimeError("Hermes Relay session context is unavailable") + relay_context = session.context.copy() - context = contextvars.copy_context() - for variable, value in relay_context.items(): - context.run(variable.set, value) + context = contextvars.copy_context() + for variable, value in relay_context.items(): + context.run(variable.set, value) - async def invoke() -> Any: - self.relay.get_scope_stack() - result = callback(*args, **kwargs) - if inspect.isawaitable(result): - return await result - return result + async def invoke() -> Any: + self.relay.get_scope_stack() + result = callback(*args, **kwargs) + if inspect.isawaitable(result): + return await result + return result - task = context.run(asyncio.create_task, invoke()) - return await task + task = context.run(asyncio.create_task, invoke()) + return await task + finally: + self._end_operation() + + def _begin_operation(self) -> None: + """Admit one Relay call while keeping process plugins alive.""" + with self._sessions_lock: + if self._closing: + raise RuntimeError("Hermes Relay runtime is shutting down") + self._active_operations += 1 + self._operations_idle.clear() + + def _end_operation(self) -> None: + with self._sessions_lock: + self._active_operations -= 1 + if self._active_operations == 0: + self._operations_idle.set() + + def acquire_operation_lease(self) -> RelayOperationLease: + """Retain plugin lifetime for work that outlives one Relay await.""" + self._begin_operation() + return RelayOperationLease(self) def emit_mark( self, @@ -586,6 +901,7 @@ class RelayRuntime: allow_closing: bool = False, failure_label: str = "scope close failed", drain_limit: int = 32, + operation_already_held: bool = False, ) -> str | None: """Pop ``handle``, draining orphaned children in the same session context. @@ -702,7 +1018,12 @@ class RelayRuntime: error_holder["retry"] = retry_exc try: - self.run_in_session( + run_in_session = ( + self._run_in_session_untracked + if operation_already_held + else self.run_in_session + ) + run_in_session( session, close_with_drain, allow_closing=allow_closing, @@ -721,6 +1042,17 @@ class RelayRuntime: def close_session(self, event: dict[str, Any]) -> None: """Close one session scope and remove it from the core registry.""" + try: + self._begin_operation() + except RuntimeError: + return + try: + self._close_session(event) + finally: + self._end_operation() + + def _close_session(self, event: dict[str, Any]) -> None: + """Close one session already admitted by the host lifecycle gate.""" session_id = _session_id(event) with self._sessions_lock: session = self._sessions.get(session_id) @@ -741,23 +1073,13 @@ class RelayRuntime: output={}, allow_closing=True, failure_label="session scope close failed", + operation_already_held=True, ) if failure: failures.append(failure) - try: - try: - _scope_op_executor().submit( - self.relay.subscribers.flush - ).result(timeout=_SCOPE_OP_TIMEOUT) - except RuntimeError: - # Interpreter shutdown: executor refuses new futures; flush - # on a bounded exit thread so a wedged pipeline cannot - # block process exit. - _run_bounded_on_exit_thread( - self.relay.subscribers.flush, _SCOPE_OP_TIMEOUT - ) - except Exception as exc: - failures.append(f"subscriber flush failed: {exc}") + # Subscriber flushing is process-wide and may wait for publications + # owned by other sessions. Final plugin teardown flushes once after all + # tracked operations drain; doing it here can deadlock an asyncio loop. with self._sessions_lock: if self._sessions.get(session_id) is session: self._sessions.pop(session_id, None) @@ -771,17 +1093,64 @@ class RelayRuntime: ) def shutdown(self) -> None: - """Close all core-owned Relay session scopes.""" + """Close core scopes and release process plugin configuration.""" with self._sessions_lock: - session_ids = list(self._sessions) - for session_id in session_ids: - self._safe(self.close_session, {"session_id": session_id}) - if self._shutdown_registered: + if self._shutdown_started: + return + self._shutdown_started = True + self._closing = True + has_active_operations = self._active_operations > 0 + if has_active_operations: + thread = threading.Thread( + target=self._finish_shutdown_after_operations, + name=f"hermes-nemo-relay-shutdown-{self.runtime_id[:8]}", + daemon=True, + ) try: - atexit.unregister(self.shutdown) + thread.start() except Exception: - pass - self._shutdown_registered = False + with self._sessions_lock: + self._shutdown_started = False + logger.warning( + "Hermes Relay deferred shutdown could not start", + exc_info=True, + ) + return + self._finish_shutdown() + + def _finish_shutdown_after_operations(self) -> None: + self._operations_idle.wait() + self._finish_shutdown() + + def _finish_shutdown(self) -> None: + try: + with self._sessions_lock: + session_ids = list(self._sessions) + for session_id in session_ids: + self._safe(self._close_session, {"session_id": session_id}) + if self._plugin_configuration_registered: + if ( + self._plugin_configuration_state + is _RelayPluginConfigurationState.ACTIVE + ): + self.release_managed_execution( + RELAY_PLUGINS_EXECUTION_CONSUMER + ) + _PLUGIN_CONFIGURATION.release(self) + self._plugin_configuration_registered = False + if self._shutdown_registered: + try: + atexit.unregister(self.shutdown) + except Exception: + pass + self._shutdown_registered = False + except Exception: + with self._sessions_lock: + self._shutdown_started = False + logger.warning("Hermes Relay shutdown failed", exc_info=True) + return + with self._sessions_lock: + self._shutdown_complete.set() @staticmethod def _safe(callback: Callable[..., Any], *args: Any, **kwargs: Any) -> Any: @@ -1611,6 +1980,87 @@ def _load_nemo_relay() -> Any: return importlib.import_module("nemo_relay") +def _configured_plugin_inputs( + relay: Any, +) -> tuple[dict[str, Any], list[Any]] | None: + """Load selected plugin inputs, or return ``None`` when none were selected.""" + configured = os.environ.get(RELAY_PLUGINS_CONFIG_ENV, "").strip() + if not configured: + legacy_vars = configured_legacy_relay_env_vars(os.environ) + if legacy_vars: + logger.warning( + "Legacy NeMo Relay exporter variables are set but no %s was " + "provided. %s no longer activate Relay exporters; migrate the " + "exporter configuration to a Relay plugins.toml file.", + RELAY_PLUGINS_CONFIG_ENV, + ", ".join(legacy_vars), + ) + return None + + config_path = Path(configured).expanduser() + try: + with config_path.open("rb") as config_file: + config = tomllib.load(config_file) + if "dynamic_plugins" in config: + raise ValueError( + "Hermes [[dynamic_plugins]] records are unsupported; use Relay " + "[[plugins.dynamic]] records" + ) + dynamic_plugins: list[Any] = [] + if "plugins" in config: + dynamic_plugins = relay.plugin.load_dynamic_plugin_activation_specs( + config_path + ) + plugin_config = dict(config) + plugin_config.pop("plugins", None) + return plugin_config, dynamic_plugins + except Exception as exc: + raise _RelayPluginConfigurationLoadError( + "Hermes Relay plugin configuration could not be loaded from " + f"{config_path}; continuing without Relay plugins" + ) from exc + + +def _flush_relay_subscribers(relay: Any) -> None: + """Flush Relay without blocking an asyncio event-loop thread.""" + _resolve_plugin_awaitable(relay.subscribers.flush_async()) + + +def _clear_relay_plugins(relay: Any) -> None: + """Clear Relay plugins without blocking an asyncio event-loop thread.""" + _resolve_plugin_awaitable(relay.plugin.clear_async()) + + +def _resolve_plugin_awaitable(value: Any) -> Any: + """Resolve Relay's async plugin API from synchronous host construction.""" + if not inspect.isawaitable(value): + return value + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(value) + + result: dict[str, Any] = {} + error: dict[str, BaseException] = {} + + def _runner() -> None: + try: + result["value"] = asyncio.run(value) + except BaseException as exc: # pragma: no cover - re-raised below + error["exc"] = exc + + thread = threading.Thread( + target=_runner, + name="hermes-nemo-relay-plugin-lifecycle", + daemon=True, + ) + thread.start() + thread.join() + if "exc" in error: + raise error["exc"] + return result.get("value") + + def _session_id(event: dict[str, Any]) -> str: return str(event.get("session_id") or "") @@ -1619,4 +2069,5 @@ def _reset_for_tests() -> None: """Reset all profile-scoped Relay hosts for isolated tests.""" SESSION_COORDINATOR._reset_active_turns_for_tests() HOST_REGISTRY.shutdown_all() + _PLUGIN_CONFIGURATION.reset_for_tests() _PROFILE_KEY_CACHE.clear() diff --git a/agent/skill_commands.py b/agent/skill_commands.py index 4169e54fe4..f1e42e6902 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -8,6 +8,7 @@ import json import logging import os import re +import threading from pathlib import Path from typing import Any, Dict, Optional @@ -24,6 +25,10 @@ logger = logging.getLogger(__name__) _skill_commands: Dict[str, Dict[str, Any]] = {} _skill_commands_platform: Optional[str] = None _skill_commands_home: Optional[str] = None +# Guards the (map, platform-tag, home-tag) triple so publication and the +# freshness lookup always see a consistent snapshot. Scanning itself stays +# outside this lock. +_publish_lock = threading.Lock() # Patterns for sanitizing skill names into clean hyphen-separated slugs. _SKILL_INVALID_CHARS = re.compile(r"[^a-z0-9-]") _SKILL_MULTI_HYPHEN = re.compile(r"-{2,}") @@ -423,9 +428,15 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]: Dict mapping "/skill-name" to {name, description, skill_md_path, skill_dir}. """ global _skill_commands, _skill_commands_platform, _skill_commands_home - _skill_commands_platform = _resolve_skill_commands_platform() - _skill_commands_home = _resolve_skill_commands_home() - _skill_commands = {} + platform = _resolve_skill_commands_platform() + home = _resolve_skill_commands_home() + # Build into a local map and publish once, at the end. Writing straight + # into the global made a scan's partial results visible to everything + # else in the process: a second, overlapping scan deduped against its own + # (empty) ``seen_names`` but collided against the first scan's already- + # published slugs, logging one bogus "already claimed" warning per skill — + # each naming the same skill as its own incumbent (#74574). + commands: Dict[str, Dict[str, Any]] = {} try: from tools.skills_tool import SKILLS_DIR, _parse_frontmatter, skill_matches_platform, skill_matches_environment, _get_disabled_skill_names from agent.skill_utils import ( @@ -505,14 +516,14 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]: # slug (e.g. "git_helper" vs "git-helper"). First-wins # preserves local-before-external precedence. cmd_key = f"/{cmd_name}" - if cmd_key in _skill_commands: + if cmd_key in commands: logger.warning( "Skill %r maps to slash command %s already claimed " "by %r; keeping the first and skipping this one.", - name, cmd_key, _skill_commands[cmd_key]["name"], + name, cmd_key, commands[cmd_key]["name"], ) continue - _skill_commands[cmd_key] = { + commands[cmd_key] = { "name": name, "description": description or f"Invoke the {name} skill", "skill_md_path": str(skill_md), @@ -522,7 +533,18 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]: continue except Exception: pass - return _skill_commands + # Publish the finished map and the platform/home it was scanned for as + # ONE step. Bare assignments are not atomic together: a reader landing + # between them sees the NEW map still carrying the OLD platform tag, and + # if that stale tag happens to match its own platform it accepts the map + # without rescanning — serving another platform's disabled-skill view, + # exactly the leak #14536 closed. Only the publish/lookup pair is locked; + # the scan above (file I/O, deferred imports) stays outside it. + with _publish_lock: + _skill_commands = commands + _skill_commands_platform = platform + _skill_commands_home = home + return commands def get_skill_commands() -> Dict[str, Dict[str, Any]]: @@ -534,13 +556,22 @@ def get_skill_commands() -> Dict[str, Dict[str, Any]]: active profile's Hermes home changes (e.g. Desktop switching profiles mid-session) so each profile sees its own ``skills.external_dirs`` (#88023). """ - if ( - not _skill_commands - or _skill_commands_platform != _resolve_skill_commands_platform() - or _skill_commands_home != _resolve_skill_commands_home() - ): - scan_skill_commands() - return _skill_commands + current_platform = _resolve_skill_commands_platform() + current_home = _resolve_skill_commands_home() + # Read the map and its tags under the same lock that publishes them, so + # the freshness decision is made against a consistent snapshot. + with _publish_lock: + commands = _skill_commands + is_fresh = ( + bool(commands) + and _skill_commands_platform == current_platform + and _skill_commands_home == current_home + ) + if is_fresh: + return commands + # Scan outside the lock — it does file I/O and deferred imports, and + # concurrent scans are already safe (each builds its own map). + return scan_skill_commands() def reload_skills() -> Dict[str, Any]: diff --git a/agent/system_prompt.py b/agent/system_prompt.py index 2e2579a79c..b176a2554f 100644 --- a/agent/system_prompt.py +++ b/agent/system_prompt.py @@ -33,10 +33,12 @@ from typing import Any, Dict, List, Optional from agent.prompt_builder import ( DEFAULT_AGENT_IDENTITY, + EXECUTION_GUIDANCE_MODELS, GOOGLE_MODEL_OPERATIONAL_GUIDANCE, HERMES_AGENT_HELP_GUIDANCE, KANBAN_GUIDANCE, MEMORY_GUIDANCE, + USER_PROFILE_GUIDANCE, OPENAI_MODEL_EXECUTION_GUIDANCE, PARALLEL_TOOL_CALL_GUIDANCE, PLATFORM_HINTS, @@ -414,8 +416,21 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) # Tool-aware behavioral guidance: only inject when the tools are loaded tool_guidance = [] + # MEMORY_GUIDANCE instructs the model to save facts to the built-in + # MEMORY.md/USER.md stores. With both disabled in config no store is built, + # so the guidance would steer the model at a tool whose every call returns + # "Memory is not available". Defaults to True for the rare code paths that + # build an agent view without going through agent_init. + # When only the user profile store is enabled, the narrower + # USER_PROFILE_GUIDANCE is injected instead — the full block instructs the + # model to write notes to a MEMORY.md store that does not exist. + _mem_enabled = getattr(agent, "_memory_enabled", True) + _profile_enabled = getattr(agent, "_user_profile_enabled", True) if "memory" in agent.valid_tool_names: - tool_guidance.append(MEMORY_GUIDANCE) + if _mem_enabled: + tool_guidance.append(MEMORY_GUIDANCE) + elif _profile_enabled: + tool_guidance.append(USER_PROFILE_GUIDANCE) if "session_search" in agent.valid_tool_names: tool_guidance.append(SESSION_SEARCH_GUIDANCE) if "skill_manage" in agent.valid_tool_names: @@ -477,13 +492,36 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) # paths, parallel tool calls, verify-before-edit, etc.) if "gemini" in _model_lower or "gemma" in _model_lower: stable_parts.append(GOOGLE_MODEL_OPERATIONAL_GUIDANCE) - # OpenAI GPT/Codex execution discipline (tool persistence, - # prerequisite checks, verification, anti-hallucination). - # Also applied to xAI Grok — same failure modes (claims completion - # without tool calls, suggests workarounds instead of using - # existing tools, replies with plans instead of executing). - if "gpt" in _model_lower or "codex" in _model_lower or "grok" in _model_lower: - stable_parts.append(OPENAI_MODEL_EXECUTION_GUIDANCE) + + # Execution-discipline guidance (tool persistence, mandatory tool use + # for arithmetic, external-write read-back, count reconciliation, + # literal preservation, verification-gated completion). Historically + # nested inside the tool-use-enforcement branch and fenced to + # gpt/codex/grok; now an independent gate so DeepSeek/Kimi/Qwen-class + # models receive it even when tool_use_enforcement is off. Controlled + # by config.yaml agent.execution_guidance: + # "auto" (default) — matches EXECUTION_GUIDANCE_MODELS + # true — always inject (all models) + # false — never inject + # list — custom model-name substrings to match + # Resolved once at session start keyed on the (fixed) model name, so + # the system prompt stays byte-stable for the life of the conversation. + if agent.valid_tool_names: + _exec_guidance = getattr(agent, "_execution_guidance", "auto") + _exec_inject = False + if _exec_guidance is True or (isinstance(_exec_guidance, str) and _exec_guidance.lower() in {"true", "always", "yes", "on"}): + _exec_inject = True + elif _exec_guidance is False or (isinstance(_exec_guidance, str) and _exec_guidance.lower() in {"false", "never", "no", "off"}): + _exec_inject = False + elif isinstance(_exec_guidance, list): + model_lower = (agent.model or "").lower() + _exec_inject = any(p.lower() in model_lower for p in _exec_guidance if isinstance(p, str)) + else: + # "auto" or any unrecognised value — use hardcoded defaults + model_lower = (agent.model or "").lower() + _exec_inject = any(p in model_lower for p in EXECUTION_GUIDANCE_MODELS) + if _exec_inject: + stable_parts.append(OPENAI_MODEL_EXECUTION_GUIDANCE) has_skills_tools = any(name in agent.valid_tool_names for name in ['skills_list', 'skill_view', 'skill_manage']) if has_skills_tools: diff --git a/agent/tool_dispatch_helpers.py b/agent/tool_dispatch_helpers.py index af0970accb..0c4259b656 100644 --- a/agent/tool_dispatch_helpers.py +++ b/agent/tool_dispatch_helpers.py @@ -557,7 +557,11 @@ def make_tool_result_message( The outer list itself is rebuilt rather than returned by identity, so callers should compare by value, not by ``is``. """ - wrapped = _maybe_wrap_untrusted(name, content) + # Order matters: detect provider-side elision on the RAW content and + # append the notice first, THEN wrap — so the notice lives inside the + # untrusted block next to the data it describes, appended exactly once + # at construction time (cache-safe). + wrapped = _maybe_wrap_untrusted(name, _maybe_append_elision_notice(name, content)) message = stamp_message_timestamp({ "role": "tool", "name": name, @@ -608,6 +612,70 @@ def _is_untrusted_tool(name: Optional[str]) -> bool: return any(name.startswith(p) for p in _UNTRUSTED_TOOL_PREFIXES) +# --- Upstream-elision detection -------------------------------------------- +# +# Some MCP servers elide data SERVER-SIDE and mark the elision inside the +# payload itself (e.g. Composio: '...13 more items' inside a JSON array, +# '"has_more": true', 'Complete response was large (N tokens). Full data +# saved to sandbox in /mnt/files/...', 'data_preview' envelopes). Because the +# result looks structurally complete, models treat the visible slice as the +# whole dataset and falsely claim completeness. When one of these markers is +# present, we append ONE compact notice at result-construction time — before +# the message enters history, never mutated later, so prompt caching is safe. + +# Conservative patterns only: each one is an explicit provider-side "there is +# more data than what you can see" signal, not a generic truncation heuristic. +_UPSTREAM_ELISION_PATTERNS = ( + re.compile(r"\.\.\.\s*\d+\s+more\s+items?", re.IGNORECASE), + re.compile(r'"has_more"\s*:\s*true', re.IGNORECASE), + re.compile(r"saved to sandbox", re.IGNORECASE), + re.compile(r"data_preview", re.IGNORECASE), +) + +# Results smaller than this can't meaningfully hide an elided enumeration — +# skip the scan entirely so tiny results pay nothing. +_ELISION_SCAN_MIN_CHARS = 1_000 + +# Bound the regex scan: markers appear near the elided structure, which for +# the payload sizes that matter (20-50K) is always inside the first 64KB. +_ELISION_SCAN_MAX_CHARS = 65_536 + +_UPSTREAM_ELISION_NOTICE = ( + '\n[hermes note: this result contains provider-side elision markers ' + '(e.g. "...N more items" / has_more:true). The data shown is INCOMPLETE ' + '— page/fetch the remainder before treating any enumeration as complete.]' +) + + +def _detect_upstream_elision(content: Any) -> bool: + """True when a string tool result carries provider-side elision markers. + + Cheap and safe by construction: non-string content is never scanned, + results under ``_ELISION_SCAN_MIN_CHARS`` short-circuit, and the regex + scan is capped at the first ``_ELISION_SCAN_MAX_CHARS`` chars. + """ + if not isinstance(content, str): + return False + if len(content) < _ELISION_SCAN_MIN_CHARS: + return False + window = content[:_ELISION_SCAN_MAX_CHARS] + return any(p.search(window) for p in _UPSTREAM_ELISION_PATTERNS) + + +def _maybe_append_elision_notice(name: str, content: Any) -> Any: + """Append the incompleteness notice to untrusted string results that + embed upstream elision markers. Returns ``content`` unchanged otherwise. + + Runs on the RAW result before untrusted-wrapping so the notice sits with + the data it describes, and only at result-construction time (cache-safe). + """ + if not _is_untrusted_tool(name): + return content + if _detect_upstream_elision(content): + return content + _UPSTREAM_ELISION_NOTICE + return content + + def _tool_output_risk_metadata(name: str, content: Any) -> Optional[Dict[str, Any]]: """Classify textual attacker-controlled output without retaining a copy. @@ -729,5 +797,7 @@ __all__ = [ "_extract_landed_file_mutation_paths", "_extract_error_preview", "_trajectory_normalize_msg", + "_detect_upstream_elision", + "_maybe_append_elision_notice", "make_tool_result_message", ] diff --git a/agent/tool_executor.py b/agent/tool_executor.py index 381f1000e9..e7bb9126db 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -48,12 +48,31 @@ from tools.thread_context import propagate_context_to_thread from tools.tool_result_storage import ( maybe_persist_tool_result, enforce_turn_budget, + extract_persisted_path, ) from tools.budget_config import BudgetConfig, DEFAULT_BUDGET, budget_for_context_window logger = logging.getLogger(__name__) +def _record_persisted_path_for_stub(agent, tool_call_id: str, function_result) -> None: + """Tell the stall guards where a persisted result's full content lives. + + When a large result is spilled to disk ( preview), a + later result-reference stub pointing at that first occurrence must carry + the spillover file path so the reference can't dangle. Best-effort: never + lets bookkeeping break tool execution. + """ + try: + if not isinstance(function_result, str): + return + path = extract_persisted_path(function_result) + if path: + agent._tool_guardrails.record_persisted_result(tool_call_id, path) + except Exception as exc: + logger.debug("persisted-path record for result stub failed: %s", exc) + + def _ensure_file_checkpoint( agent, function_name: str, @@ -87,7 +106,10 @@ def _budget_for_agent(agent) -> BudgetConfig: """ try: ctx = getattr(getattr(agent, "context_compressor", None), "context_length", None) - return budget_for_context_window(int(ctx)) if ctx else DEFAULT_BUDGET + # budget_for_context_window(None) (rather than DEFAULT_BUDGET) so the + # config-driven MCP threshold override still applies when the context + # length isn't resolvable. + return budget_for_context_window(int(ctx) if ctx else None) except Exception: return DEFAULT_BUDGET @@ -1730,6 +1752,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe function_args, function_result, failed=is_error, + tool_call_id=getattr(tc, "id", "") or "", ) if is_error: @@ -1764,6 +1787,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe env=get_active_env(effective_task_id), config=_tool_budget, ) if not _is_multimodal_tool_result(function_result) else function_result + _record_persisted_path_for_stub(agent, tc.id, function_result) subdir_hints = agent._subdirectory_hints.check_tool_call(name, args) if subdir_hints: @@ -2120,6 +2144,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe question=next_args.get("question", ""), choices=next_args.get("choices"), multi_select=next_args.get("multi_select", False), + questions=next_args.get("questions"), callback=agent.clarify_callback, ) function_result, function_args, middleware_trace, _execution_blocked, _execution_dispatched = _managed_values(_run_agent_tool_execution_middleware( @@ -2177,6 +2202,57 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe tool_duration = time.time() - tool_start_time if agent._should_emit_quiet_tool_messages(): agent._vprint(f" {_get_cute_tool_message_impl('read_preview', function_args, tool_duration, result=function_result)}") + elif function_name == "drive_preview": + def _execute(next_args: dict) -> Any: + from tools.drive_preview_tool import drive_preview_tool as _drive_preview_tool + return _drive_preview_tool( + action=next_args.get("action", ""), + ref=next_args.get("ref"), + selector=next_args.get("selector"), + text=next_args.get("text"), + key=next_args.get("key"), + submit=next_args.get("submit"), + amount=next_args.get("amount"), + to=next_args.get("to"), + limit=next_args.get("max"), + callback=getattr(agent, "drive_preview_callback", None), + ) + function_result, function_args, middleware_trace, _execution_blocked, _execution_dispatched = _managed_values(_run_agent_tool_execution_middleware( + agent, + function_name=function_name, + function_args=function_args, + effective_task_id=effective_task_id, + tool_call_id=getattr(tool_call, "id", "") or "", + execute=_execute, + scope_block=_ts_scope_block, + display_index=i, + )) + tool_duration = time.time() - tool_start_time + if agent._should_emit_quiet_tool_messages(): + agent._vprint(f" {_get_cute_tool_message_impl('drive_preview', function_args, tool_duration, result=function_result)}") + elif function_name == "annotate_preview": + def _execute(next_args: dict) -> Any: + from tools.annotate_preview_tool import annotate_preview_tool as _annotate_preview_tool + return _annotate_preview_tool( + action=next_args.get("action", "add"), + ref=next_args.get("ref"), + selector=next_args.get("selector"), + label=next_args.get("label"), + callback=getattr(agent, "drive_preview_callback", None), + ) + function_result, function_args, middleware_trace, _execution_blocked, _execution_dispatched = _managed_values(_run_agent_tool_execution_middleware( + agent, + function_name=function_name, + function_args=function_args, + effective_task_id=effective_task_id, + tool_call_id=getattr(tool_call, "id", "") or "", + execute=_execute, + scope_block=_ts_scope_block, + display_index=i, + )) + tool_duration = time.time() - tool_start_time + if agent._should_emit_quiet_tool_messages(): + agent._vprint(f" {_get_cute_tool_message_impl('annotate_preview', function_args, tool_duration, result=function_result)}") elif function_name == "read_window_below": def _execute(next_args: dict) -> Any: from tools.read_window_tool import read_window_below_tool as _read_window_below_tool @@ -2196,6 +2272,33 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe tool_duration = time.time() - tool_start_time if agent._should_emit_quiet_tool_messages(): agent._vprint(f" {_get_cute_tool_message_impl('read_window_below', function_args, tool_duration, result=function_result)}") + elif function_name == "tour": + def _execute(next_args: dict) -> Any: + from tools.tour_tool import tour_tool as _tour_tool + return _tour_tool( + action=next_args.get("action", ""), + surface=next_args.get("surface"), + selector=next_args.get("selector"), + title=next_args.get("title"), + text=next_args.get("text"), + side=next_args.get("side"), + steps=next_args.get("steps"), + step_index=next_args.get("step_index"), + callback=getattr(agent, "tour_callback", None), + ) + function_result, function_args, middleware_trace, _execution_blocked, _execution_dispatched = _managed_values(_run_agent_tool_execution_middleware( + agent, + function_name=function_name, + function_args=function_args, + effective_task_id=effective_task_id, + tool_call_id=getattr(tool_call, "id", "") or "", + execute=_execute, + scope_block=_ts_scope_block, + display_index=i, + )) + tool_duration = time.time() - tool_start_time + if agent._should_emit_quiet_tool_messages(): + agent._vprint(f" {_get_cute_tool_message_impl('tour', function_args, tool_duration, result=function_result)}") elif function_name == "setup_mcp": def _execute(next_args: dict) -> Any: from tools.setup_mcp_tool import setup_mcp_tool as _setup_mcp_tool @@ -2541,6 +2644,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe function_args, function_result, failed=_is_error_result, + tool_call_id=getattr(tool_call, "id", "") or "", ) result_preview = function_result if agent.verbose_logging else ( function_result[:200] if len(function_result) > 200 else function_result @@ -2579,6 +2683,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe env=get_active_env(effective_task_id), config=_tool_budget, ) if not _is_multimodal_tool_result(function_result) else function_result + _record_persisted_path_for_stub(agent, tool_call.id, function_result) # Discover subdirectory context files from tool arguments subdir_hints = agent._subdirectory_hints.check_tool_call(function_name, function_args) diff --git a/agent/tool_guardrails.py b/agent/tool_guardrails.py index 6e6a9cd8f5..e4afbbba82 100644 --- a/agent/tool_guardrails.py +++ b/agent/tool_guardrails.py @@ -59,6 +59,52 @@ MUTATING_TOOL_NAMES = frozenset( } ) +# Tools that are legitimately re-invoked with identical arguments and may +# legitimately return an unchanged result while waiting on external progress — +# background-process management and job pollers. The identical-call loop +# notice (agent.stall_guards) never fires for these, so polling patterns like +# ``process(action="poll")`` or repeatedly checking a generation job stay +# unannotated. +STALL_GUARD_REPEATABLE_TOOLS = frozenset( + { + "process", + "bfl_flux3_get_result", + } +) + +# Poller naming conventions (e.g. ``_get_result``) used by generated / +# MCP tool surfaces. Matched as suffixes so vendor-prefixed pollers are exempt +# without enumerating every vendor. +_STALL_GUARD_REPEATABLE_SUFFIXES = ( + "_get_result", + "_poll", +) + +# The notice fires on the Nth consecutive identical call (same tool, same +# canonical args, same result). 3 tolerates one legitimate double-check while +# catching the observed re-issue loops (3x/4x identical calls in eval traces). +STALL_GUARD_IDENTICAL_CALL_THRESHOLD = 3 + +# Result-reference stubbing (agent.stall_guards): from the 2nd consecutive +# identical call whose FRESH result is byte-identical to the previous one, +# the duplicate payload is replaced in context by a short reference stub. +# Results under this size aren't worth stubbing (the stub itself plus the +# lost locality outweigh the savings), and error results are never stubbed +# (the model must see every fresh error verbatim). +IDENTICAL_RESULT_STUB_MIN_CHARS = 512 + +# How much of the canonical args JSON the stub carries so the model still +# knows WHAT the referenced call was even if context compression later +# evicts the referenced result (cheap dangling-reference mitigation). +_RESULT_STUB_ARGS_PREVIEW_CHARS = 120 + + +def is_stall_guard_repeatable(tool_name: str) -> bool: + """Whether a tool is exempt from the identical-call loop notice.""" + if tool_name in STALL_GUARD_REPEATABLE_TOOLS: + return True + return tool_name.endswith(_STALL_GUARD_REPEATABLE_SUFFIXES) + @dataclass(frozen=True) class ToolCallGuardrailConfig: @@ -173,6 +219,21 @@ class LoopCapConfig: ) +@dataclass(frozen=True) +class IdenticalCallObservation: + """Outcome of observing one completed tool call for the stall guards. + + ``notice`` is the identical-call loop-breaker notice (appended after the + result). ``stub`` is the result-reference replacement for a byte-identical + duplicate result (replaces the result content). Both may be set on the + same call (3rd+ identical call): the stub replaces the payload and the + notice is appended after it. + """ + + notice: str | None = None + stub: str | None = None + + @dataclass(frozen=True) class ToolCallSignature: """Stable, non-reversible identity for a tool name plus canonical args.""" @@ -282,6 +343,26 @@ class ToolCallGuardrailController: self._same_tool_failure_counts: dict[str, int] = {} self._no_progress: dict[ToolCallSignature, tuple[str, int]] = {} self._halt_decision: ToolGuardrailDecision | None = None + # Identical-call loop-breaker state (agent.stall_guards): tracks the + # CONSECUTIVE streak of identical (tool, canonical args) calls whose + # results were also identical. Any different call — or a different + # result — resets the streak, so legitimate re-reads after edits and + # varied polling are never flagged. Per-turn, like everything else here. + # NOTE: open PR #85352 (patrykkopycinski) tracks no-progress loops + # ACROSS turns via a detection window — a different mechanism from + # this per-turn consecutive streak. Coordinate future work there. + self._identical_streak_sig: ToolCallSignature | None = None + self._identical_streak_result_hash: str = "" + self._identical_streak_count: int = 0 + # tool_call_id of the FIRST call in the current streak, so a + # result-reference stub can point at the message that carries the + # full payload. + self._identical_streak_first_call_id: str = "" + # tool_call_id -> spillover file path for results that were persisted + # out of context (persisted-output preview). Lets a reference stub + # carry the file path so the reference can't dangle when the first + # occurrence entered context as a preview. + self._persisted_result_paths: dict[str, str] = {} # Per-turn runaway-loop cap counters. Reset every turn (this method # runs at the start of each run_conversation), so the caps bound a # single agent loop rather than accumulating across the session. @@ -444,6 +525,138 @@ class ToolCallGuardrailController: return False return tool_name in self.config.idempotent_tools + def observe_identical_call( + self, + tool_name: str, + args: Mapping[str, Any] | None, + result: str | None, + ) -> str | None: + """Track consecutive identical calls; return a loop-breaker notice or None. + + Back-compat wrapper around :meth:`observe_call` for callers that only + care about the loop-breaker notice. + """ + return self.observe_call(tool_name, args, result).notice + + def observe_call( + self, + tool_name: str, + args: Mapping[str, Any] | None, + result: str | None, + *, + tool_call_id: str = "", + failed: bool = False, + ) -> "IdenticalCallObservation": + """Track consecutive identical calls; return notice + dedupe stub info. + + Two independent outputs from the same consecutive-streak tracker: + + - ``notice``: the compact loop-breaker notice, fired when the SAME + tool is called with identical canonical arguments AND returns an + identical result for the ``STALL_GUARD_IDENTICAL_CALL_THRESHOLD``-th + (and every subsequent) consecutive time within the turn. Purely + observational — never blocks the call. Allowlisted pollers + (``is_stall_guard_repeatable``) are exempt from the NOTICE. + - ``stub``: a short reference replacement for the CURRENT result, + produced from the 2nd consecutive identical call whose fresh result + is byte-identical to the previous one. The tool still executed — + only the context representation is deduplicated, so polling + semantics are preserved (a changed result flows through whole and + resets the streak). Pollers are NOT exempt from stubbing: for a + poller, an identical result means nothing changed, which is exactly + when the stub saves the most context and loses nothing. Results + under ``IDENTICAL_RESULT_STUB_MIN_CHARS`` and failed/error results + are never stubbed, and only plain-string results are considered. + + Any intervening different call or changed result resets the streak. + Callers substitute/append at tool RESULT construction time, which is + cache-safe: tool results are append-only and never mutate + already-sent context. + """ + is_plain_str = isinstance(result, str) + signature = ToolCallSignature.from_call(tool_name, _coerce_args(args)) + result_hash = _result_hash(result) if is_plain_str else "" + + if ( + is_plain_str + and self._identical_streak_sig == signature + and self._identical_streak_result_hash == result_hash + ): + self._identical_streak_count += 1 + else: + # New streak (or non-string result, which never forms a streak — + # multimodal content lists pass through untouched). + self._identical_streak_sig = signature if is_plain_str else None + self._identical_streak_result_hash = result_hash + self._identical_streak_count = 1 if is_plain_str else 0 + self._identical_streak_first_call_id = tool_call_id or "" + + count = self._identical_streak_count + + notice = None + if ( + not is_stall_guard_repeatable(tool_name) + and count >= STALL_GUARD_IDENTICAL_CALL_THRESHOLD + ): + ordinal = f"{count}{'th' if 11 <= count % 100 <= 13 else {1: 'st', 2: 'nd', 3: 'rd'}.get(count % 10, 'th')}" + notice = ( + f"[hermes note: this is the {ordinal} consecutive identical call to " + f"{tool_name} with identical arguments returning the same result. " + "Do not repeat it — change arguments, use a different tool, or " + "proceed with what you have.]" + ) + + stub = None + if ( + is_plain_str + and count >= 2 + and not failed + and len(result) >= IDENTICAL_RESULT_STUB_MIN_CHARS + ): + stub = self._build_result_reference_stub(tool_name, args) + + return IdenticalCallObservation(notice=notice, stub=stub) + + def record_persisted_result(self, tool_call_id: str, file_path: str) -> None: + """Remember the spillover path a persisted result was saved to. + + When the first occurrence of a result entered context as a + persisted-output preview, a later reference stub must carry the + spillover file path so the reference can't dangle. + """ + if tool_call_id and file_path: + self._persisted_result_paths[tool_call_id] = file_path + + def _build_result_reference_stub( + self, tool_name: str, args: Mapping[str, Any] | None + ) -> str: + """Build the reference stub replacing a byte-identical duplicate result. + + Carries the tool name + a canonical-args preview so that even if + context compression later evicts the referenced result, the model + still knows WHAT the call was (cheap dangling-reference mitigation). + """ + try: + args_preview = canonical_tool_args(_coerce_args(args)) + except TypeError: + args_preview = "{}" + if len(args_preview) > _RESULT_STUB_ARGS_PREVIEW_CHARS: + args_preview = args_preview[:_RESULT_STUB_ARGS_PREVIEW_CHARS] + "…" + first_id = self._identical_streak_first_call_id + ref = f" (tool_call_id {first_id})" if first_id else "" + stub = ( + f"[hermes note: this result is byte-identical to the {tool_name} " + f"result earlier this turn{ref}. Refer to that result; it has not " + f"changed. Args: {args_preview}]" + ) + spill_path = self._persisted_result_paths.get(first_id) if first_id else None + if spill_path: + stub += ( + f"\n[The referenced result was persisted to: {spill_path} — " + "page through it with read_file if you need the full content.]" + ) + return stub + def _check_loop_cap( self, tool_name: str, diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py index 939ef6f446..bec0f9a82b 100644 --- a/agent/transports/chat_completions.py +++ b/agent/transports/chat_completions.py @@ -13,6 +13,15 @@ import json from typing import Any, Dict from agent.lmstudio_reasoning import resolve_lmstudio_effort +from agent.reasoning_effort import ( + KIMI_K3_EFFORTS, + KIMI_K3_OVERRIDES, + OPENAI_COMPAT_WIRE_EFFORTS, + TOKENHUB_EFFORTS, + clamp_effort, + kimi_supported_efforts, + requested_effort, +) from agent.moonshot_schema import is_moonshot_model, sanitize_moonshot_tools from agent.prompt_builder import DEVELOPER_ROLE_MODELS from agent.transports.base import ProviderTransport @@ -84,15 +93,25 @@ def _add_prompt_cache_key( def _reasoning_config_for_model(model: str, reasoning_config: dict | None) -> dict | None: - """Return the model's wire-compatible reasoning config.""" + """Return the model's wire-compatible reasoning config. + + Hermes' internal effort set extends the wire vocabulary with ``ultra`` + (the /reasoning command documents none..xhigh|max|ultra). OpenAI- + compatible wires — OpenRouter chief among them — accept exactly + max|xhigh|high|medium|low|minimal|none and reject the extension with + HTTP 400 (#89503). Clamp against the declared wire vocabulary via the + shared policy in ``agent.reasoning_effort``; provider profiles with + narrower sets clamp again downstream. + """ if not isinstance(reasoning_config, dict): return reasoning_config - if ( - "gpt-5.6" in (model or "").lower() - and str(reasoning_config.get("effort") or "").strip().lower() == "ultra" - ): + effort = str(reasoning_config.get("effort") or "").strip().lower() + if not effort: + return reasoning_config + clamped = clamp_effort(effort, OPENAI_COMPAT_WIRE_EFFORTS) + if clamped != effort: normalized = dict(reasoning_config) - normalized["effort"] = "max" + normalized["effort"] = clamped return normalized return reasoning_config @@ -559,11 +578,22 @@ class ChatCompletionsTransport(ProviderTransport): and reasoning_config.get("enabled") is False ) if not _kimi_thinking_off: - _kimi_effort = "medium" - if reasoning_config and isinstance(reasoning_config, dict): - _e = (reasoning_config.get("effort") or "").strip().lower() - if _e in {"low", "medium", "high"}: - _kimi_effort = _e + # Kimi vocabularies are declared in agent.reasoning_effort: + # K3 = low/high/max (with the vendor-documented medium→high, + # xhigh→max rounding), K2-era = low/medium/high. Default when + # no effort was requested: K3's server default is high, + # K2-era's is medium. + _supported = kimi_supported_efforts(model) + _overrides = ( + KIMI_K3_OVERRIDES if _supported is KIMI_K3_EFFORTS else None + ) + _e = requested_effort(reasoning_config) + if _e is None: + _kimi_effort = ( + "high" if _supported is KIMI_K3_EFFORTS else "medium" + ) + else: + _kimi_effort = clamp_effort(_e, _supported, _overrides) api_kwargs["reasoning_effort"] = _kimi_effort # Tencent TokenHub: top-level reasoning_effort (unless thinking disabled) @@ -574,11 +604,13 @@ class ChatCompletionsTransport(ProviderTransport): and reasoning_config.get("enabled") is False ) if not _tokenhub_thinking_off: - _tokenhub_effort = "high" - if reasoning_config and isinstance(reasoning_config, dict): - _e = (reasoning_config.get("effort") or "").strip().lower() - if _e in {"low", "medium", "high"}: - _tokenhub_effort = _e + # TokenHub accepts low/medium/high (declared in + # agent.reasoning_effort); default high when no effort was + # requested. + _e = requested_effort(reasoning_config) + _tokenhub_effort = ( + "high" if _e is None else clamp_effort(_e, TOKENHUB_EFFORTS) + ) api_kwargs["reasoning_effort"] = _tokenhub_effort # LM Studio: top-level reasoning_effort. Only emit when the model diff --git a/agent/transports/codex.py b/agent/transports/codex.py index 9a866225b0..02e7390c63 100644 --- a/agent/transports/codex.py +++ b/agent/transports/codex.py @@ -27,6 +27,13 @@ def _cache_scope_from_session_id(session_id: Optional[str]) -> str: match = _CRON_SESSION_ID_RE.match(sid) return match.group(1) if match else sid +from agent.reasoning_effort import ( + ACTUAL_RELAY_EFFORTS, + XAI_GROK46_EFFORTS, + XAI_LEGACY_EFFORTS, + clamp_effort, + codex_supported_efforts, +) from agent.transports.base import ProviderTransport from agent.transports.types import NormalizedResponse, ToolCall @@ -432,30 +439,31 @@ class ResponsesApiTransport(ProviderTransport): elif reasoning_config.get("effort"): reasoning_effort = reasoning_config["effort"] - _effort_clamp = {"minimal": "low"} - if "gpt-5.6" in (model or "").lower(): - # Ultra is the Codex product tier; the Responses API wire value is max. - _effort_clamp["ultra"] = "max" + # Wire vocabularies are declared in agent.reasoning_effort; the shared + # clamp policy (nearest weaker supported level, never escalate, + # never invert the ladder) replaces the per-backend hand maps that + # repeatedly leaked internal levels like "ultra" to the wire + # (#89503 class) or clamped one rung below a model's real ceiling + # (#87279). if params.get("is_xai_responses", False): from agent.model_metadata import is_grok_46_family - # Grok 4.6 accepts xhigh as a wire value; older Grok models top - # out at high. max/ultra are Hermes ladder aliases for "this - # model's ceiling", so they clamp to the strongest level the - # model actually accepts — xhigh on grok-4.6, high elsewhere — - # never one rung below it (#87279). - if is_grok_46_family(model): - _effort_clamp.update({"max": "xhigh", "ultra": "xhigh"}) - else: - _effort_clamp["xhigh"] = "high" - _effort_clamp.update({"max": "high", "ultra": "high"}) - if (params.get("provider") or "").strip().lower() == "actual": - # Actual Computer relays to SGLang/vLLM backends that accept only - # none/low/medium/high/max for reasoning effort — a forwarded - # xhigh/ultra fails with a wrapped HTTP 400 ("Expecting value: - # line 1 column 1"). Clamp Hermes' wider set to the supported one. - _effort_clamp.update({"xhigh": "high", "ultra": "max"}) - reasoning_effort = _effort_clamp.get(reasoning_effort, reasoning_effort) + # Grok 4.6 accepts xhigh as a wire value; older Grok tops out + # at high. + _supported = ( + XAI_GROK46_EFFORTS if is_grok_46_family(model) + else XAI_LEGACY_EFFORTS + ) + elif (params.get("provider") or "").strip().lower() == "actual": + # Actual Computer relays to SGLang/vLLM backends: + # none/low/medium/high/max. + _supported = ACTUAL_RELAY_EFFORTS + else: + # OpenAI/Codex Responses backend — per-model vocabulary + # (live-verified: "max" is gpt-5.6-only, "minimal" always + # rejected). #68365 premise confirmed. + _supported = codex_supported_efforts(model) + reasoning_effort = clamp_effort(reasoning_effort, _supported) response_tools = _responses_tools(tools) diff --git a/agent/turn_context.py b/agent/turn_context.py index a90bee9a1a..df8969a666 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -605,6 +605,15 @@ def build_turn_context( # NOTE: _turns_since_memory and _iters_since_skill are NOT reset here. agent.iteration_budget = IterationBudget(agent.max_iterations) + # Wall-clock run budget: per-run_conversation clock. Only stamped when a + # budget is configured so the default path stays clock-free; the wrap-up + # latch resets each turn (one notice per run, not per session). + if getattr(agent, "run_budget_seconds", None): + agent._run_budget_started_at = time.time() + else: + agent._run_budget_started_at = None + agent._run_budget_wrapup_injected = False + # Log conversation turn start for debugging/observability. _preview_text = summarize_user_message_for_log(user_message) _msg_preview = (_preview_text[:80] + "...") if len(_preview_text) > 80 else _preview_text @@ -1154,6 +1163,57 @@ def build_turn_context( agent._last_content_with_tools = None agent._last_content_tools_all_housekeeping = False agent._mute_post_response = False + elif not agent.compression_enabled: + # Uncompressed session guard (#89297): when compression is explicitly + # disabled, sessions can grow past the model's context window across + # hundreds of messages with nothing to shrink them. The warning itself + # fires from the conversation loop's pre-API site, which reuses the + # unconditionally computed request estimate at zero marginal cost and + # covers both turn-start and mid-turn growth (every provider request + # passes through it). Here we only RE-ARM the dedup once the session + # is back under the window, so the guard can warn again after the + # user compacts (/compress with force=True works with compression + # disabled) and the context later regrows past the limit. + _ctx_len = getattr( + getattr(agent, "context_compressor", None), "context_length", None + ) + if isinstance(_ctx_len, int) and _ctx_len > 0: + _raw_chars = 0 + for _m in messages: + if not isinstance(_m, dict): + continue + _c = _m.get("content") + if isinstance(_c, str): + _raw_chars += len(_c) + elif _c: + # Non-string, non-empty content (multimodal part lists, + # dict payloads) defeats a char count — force the real + # estimate by treating it as over-gate. None/"" (routine + # assistant tool-call rows) contribute nothing. + _raw_chars = _ctx_len + 1 + break + # Cheap gate: a session whose raw text is under ~1/4 of the + # window (4 chars/token upper bound) cannot be over it — skip + # the estimator. Non-string (multimodal) content defeats a char + # count, so any such message forces the real estimate. + if _raw_chars <= _ctx_len: + _clear_warn = getattr( + agent, "_clear_context_overflow_warn", None + ) + if callable(_clear_warn): + _clear_warn() + else: + _uncompressed_tokens = estimate_request_tokens_rough( + messages, + system_prompt=active_system_prompt or "", + tools=agent.tools or None, + ) + if _uncompressed_tokens <= _ctx_len: + _clear_warn = getattr( + agent, "_clear_context_overflow_warn", None + ) + if callable(_clear_warn): + _clear_warn() if _preflight_compressed: # Compression rebuilt the list (tail messages are fresh compaction diff --git a/agent/video_gen_registry.py b/agent/video_gen_registry.py index ff46b087c0..5b4c9cea54 100644 --- a/agent/video_gen_registry.py +++ b/agent/video_gen_registry.py @@ -132,6 +132,18 @@ def get_active_provider() -> Optional[VideoGenProvider]: except Exception as exc: logger.debug("Could not read video_gen.provider from config: %s", exc) + # The managed "Nous Subscription" selection is serviced by the FAL + # plugin through the managed fal-queue gateway (the plugin's resolver + # routes managed when the stored selection is "nous"). + if configured: + try: + from tools.tool_backend_helpers import NOUS_MANAGED_PROVIDER + + if configured.lower() == NOUS_MANAGED_PROVIDER: + configured = "fal" + except Exception: # pragma: no cover — helpers are in-repo + pass + with _lock: snapshot = dict(_providers) snapshot.update(_scoped_providers.get(hermes_home_key(), {})) diff --git a/agent/web_search_provider.py b/agent/web_search_provider.py index e0f7ea1f1d..0f7f706c86 100644 --- a/agent/web_search_provider.py +++ b/agent/web_search_provider.py @@ -126,6 +126,22 @@ class WebSearchProvider(abc.ABC): """Return True if this provider implements :meth:`search`.""" return True + def is_keyless_available(self) -> bool: + """Return True when this provider can serve calls WITHOUT credentials. + + A separate, weaker tier than :meth:`is_available`: providers with a + public anonymous free tier (Exa / Parallel MCP endpoints) return + True here so the registry can fall back to them when NO provider is + configured or keyed — and only then. Keyless availability must never + make :meth:`is_available` return True, or the legacy preference walk + would route users with real credentials for a lower-priority backend + onto the free tier of a higher-priority one. + + Like :meth:`is_available`, this must be cheap and must NOT make + network calls. Default: False. + """ + return False + def supports_extract(self) -> bool: """Return True if this provider implements :meth:`extract`. diff --git a/agent/web_search_registry.py b/agent/web_search_registry.py index 2e0c116ec0..112fadc6df 100644 --- a/agent/web_search_registry.py +++ b/agent/web_search_registry.py @@ -166,6 +166,44 @@ _LEGACY_PREFERENCE = ( "ddgs", ) +# Keyless free-tier walk — strictly LAST-resort, tried only after the +# availability-filtered legacy walk finds nothing (i.e. the user has zero +# web credentials and no importable ddgs). All five vendors expose public +# anonymous free tiers (see plugins/web/keyless_mcp.py). Unpinned keyless +# traffic round-robins across the ring per request (the ring cursor lives +# in keyless_mcp; an explicit `hermes tools` pick bypasses this walk +# entirely, and rate-limited requests fail over to the next ring vendor). +# Disable the tier with ``web.keyless_fallback: false``. +_KEYLESS_PREFERENCE = ( + "exa", + "parallel", + "tavily", + "firecrawl", + "keenable", +) + + +def _keyless_preference() -> tuple: + """Return the keyless walk order for resolution. + + Delegates the entry-vendor choice to the ring cursor in + :mod:`plugins.web.keyless_mcp` (round-robin per request, seeded by the + per-process random session id) so resolution and dispatch agree on + which vendor a fresh install starts at. The remaining vendors follow + in ring order as fallbacks for registration gaps. + """ + try: + from plugins.web.keyless_mcp import _KEYLESS_RING, _ring_cursor + + start = _ring_cursor % len(_KEYLESS_RING) + return tuple( + _KEYLESS_RING[(start + i) % len(_KEYLESS_RING)] + for i in range(len(_KEYLESS_RING)) + ) + except Exception as exc: # noqa: BLE001 — ring optional in stripped envs + logger.debug("keyless ring order unavailable: %s", exc) + return _KEYLESS_PREFERENCE + def _resolve(configured: Optional[str], *, capability: str) -> Optional[WebSearchProvider]: """Resolve the active provider for a capability ("search" | "extract"). @@ -254,9 +292,39 @@ def _resolve(configured: Optional[str], *, capability: str) -> Optional[WebSearc ): return provider + # 4. Keyless free-tier walk — the user has NO credentialed/importable + # backend at all. Fall back to providers that can serve anonymously + # (public MCP free tiers), unless disabled via + # ``web.keyless_fallback: false``. This tier never pre-empts a keyed + # setup: it is only reachable when the legacy walk found nothing. + if _keyless_tier_enabled(): + for name in _keyless_preference(): + provider = snapshot.get(name) + if provider is None or not _capable(provider): + continue + try: + if provider.is_keyless_available(): + return provider + except Exception as exc: # noqa: BLE001 — buggy provider skipped + logger.debug( + "provider %s.is_keyless_available() raised %s", name, exc + ) + return None +def _keyless_tier_enabled() -> bool: + """Read ``web.keyless_fallback`` from config.yaml (default: enabled).""" + try: + from hermes_cli.config import load_config + + web_cfg = load_config().get("web") or {} + return bool(web_cfg.get("keyless_fallback", True)) + except Exception as exc: # noqa: BLE001 — config layer optional + logger.debug("keyless_fallback config read failed: %s", exc) + return True + + def _disabled_web_plugin_for(configured: Optional[str] = None, *, capability: Optional[str] = None) -> Optional[str]: """Return the plugin key of a *disabled* bundled web plugin that would have provided the configured backend, or None. diff --git a/apps/bootstrap-installer/package.json b/apps/bootstrap-installer/package.json index 5e72fb283f..c385f9b108 100644 --- a/apps/bootstrap-installer/package.json +++ b/apps/bootstrap-installer/package.json @@ -32,7 +32,7 @@ "clsx": "2.1.1", "katex": "0.16.47", "lucide-react": "0.577.0", - "nanostores": "1.4.0", + "nanostores": "1.4.2", "radix-ui": "1.6.7", "react": "19.2.7", "react-dom": "19.2.7", diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index e2f3614022..8d4512e65f 100644 --- a/apps/desktop/DESIGN.md +++ b/apps/desktop/DESIGN.md @@ -151,6 +151,12 @@ Notes: - SVGs inherit `size-3.5` (`size-3` at `xs`). Don't re-set icon size. - Polymorph with `asChild` when the button must render as a link/Slot. +## Badges — one component + +`src/components/ui/badge.tsx`. Variants: `default` (tinted primary), `muted`, +`warn`, `destructive`, `outline`, `solid` (primary fill — icon-corner counts). +Sizes: `default`, `xs`, `overlay` (titlebar glyph counts). + ## Form controls - **`controlVariants`** (`src/components/ui/control.ts`) is the shared shape for @@ -190,6 +196,15 @@ Notes: - **Empty:** `EmptyState` for plain page bodies; `PanelEmpty` for overlay master/detail empties with an icon and action. Don't hand-roll a third centered empty. +- **Confirmation:** `ConfirmDialog` is the only way we ask "are you sure". It + opens focused on Confirm, so `Enter` confirms and `Esc` cancels, and it owns + the pending → done → close beat and the inline error — a call site passes an + async `onConfirm` and nothing else. A third way out (e.g. "Remove from + sidebar" beside "Delete worktree") goes in the one `secondaryAction` slot. + Never `window.confirm`: it's an unstyled blocking Chromium modal. A handler + that wants the answer inline instead of a mounted dialog calls `confirm()` + from `src/store/confirm.ts`, which renders this same primitive through the + single `ConfirmHost` at the shell — the way `notify()` backs notifications. ## Chat, tools & boot surfaces @@ -315,7 +330,8 @@ The detailed state contract lives in the scoped ## Before you add something — checklist - [ ] Reuse a primitive (`Button`, `SearchField`, `SegmentedControl`, - `ListRow`, `Loader`, `ErrorState`, `LogView`) instead of forking one? + `ListRow`, `Loader`, `ErrorState`, `LogView`, `ConfirmDialog`) instead of + forking one? - [ ] Tokens (`--ui-*`, `shadow-nous`, `--stroke-nous`) — zero raw colors / one-off shadows? - [ ] No `className` overriding a primitive's padding / size / radius / chrome? diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 31cb679465..997cb72846 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -182,7 +182,9 @@ Changing profiles or connection modes is a soft workspace switch, not another cold boot. The shell and current management overlay remain mounted while gateway-bound nanostores are wiped, query-backed data is invalidated, and the new connection repopulates skeletons. This prevents rows or transcripts from -the previous gateway bleeding into the next one. +the previous gateway bleeding into the next one. Switching changes only the +foreground view and request route: it does not cancel turns or stop a backend, +and retained background sockets continue receiving events from running jobs. ### Verification diff --git a/apps/desktop/e2e/batch-clarify.spec.ts b/apps/desktop/e2e/batch-clarify.spec.ts new file mode 100644 index 0000000000..acf97ddb2e --- /dev/null +++ b/apps/desktop/e2e/batch-clarify.spec.ts @@ -0,0 +1,86 @@ +/** + * E2E batch clarify test — the multi-question clarify card must mount ONCE. + * + * Regression coverage for the duplicated-card bug: `tool.start` carries the + * model's tool_call_id while `clarify.request` carries a gateway-generated + * request_id. A batch payload has no top-level `question`, so the two rows + * only merge when the correlation key comes from the question list + * (`batchClarifyMatchValue` in lib/chat-messages/tool-parts.ts). Before that + * fix this exact flow rendered two identical interactive cards. + * + * The flow runs the real chain: composer → gateway → agent → clarify tool → + * clarify.request event → renderer, against the mock inference server. + */ + +import { expect, test } from './test' + +import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures' +import { BATCH_CLARIFY_QUESTIONS, BATCH_CLARIFY_TRIGGER } from './mock-server' + +let fixture: MockBackendFixture | null = null + +test.beforeAll(async () => { + fixture = await setupMockBackend() + await waitForAppReady(fixture!, 120_000) +}) + +test.afterAll(async () => { + await fixture?.cleanup() + fixture = null +}) + +test.describe('batch clarify card', () => { + test('renders exactly one card and completes via per-question locks', async () => { + const page = fixture!.page + const composer = page.locator('[contenteditable="true"]').first() + await composer.waitFor({ state: 'visible', timeout: 10_000 }) + + await composer.click() + await composer.type(BATCH_CLARIFY_TRIGGER, { delay: 20 }) + await page.keyboard.press('Enter') + + // The live batch form marks itself with data-clarify-batch=. + const batchCard = page.locator('form[data-clarify-batch]') + await batchCard.first().waitFor({ state: 'visible', timeout: 60_000 }) + + // THE regression assertion: one card, not two. + await expect(batchCard).toHaveCount(1) + await expect(batchCard).toHaveAttribute('data-clarify-batch', String(BATCH_CLARIFY_QUESTIONS.length)) + + // Both questions render inside the single card. + for (const entry of BATCH_CLARIFY_QUESTIONS) { + await expect(batchCard.getByText(entry.question)).toHaveCount(1) + } + + // Each question text also appears exactly once in the whole transcript — + // catches a duplicate that mounts outside a form[data-clarify-batch]. + for (const entry of BATCH_CLARIFY_QUESTIONS) { + await expect(page.getByText(entry.question)).toHaveCount(1) + } + + // Answer both questions: stage picks locally (no server traffic yet). + const confirmButton = batchCard.locator('button[type="submit"]') + await expect(confirmButton).toContainText('Confirm and continue') + await expect(confirmButton).toBeDisabled() + + await batchCard.getByRole('button', { name: /Coffee/ }).click() + await expect(confirmButton).toBeDisabled() + + await batchCard.getByRole('button', { name: /Morning/ }).click() + await expect(confirmButton).toBeEnabled() + + // ONE confirm submits the whole batch. + await confirmButton.click() + + // The settled card lists both questions with their locked answers. + const settled = page.locator('[data-clarify-settled]') + await settled.waitFor({ state: 'visible', timeout: 30_000 }) + await expect(settled.getByText(BATCH_CLARIFY_QUESTIONS[0].question)).toBeVisible() + await expect(settled.getByText('Coffee', { exact: true })).toBeVisible() + await expect(settled.getByText(BATCH_CLARIFY_QUESTIONS[1].question)).toBeVisible() + await expect(settled.getByText('Morning', { exact: true })).toBeVisible() + + // And still no duplicate live card lingering after settle. + await expect(page.locator('form[data-clarify-batch]')).toHaveCount(0) + }) +}) diff --git a/apps/desktop/e2e/boot.spec.ts b/apps/desktop/e2e/boot.spec.ts index 3a74fa4cc5..19f2d81fa4 100644 --- a/apps/desktop/e2e/boot.spec.ts +++ b/apps/desktop/e2e/boot.spec.ts @@ -50,6 +50,25 @@ test.describe('dev-mode boot with mock backend', () => { }) }) + // A preload that throws never reaches contextBridge, so the renderer boots + // into "Desktop IPC bridge is unavailable" and every test below it dies on a + // 120s never-became-ready timeout instead. Checking the bridge by name makes + // that failure legible. The sandbox lets preload require only electron, + // events, timers and url — adding any other node builtin lands here. + test('the preload bridge reaches the renderer', async () => { + const bridge = await fixture!.page.evaluate(() => { + const desktop = (window as unknown as { hermesDesktop?: Record }).hermesDesktop + + return { + present: typeof desktop, + glassSupported: typeof desktop?.glassSupported, + translucencySupported: typeof desktop?.translucencySupported + } + }) + + expect(bridge).toEqual({ present: 'object', glassSupported: 'boolean', translucencySupported: 'boolean' }) + }) + test('backend boots and app becomes ready', async () => { // This is the big one — wait for the full boot chain to complete: // electron starts → hermes serve is spawned → WS connects → config diff --git a/apps/desktop/e2e/context-menu-editables.spec.ts b/apps/desktop/e2e/context-menu-editables.spec.ts new file mode 100644 index 0000000000..5f0c3787ab --- /dev/null +++ b/apps/desktop/e2e/context-menu-editables.spec.ts @@ -0,0 +1,123 @@ +/** + * Context-menu edit verbs on real editables — the regressions jsdom cannot + * catch, exercised against the real renderer (real radix focus trap, real + * React unmount timing, real selection). + * + * The class under test: "Select all" from the app context menu must act on + * the FIELD the menu was opened on, never on the surrounding transcript. + * The first fix (focus-restore before dispatch) passed unit tests and still + * failed live because the radix trap steals focus back; the second fix runs + * selection renderer-side after the trap unmounts. These tests pin the + * observable outcome, not the mechanism. + * + * Menu items are addressed by accessible-name PREFIX (`/^Copy/`): the name + * includes the shortcut suffix ("Copy Ctrl+V" / "Copy ⌘V"), which is also + * host-dependent. + */ + +import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures' +import { expect, test } from './test' + +let fixture: MockBackendFixture | null = null + +test.beforeAll(async () => { + fixture = await setupMockBackend() + await waitForAppReady(fixture, 120_000) +}) + +test.afterAll(async () => { + await fixture?.cleanup() + fixture = null +}) + +test('select all from the composer context menu selects the draft, not the chat', async () => { + const page = fixture!.page + const composer = page.locator('[data-slot="composer-rich-input"]').first() + + // Put a message into the transcript so there is chat text a document-wide + // select-all WOULD grab — the bug this test exists to catch. Wait for the + // mock reply to COMPLETE: while the turn is busy the composer is in its + // steer shape and a typed draft does not land in it. + await composer.click() + await composer.pressSequentially('transcript anchor message') + await page.keyboard.press('Enter') + await page.waitForFunction(() => (document.body.textContent ?? '').includes('mock inference server'), undefined, { + timeout: 60_000 + }) + + // Draft text in the composer, then right-click it. + await composer.click() + await composer.pressSequentially('draft under selection') + await composer.click({ button: 'right' }) + + const selectAll = page.getByRole('menuitem', { name: /^Select all/ }) + + await selectAll.waitFor({ state: 'visible', timeout: 10_000 }) + await selectAll.click() + + // The selection must live inside the composer and cover exactly the draft. + await expect + .poll( + () => + page.evaluate(() => { + const selection = window.getSelection() + const editable = document.querySelector('[data-slot="composer-rich-input"]') + + if (!selection || selection.rangeCount === 0 || !editable) { + return { inside: false, text: '' } + } + + return { + inside: editable.contains(selection.getRangeAt(0).commonAncestorContainer), + text: selection.toString() + } + }), + { timeout: 10_000 } + ) + .toEqual({ inside: true, text: 'draft under selection' }) + + // Clear the draft so later tests start clean. + await page.keyboard.press('Delete') +}) + +test('cut, copy, and select all gray out in an empty composer', async () => { + const page = fixture!.page + const composer = page.locator('[data-slot="composer-rich-input"]').first() + + await composer.click() + await composer.click({ button: 'right' }) + + const selectAll = page.getByRole('menuitem', { name: /^Select all/ }) + + await selectAll.waitFor({ state: 'visible', timeout: 10_000 }) + + await expect(selectAll).toHaveAttribute('data-disabled', /.*/) + await expect(page.getByRole('menuitem', { name: /^Cut/ })).toHaveAttribute('data-disabled', /.*/) + await expect(page.getByRole('menuitem', { name: /^Copy/ })).toHaveAttribute('data-disabled', /.*/) + + await page.keyboard.press('Escape') +}) + +test('paste enables when the clipboard holds text', async () => { + const page = fixture!.page + const composer = page.locator('[data-slot="composer-rich-input"]').first() + + // The empty-clipboard branch stays in the unit suite: the e2e app shares + // the SYSTEM clipboard, and writeText('') does not reliably clear it. + await page.evaluate(() => + ( + window as unknown as { hermesDesktop?: { writeClipboard?: (text: string) => Promise } } + ).hermesDesktop?.writeClipboard?.('clipboard payload') + ) + await composer.click() + await composer.click({ button: 'right' }) + + const paste = page.getByRole('menuitem', { name: /^Paste/ }) + + await paste.waitFor({ state: 'visible', timeout: 10_000 }) + + // The clipboard probe is an async IPC — the item enables when it lands. + await expect.poll(() => paste.getAttribute('data-disabled'), { timeout: 10_000 }).toBeNull() + + await page.keyboard.press('Escape') +}) diff --git a/apps/desktop/e2e/mock-server.ts b/apps/desktop/e2e/mock-server.ts index 8de1af8aa4..37b5d15af7 100644 --- a/apps/desktop/e2e/mock-server.ts +++ b/apps/desktop/e2e/mock-server.ts @@ -339,6 +339,40 @@ const BLOCKING_CLARIFY_TURN: ScriptedTurn = { toolCalls: [{ name: 'clarify', args: { question: BLOCKING_CLARIFY_QUESTION, choices: ['Yes', 'No'] } }], } +/** + * A marker that makes the mock emit a blocking BATCH clarify tool call + * (multi-question form). Regression coverage for the duplicated-card bug: + * the tool.start row and the clarify.request row carry different ids and a + * batch payload has no top-level question, so the correlation key must come + * from the question list or the card mounts twice. + */ +export const BATCH_CLARIFY_TRIGGER = 'E2E_BATCH_CLARIFY_TRIGGER' +export const BATCH_CLARIFY_QUESTIONS = [ + { question: 'Pick a batch drink?', choices: ['Coffee', 'Tea'] }, + { question: 'Pick a batch time?', choices: ['Morning', 'Night'] }, +] + +const BATCH_CLARIFY_TURN: ScriptedTurn = { + text: '', + toolCalls: [{ name: 'clarify', args: { questions: BATCH_CLARIFY_QUESTIONS } }], +} + +function includesBatchClarifyTrigger(value: unknown): boolean { + if (typeof value === 'string') { + return value.includes(BATCH_CLARIFY_TRIGGER) + } + + if (Array.isArray(value)) { + return value.some(includesBatchClarifyTrigger) + } + + if (value && typeof value === 'object') { + return Object.values(value).some(includesBatchClarifyTrigger) + } + + return false +} + function includesBlockingClarifyTrigger(value: unknown): boolean { if (typeof value === 'string') { return value.includes(BLOCKING_CLARIFY_TRIGGER) @@ -484,6 +518,24 @@ export function startMockServer(options: MockServerOptions = {}): Promise message?.role === 'tool') + + if (!hasToolResult) { + if (stream) { + streamScriptedTurn(res, model, BATCH_CLARIFY_TURN) + } else { + nonStreamingScriptedTurn(res, model, BATCH_CLARIFY_TURN) + } + return + } + } + if (includesBlockingClarifyTrigger(parsed.messages)) { if (stream) { streamScriptedTurn(res, model, BLOCKING_CLARIFY_TURN) diff --git a/apps/desktop/electron/backend-start-failure.test.ts b/apps/desktop/electron/backend-start-failure.test.ts index f535266f95..f612171f46 100644 --- a/apps/desktop/electron/backend-start-failure.test.ts +++ b/apps/desktop/electron/backend-start-failure.test.ts @@ -3,8 +3,10 @@ import assert from 'node:assert/strict' import { test } from 'vitest' import { + isHostKeyChangedBootFailure, isRetryableRemoteBootFailure, shouldLatchBackendStartFailure, + shouldLatchHostKeyChangedFailure, shouldLatchRemoteReauthFailure } from './backend-start-failure' @@ -81,3 +83,53 @@ test('retryable and reauth-latch are mutually exclusive for remote failures', () assert.equal(retry !== latch, true, `remote failure with reauth=${isReauth} must pick exactly one path`) } }) + +test('FIX host-key change: classified from the kind tag and from stringified ssh banners', () => { + // classifySshError tags the Error it built; errors that crossed an IPC or + // string boundary only keep the message. Both shapes must classify. + const tagged = Object.assign(new Error('SSH refused to connect.'), { kind: 'host-key-changed' }) + assert.equal(isHostKeyChangedBootFailure(tagged), true) + assert.equal( + isHostKeyChangedBootFailure(new Error('@@@@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @@@@')), + true + ) + assert.equal(isHostKeyChangedBootFailure(new Error('Host key verification failed.')), true) + assert.equal( + isHostKeyChangedBootFailure(new Error('The host key for root@203.0.113.7 has CHANGED since you last connected.')), + true + ) + assert.equal(isHostKeyChangedBootFailure(new Error('Connection refused')), false) + assert.equal(isHostKeyChangedBootFailure(null), false) +}) + +test('FIX host-key change: latches and is never auto-retried (157-failure loop, Aug 2026 bundle)', () => { + // SSH fails closed on a changed host key: every retry re-drives the same + // doomed boot until the user clears known_hosts. Terminal, like reauth. + const context = { attemptedRemote: true, isReauth: false, isHostKeyChanged: true } + assert.equal(shouldLatchHostKeyChangedFailure(context), true) + assert.equal(isRetryableRemoteBootFailure(context), false) +}) + +test('host-key latch never fires for local failures or ordinary remote faults', () => { + assert.equal( + shouldLatchHostKeyChangedFailure({ attemptedRemote: false, isReauth: false, isHostKeyChanged: true }), + false + ) + assert.equal( + shouldLatchHostKeyChangedFailure({ attemptedRemote: true, isReauth: false, isHostKeyChanged: false }), + false + ) + assert.equal(shouldLatchHostKeyChangedFailure({ attemptedRemote: true, isReauth: false }), false) +}) + +test('every remote failure picks exactly one path: retry, reauth latch, or host-key latch', () => { + for (const isReauth of [true, false]) { + for (const isHostKeyChanged of [true, false]) { + const retry = isRetryableRemoteBootFailure({ attemptedRemote: true, isReauth, isHostKeyChanged }) + const reauth = shouldLatchRemoteReauthFailure({ attemptedRemote: true, isReauth }) + const hostKey = shouldLatchHostKeyChangedFailure({ attemptedRemote: true, isReauth, isHostKeyChanged }) + const picked = [retry, reauth, hostKey].filter(Boolean).length + assert.ok(picked >= 1, `remote failure reauth=${isReauth} hostKey=${isHostKeyChanged} fell through every path`) + } + } +}) diff --git a/apps/desktop/electron/backend-start-failure.ts b/apps/desktop/electron/backend-start-failure.ts index efc4361637..9c89822397 100644 --- a/apps/desktop/electron/backend-start-failure.ts +++ b/apps/desktop/electron/backend-start-failure.ts @@ -79,6 +79,44 @@ export interface RemoteBootRetryContext { * never self-heal without the user signing in again. */ isReauth: boolean + /** + * True when SSH refused to connect because the host's key CHANGED + * (StrictHostKeyChecking fails closed). Retrying cannot succeed until the + * user verifies the change and removes the stale known_hosts entry, so this + * is terminal like a reauth rejection — not connectivity. + */ + isHostKeyChanged?: boolean +} + +/** + * A host-key-change refusal is identifiable both by the `kind` tag + * classifySshError puts on the error and — for errors that crossed a + * stringifying boundary — by the stable phrases ssh/our own message carry. + * One user hit 157 consecutive boot-retry failures over 2.5h against a + * reinstalled VPS (Aug 2026 bundle) because this was classified as transient. + */ +export function isHostKeyChangedBootFailure(error: unknown): boolean { + if ((error as { kind?: string } | null | undefined)?.kind === 'host-key-changed') { + return true + } + + const message = error instanceof Error ? error.message : String(error ?? '') + + return /REMOTE HOST IDENTIFICATION HAS CHANGED|Host key verification failed|host key for .+ has CHANGED/i.test( + message + ) +} + +/** + * Whether a failed remote boot should latch (into `backendStartFailure`) + * because the host key changed. Same rationale as the reauth latch: the + * failure cannot self-heal, and an unlatched terminal failure makes every + * recovery surface re-drive the identical doomed boot. The latch is released + * by the existing reset/repair/apply-config paths once the user has run + * `ssh-keygen -R `. + */ +export function shouldLatchHostKeyChangedFailure(context: RemoteBootRetryContext): boolean { + return context.attemptedRemote && context.isHostKeyChanged === true } /** @@ -93,9 +131,10 @@ export interface RemoteBootRetryContext { * only arms after a completed boot, so the app sat on "Desktop boot failed" * until the user manually re-entered the same connection details (which just * forced a fresh bootstrap). A missing capability differs from a transient - * failure: confirmed reauth rejections and local failures stay out of the - * retry path; everything else remote is connectivity and should retry. + * failure: confirmed reauth rejections, host-key changes, and local failures + * stay out of the retry path; everything else remote is connectivity and + * should retry. */ export function isRetryableRemoteBootFailure(context: RemoteBootRetryContext): boolean { - return context.attemptedRemote && !context.isReauth + return context.attemptedRemote && !context.isReauth && context.isHostKeyChanged !== true } diff --git a/apps/desktop/electron/connection-config.test.ts b/apps/desktop/electron/connection-config.test.ts index 7b61b3bf4f..8bc40e639a 100644 --- a/apps/desktop/electron/connection-config.test.ts +++ b/apps/desktop/electron/connection-config.test.ts @@ -455,10 +455,13 @@ test('apiRequestRegistryConnectionId extracts a genuinely non-local connection i assert.equal(apiRequestRegistryConnectionId({ connectionId: ' gw-1 ', path: '/x' }), 'gw-1') }) -test('apiRequestRegistryConnectionId resolves null for the legacy/local routes', () => { +test('apiRequestRegistryConnectionId preserves an explicit local registry route', () => { + assert.equal(apiRequestRegistryConnectionId({ connectionId: 'local', path: '/x' }), 'local') +}) + +test('apiRequestRegistryConnectionId resolves null for unscoped legacy routes', () => { assert.equal(apiRequestRegistryConnectionId({ path: '/api/cron/jobs' }), null) assert.equal(apiRequestRegistryConnectionId({ connectionId: '', path: '/x' }), null) - assert.equal(apiRequestRegistryConnectionId({ connectionId: 'local', path: '/x' }), null) assert.equal(apiRequestRegistryConnectionId({ connectionId: null, path: '/x' }), null) assert.equal(apiRequestRegistryConnectionId(null), null) assert.equal(apiRequestRegistryConnectionId(undefined), null) @@ -715,6 +718,37 @@ test('resolveProfileApiRequest scopes complete safe families according to their ) }) +test('resolveProfileApiRequest routes action-status polls with the action-spawning routes', () => { + // /api/actions/{name}/status must land on the SAME backend as the endpoints + // that spawn actions (skills hub install/uninstall/update, mcp catalog + // install): _spawn_hermes_action registers the dynamic action name only in + // the spawning process. Splitting the pair 404s the poll with + // "Unknown action: skills-install--". + assert.deepEqual( + resolveProfileApiRequest('iris', '/api/actions/skills-install-ascii-art-dd7bccf1/status?lines=200', { + requestMethod: 'GET' + }), + { + backendProfile: null, + requestPath: '/api/actions/skills-install-ascii-art-dd7bccf1/status?lines=200&profile=iris' + } + ) + // The spawn side (hub install) and the poll side must agree on the backend. + assert.deepEqual( + resolveProfileApiRequest('iris', '/api/skills/hub/install', { + requestMethod: 'POST' + }), + { backendProfile: null, requestPath: '/api/skills/hub/install?profile=iris' } + ) + // MCP catalog installs spawn background actions too — same pairing rule. + assert.deepEqual( + resolveProfileApiRequest('iris', '/api/mcp/catalog/install', { + requestMethod: 'POST' + }), + { backendProfile: null, requestPath: '/api/mcp/catalog/install?profile=iris' } + ) +}) + test('resolveProfileApiRequest preserves remote routing precedence', () => { assert.deepEqual( resolveProfileApiRequest('iris', '/api/memory/reset', { diff --git a/apps/desktop/electron/connection-config.ts b/apps/desktop/electron/connection-config.ts index 88c3ae2e00..1934fbec0d 100644 --- a/apps/desktop/electron/connection-config.ts +++ b/apps/desktop/electron/connection-config.ts @@ -548,7 +548,11 @@ const LOCAL_PRIMARY_SCOPED_ROUTES = new Set([ 'GET /api/skills/hub/search', 'GET /api/skills/hub/sources', 'POST /api/skills/hub/uninstall', - 'POST /api/skills/hub/update' + 'POST /api/skills/hub/update', + // Spawns a background action polled via /api/actions/{name}/status — must + // live on the SAME backend as that poll family (below), or the poll asks a + // backend that never registered the dynamic action name and 404s. + 'POST /api/mcp/catalog/install' ]) function localPrimaryRequestScope(opts: ProfileRouteOptions): boolean | null { @@ -572,6 +576,16 @@ function localPrimaryRequestScope(opts: ProfileRouteOptions): boolean | null { return true } + // Action-status polls MUST land on the same backend as the endpoints that + // spawned them: `_spawn_hermes_action` registers the (often dynamic, e.g. + // `skills-install--`) action name only in the spawning + // process's memory. Every action-spawning route above scopes to the + // primary, so the poll family follows — a pooled-backend poll 404s with + // "Unknown action" even though the install itself succeeded (#89xxx). + if (pathname.startsWith('/api/actions/')) { + return true + } + // Every current /api/tools handler accepts `profile`; every /api/profiles // handler either aggregates profiles or names its target in the path/body. // These are the only whole families safe to route through the primary. @@ -750,15 +764,16 @@ function pathWithProfileScope(path, profile) { /** * Registry connection a REST request is explicitly pinned to, or null for the - * legacy profile-routed path. `''`/`'local'` mean the local pool — callers - * only detour through the registry for a genuinely non-local connection, so - * single-source users keep the byte-identical v1 route. + * legacy profile-routed path. An explicit `local` id must stay registry-scoped: + * when the v1 route is remote, only the registry resolver can force the request + * back to this device. Single-source users omit the id and keep the + * byte-identical v1 route. */ function apiRequestRegistryConnectionId(request): null | string { const raw = request && typeof request === 'object' ? (request as { connectionId?: unknown }).connectionId : '' const id = String(raw ?? '').trim() - if (!id || id === 'local') { + if (!id) { return null } diff --git a/apps/desktop/electron/connection-registry.test.ts b/apps/desktop/electron/connection-registry.test.ts index a1f2bbb7a0..d357597a01 100644 --- a/apps/desktop/electron/connection-registry.test.ts +++ b/apps/desktop/electron/connection-registry.test.ts @@ -28,7 +28,10 @@ import { REGISTRY_VERSION, rememberSshEnumeration, removeConnection, + resolvedConnectionId, resolveRegistryLocalRoute, + setConnectionLaunchMode, + setLastUsedConnection, setPrimaryConnection, shouldDeferLocalEnumeration, shouldRetrySshInventory, @@ -54,6 +57,49 @@ test('labelSlug kebab-cases and never returns empty for non-empty input', () => assert.equal(labelSlug('!!!'), 'connection') }) +test('resolvedConnectionId identifies local and migrated remote descriptors', () => { + const registry = migrateV1ToRegistry({ + mode: 'local', + profiles: { + personal: { mode: 'remote', url: 'https://personal.example:9443/', authMode: 'token' }, + work: { mode: 'ssh', host: 'work-host', user: 'root' } + } + }) + + const personal = registry.connections.find(connection => connection.kind === 'remote') + const work = registry.connections.find(connection => connection.kind === 'ssh') + + assert.equal(resolvedConnectionId(registry, { mode: 'local' }), LOCAL_CONNECTION_ID) + assert.equal( + resolvedConnectionId(registry, { + baseUrl: 'https://personal.example:9443', + mode: 'remote', + remoteKind: 'url' + }), + personal?.id + ) + assert.equal( + resolvedConnectionId(registry, { + baseUrl: 'http://127.0.0.1:49152', + mode: 'remote', + remoteHost: 'root@work-host', + remoteKind: 'ssh' + }), + work?.id + ) +}) + +test('resolvedConnectionId does not guess an unregistered remote', () => { + assert.equal( + resolvedConnectionId(emptyRegistry(), { + baseUrl: 'https://unknown.example', + mode: 'remote', + remoteKind: 'url' + }), + null + ) +}) + test('agentHandle bare when unique, @name-device shape when duplicated', () => { assert.equal(agentHandle('research', 'Homelab', false), 'research') assert.equal(agentHandle('research', 'Homelab', true), 'research-homelab') @@ -225,6 +271,28 @@ test('rememberSshEnumeration: live list wins, cache then seed default', () => { }) }) +test('rememberSshEnumeration: a bounced remote source keeps its last-known roster (4-bots-show-as-2)', () => { + // A VPS restart makes the remote source unreachable for a few polls. The + // last successful enumeration must keep painting so the roster does not + // silently drop that source's bots mid-outage. + assert.deepEqual( + rememberSshEnumeration({ profiles: null, error: 'unreachable' }, ['default', 'ceo', 'accounter'], 'remote'), + { profiles: ['default', 'ceo', 'accounter'], error: 'unreachable' } + ) + // Never-seen remote source: no seed — an unreachable URL is not evidence a + // backend exists there. + assert.deepEqual(rememberSshEnumeration({ profiles: null, error: 'unreachable' }, null, 'remote'), { + profiles: null, + error: 'unreachable' + }) + // Local enumeration failures never reuse a cache (the local runtime answers + // authoritatively or not at all). + assert.deepEqual(rememberSshEnumeration({ profiles: null, error: 'boom' }, ['default'], 'local'), { + profiles: null, + error: 'boom' + }) +}) + test('shouldRetrySshInventory: first try, cooldown, then retry; cache never retries', () => { assert.equal(shouldRetrySshInventory(false, null, 1_000), true) assert.equal(shouldRetrySshInventory(false, 1_000, 30_000, 60_000), false) @@ -644,6 +712,8 @@ test('normalizeRegistry degrades junk to a local-only registry', () => { assert.equal(registry.version, REGISTRY_VERSION) assert.equal(registry.primary, LOCAL_CONNECTION_ID) + assert.equal(registry.launchMode, 'primary') + assert.equal(registry.lastUsed, LOCAL_CONNECTION_ID) assert.equal(registry.connections.length, 1) assert.equal(registry.connections[0].kind, 'local') } @@ -675,6 +745,8 @@ test('normalizeRegistry round-trips a valid registry unchanged in shape', () => const input = { version: 2, primary: 'homelab', + launchMode: 'last-used', + lastUsed: 'homelab', connections: [ { id: 'local', kind: 'local', label: 'This device' }, { @@ -700,6 +772,8 @@ test('normalizeRegistry round-trips a valid registry unchanged in shape', () => const registry = normalizeRegistry(input) assert.equal(registry.primary, 'homelab') + assert.equal(registry.launchMode, 'last-used') + assert.equal(registry.lastUsed, 'homelab') assert.equal(registry.connections.length, 4) assert.deepEqual( registry.connections.map(c => c.id), @@ -709,6 +783,22 @@ test('normalizeRegistry round-trips a valid registry unchanged in shape', () => assert.equal(registry.connections[3].port, 2222) }) +test('normalizeRegistry falls back to Primary when the last-used source is missing', () => { + const registry = normalizeRegistry({ + version: 2, + primary: 'homelab', + launchMode: 'last-used', + lastUsed: 'retired-host', + connections: [ + { id: 'local', kind: 'local', label: 'This device' }, + { id: 'homelab', kind: 'remote', label: 'Homelab', url: 'http://10.0.0.5:9119' } + ] + }) + + assert.equal(registry.launchMode, 'last-used') + assert.equal(registry.lastUsed, 'homelab') +}) + // --- v1 → v2 migration --- test('migrate: v1 local-only config → local-only registry', () => { @@ -793,17 +883,19 @@ test('migrate: duplicate host labels are suffixed, not dropped', () => { // --- registry operations --- -test('removeConnection: local refuses, primary retargets to local', () => { +test('removeConnection: local refuses, primary and last-used retarget safely', () => { let registry = emptyRegistry() const entry = normalizeConnectionInput({ kind: 'remote', label: 'Homelab', url: 'http://10.0.0.5:9119' }, registry) registry = upsertConnection(registry, entry) registry = setPrimaryConnection(registry, entry.id) + registry = setLastUsedConnection(registry, entry.id) assert.throws(() => removeConnection(registry, LOCAL_CONNECTION_ID), /cannot be removed/) const after = removeConnection(registry, entry.id) assert.equal(after.primary, LOCAL_CONNECTION_ID) + assert.equal(after.lastUsed, LOCAL_CONNECTION_ID) assert.equal(after.connections.length, 1) // Removing an unknown id is a no-op, not an error. assert.equal(removeConnection(after, 'ghost'), after) @@ -816,6 +908,17 @@ test('setPrimaryConnection validates the target id', () => { assert.equal(setPrimaryConnection(registry, LOCAL_CONNECTION_ID).primary, LOCAL_CONNECTION_ID) }) +test('last-used source and launch mode validate their persisted values', () => { + let registry = emptyRegistry() + const entry = normalizeConnectionInput({ kind: 'remote', label: 'Homelab', url: 'http://10.0.0.5:9119' }, registry) + registry = upsertConnection(registry, entry) + + assert.throws(() => setLastUsedConnection(registry, 'ghost'), /No connection/) + assert.equal(setLastUsedConnection(registry, entry.id).lastUsed, entry.id) + assert.equal(setConnectionLaunchMode(registry, 'last-used').launchMode, 'last-used') + assert.throws(() => setConnectionLaunchMode(registry, 'sometimes'), /Unknown connection launch mode/) +}) + test('upsertConnection replaces by id and appends new ids', () => { let registry = emptyRegistry() const a = normalizeConnectionInput({ kind: 'remote', label: 'A', url: 'http://a:1' }, registry) diff --git a/apps/desktop/electron/connection-registry.ts b/apps/desktop/electron/connection-registry.ts index c9dcc09b2c..cec23fea90 100644 --- a/apps/desktop/electron/connection-registry.ts +++ b/apps/desktop/electron/connection-registry.ts @@ -76,6 +76,11 @@ export interface ConnectionRegistry { version: typeof REGISTRY_VERSION /** id of the connection that owns the window/primary backend. */ primary: string + /** Which saved source Sessions should restore when the app launches. */ + launchMode: 'last-used' | 'primary' + /** Last source the Sessions workspace successfully opened. Additive in v2 + * so registries written before multi-source switching still normalize. */ + lastUsed: string connections: RegistryConnection[] } @@ -178,6 +183,79 @@ export interface RegistryLocalRoute { poolKey: string } +export interface ResolvedConnectionDescriptor { + baseUrl?: string + mode?: 'local' | 'remote' + remoteHost?: string + remoteKind?: 'cloud' | 'ssh' | 'url' +} + +/** + * Recover registry identity for a descriptor resolved through the legacy v1 + * profile path. Registry-scoped routes already carry `connectionId`; this + * bridge keeps migrated per-profile remotes truthful until v1 is retired. + */ +export function resolvedConnectionId( + registry: ConnectionRegistry, + descriptor: ResolvedConnectionDescriptor +): null | string { + if (descriptor.mode === 'local') { + return registry.connections.find(connection => connection.kind === 'local')?.id ?? null + } + + if (descriptor.mode !== 'remote') { + return null + } + + if (descriptor.remoteKind === 'ssh') { + const remoteHost = String(descriptor.remoteHost || '') + .trim() + .toLowerCase() + + if (!remoteHost) { + return null + } + + return ( + registry.connections.find(connection => { + if (connection.kind !== 'ssh') { + return false + } + + const host = String(connection.host || '') + .trim() + .toLowerCase() + + const target = connection.user ? `${String(connection.user).trim().toLowerCase()}@${host}` : host + + return target === remoteHost + })?.id ?? null + ) + } + + let baseUrl = '' + + try { + baseUrl = normalizeRemoteBaseUrl(descriptor.baseUrl) + } catch { + return null + } + + return ( + registry.connections.find(connection => { + if (connection.kind !== 'cloud' && connection.kind !== 'remote') { + return false + } + + try { + return normalizeRemoteBaseUrl(connection.url) === baseUrl + } catch { + return false + } + })?.id ?? null + ) +} + /** * How the registry's 'local' entry resolves a backend for `profile`. * @@ -261,10 +339,15 @@ export interface RosterAgent { } /** - * SSH roster enumeration skips undialed sources (connect-on-demand). Reuse the - * last successful profile list so Bot Mode does not go empty the moment the - * window switches back to local. Never-seen SSH sources still get a `default` - * seed so the device is clickable. + * Roster enumeration skips undialed sources (connect-on-demand) and reports + * unreachable ones with `profiles: null`. Reuse the last successful profile + * list so Bot Mode does not go empty (or drop to a partial roster) the moment + * a source is briefly unreachable — SSH tunnels drop on sleep/wake, and a + * remote gateway bounce (VPS restart) otherwise erased its bots from the + * roster until the next successful enumeration ("my 4 bots show as 2", Aug + * 2026 bundle). Never-seen SSH sources still get a `default` seed so the + * device is clickable; never-seen remote sources stay empty (no seed) since + * an unreachable URL is not evidence a backend exists there. */ export function rememberSshEnumeration( enumeration: Pick, @@ -275,7 +358,7 @@ export function rememberSshEnumeration( return enumeration } - if (kind !== 'ssh') { + if (kind === 'local') { return enumeration } @@ -283,7 +366,7 @@ export function rememberSshEnumeration( return { profiles: cached, error: enumeration.error } } - if (enumeration.error === 'connect-on-demand') { + if (kind === 'ssh' && enumeration.error === 'connect-on-demand') { return { profiles: ['default'], error: 'connect-on-demand' } } @@ -818,11 +901,15 @@ export function normalizeRegistry(raw: unknown): ConnectionRegistry { connections.unshift(localEntry()) } - const primary = String(parsed.primary || '').trim() + const storedPrimary = String(parsed.primary || '').trim() + const primary = connections.some(c => c.id === storedPrimary) ? storedPrimary : LOCAL_CONNECTION_ID + const storedLastUsed = String(parsed.lastUsed || '').trim() return { version: REGISTRY_VERSION, - primary: connections.some(c => c.id === primary) ? primary : LOCAL_CONNECTION_ID, + primary, + launchMode: parsed.launchMode === 'last-used' ? 'last-used' : 'primary', + lastUsed: connections.some(c => c.id === storedLastUsed) ? storedLastUsed : primary, connections } } @@ -967,7 +1054,7 @@ export function migrateV1ToRegistry(v1: unknown): ConnectionRegistry { } } - return { version: REGISTRY_VERSION, primary, connections } + return { version: REGISTRY_VERSION, primary, launchMode: 'primary', lastUsed: primary, connections } } /** Insert or replace by id. Input must already be normalized/validated. */ @@ -994,9 +1081,12 @@ export function removeConnection(registry: ConnectionRegistry, id: string): Conn throw new Error('The local connection cannot be removed.') } + const primary = registry.primary === id ? LOCAL_CONNECTION_ID : registry.primary + return { ...registry, - primary: registry.primary === id ? LOCAL_CONNECTION_ID : registry.primary, + primary, + lastUsed: registry.lastUsed === id ? primary : registry.lastUsed, connections: registry.connections.filter(c => c.id !== id) } } @@ -1009,3 +1099,21 @@ export function setPrimaryConnection(registry: ConnectionRegistry, id: string): return { ...registry, primary: id } } + +/** Remember the last source the Sessions workspace opened successfully. */ +export function setLastUsedConnection(registry: ConnectionRegistry, id: string): ConnectionRegistry { + if (!registry.connections.some(c => c.id === id)) { + throw new Error(`No connection with id "${id}".`) + } + + return { ...registry, lastUsed: id } +} + +/** Choose whether launch restores the explicit primary or the last-used source. */ +export function setConnectionLaunchMode(registry: ConnectionRegistry, launchMode: string): ConnectionRegistry { + if (launchMode !== 'last-used' && launchMode !== 'primary') { + throw new Error(`Unknown connection launch mode "${String(launchMode)}".`) + } + + return { ...registry, launchMode } +} diff --git a/apps/desktop/electron/fs-ipc.ts b/apps/desktop/electron/fs-ipc.ts new file mode 100644 index 0000000000..ff25db2013 --- /dev/null +++ b/apps/desktop/electron/fs-ipc.ts @@ -0,0 +1,197 @@ +// IPC surface for local filesystem operations the renderer's project/file +// surfaces use: directory reads, reveal/open in the OS file manager, plugin +// roots + git installs, rename/write/trash. Extracted from main.ts; path +// hardening, HERMES_HOME resolution, and the git binary stay injected. +import fs from 'node:fs' +import path from 'node:path' + +import { ipcMain, shell } from 'electron' + +import { installDesktopPluginFromGit, probePluginRepo } from './desktop-plugin-install' +import { readDirForIpc } from './fs-read-dir' +import { gitRootForIpc } from './git-root' + +export interface FsIpcDeps { + hermesHome: string + readActiveDesktopProfile: () => null | string + expandUserPath: (value: string) => string + resolveRequestedPathForIpc: (value: string, options: { purpose: string }) => string + directoryExists: (value: string) => boolean + resolveGitBinary: () => string +} + +export function registerFsIpc({ + hermesHome, + readActiveDesktopProfile, + expandUserPath, + resolveRequestedPathForIpc, + directoryExists, + resolveGitBinary +}: FsIpcDeps) { + ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dirPath)) + + ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath)) + + // Reveal a path in the OS file manager (Finder / Explorer / Files). + ipcMain.handle('hermes:fs:reveal', async (_event, targetPath) => { + const target = String(targetPath || '').trim() + + if (!target) { + return false + } + + try { + shell.showItemInFolder(target) + + return true + } catch { + return false + } + }) + + // Open a DIRECTORY in the OS file manager, creating it first if needed. Unlike + // `reveal` (which selects an existing item and silently no-ops on a missing + // path — the "Open plugins folder" Windows bug), this is for the plugins door, + // which often doesn't exist on first use. `shell.openPath` returns '' on + // success or an error string; both mkdir + openPath failures are surfaced. + ipcMain.handle('hermes:fs:openDir', async (_event, dirPath) => { + const dir = String(dirPath || '').trim() + + if (!dir) { + return { ok: false, error: 'no path' } + } + + try { + await fs.promises.mkdir(dir, { recursive: true }) + const error = await shell.openPath(path.normalize(dir)) + + return error ? { ok: false, error } : { ok: true } + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } + }) + + // The LOCAL Desktop runtime-plugin root: `/desktop-plugins`, + // resolved from the main-process HERMES_HOME (see resolveHermesHome) — NOT from + // the connected backend. A remote backend reports its own `hermes_home` over + // the gateway, which is a path on the REMOTE box; deriving the plugin dir from + // it yields `undefined/desktop-plugins` (or a non-existent remote path) and the + // on-disk plugin door silently breaks (#66899). Electron owns this resolution + // so it stays valid in every connection mode. Created on demand, like openDir. + async function localPluginsRoot(dirName: string): Promise { + // Profile-aware: a named Desktop profile gets its own plugin root under + // profiles//, matching the profile-scoped hermes_home the backend + // reported before this resolver existed. 'default'/unset pins the global root. + const profile = readActiveDesktopProfile() + const base = profile && profile !== 'default' ? path.join(hermesHome, 'profiles', profile) : hermesHome + const dir = path.join(base, dirName) + + try { + await fs.promises.mkdir(dir, { recursive: true }) + } catch { + // Best-effort create; return the path regardless so the reveal action can + // still surface a real openPath error and the scanner can retry later. + } + + return dir + } + + ipcMain.handle('hermes:fs:desktopPluginsRoot', async () => localPluginsRoot('desktop-plugins')) + + // The LOCAL agent-plugin root (`/plugins`), same Electron-local + // resolution as above. This is the desktop half of a UNIFIED plugin package: + // an agent plugin may ship `desktop/plugin.js` alongside its Python code (the + // same shape as `dashboard/manifest.json`), and the renderer's disk door scans + // this root for it — one installable folder serving both SDKs. + ipcMain.handle('hermes:fs:agentPluginsRoot', async () => localPluginsRoot('plugins')) + + ipcMain.handle('hermes:plugin:probe', async (_event, payload) => { + const identifier = String(payload?.identifier || payload?.repo || '').trim() + + if (!identifier) { + return { ok: false, error: 'identifier is required', agent: false, desktop: false, warnings: [] } + } + + return probePluginRepo(resolveGitBinary(), identifier) + }) + + ipcMain.handle('hermes:plugin:installDesktop', async (_event, payload) => { + const identifier = String(payload?.identifier || payload?.repo || '').trim() + + if (!identifier) { + return { ok: false, error: 'identifier is required' } + } + + const desktopPluginsRoot = await localPluginsRoot('desktop-plugins') + + return installDesktopPluginFromGit(resolveGitBinary(), identifier, desktopPluginsRoot, Boolean(payload?.force)) + }) + + // Rename a file/folder in place. The renderer passes the existing path + a new + // base name; the destination is resolved in the SAME parent dir so a rename can + // never move the item elsewhere or traverse out. Rejects on a name collision. + ipcMain.handle('hermes:fs:rename', async (_event, targetPath, newName) => { + const src = String(targetPath || '').trim() + const name = String(newName || '').trim() + + if (!src || !name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) { + throw new Error('Invalid rename') + } + + const dst = path.join(path.dirname(src), name) + + if (dst === src) { + return { path: dst } + } + + if (fs.existsSync(dst)) { + throw new Error(`"${name}" already exists`) + } + + await fs.promises.rename(src, dst) + + return { path: dst } + }) + + // Write a small UTF-8 text file (e.g. a project's IDEA.md at creation). The path + // is hardened (resolveRequestedPathForIpc) and the parent must already exist — + // this never creates directory trees or escapes the allowed roots, and content + // is size-capped so it can't be abused as a bulk-write primitive. + ipcMain.handle('hermes:fs:writeText', async (_event, filePath, content) => { + const raw = String(filePath || '').trim() + + if (!raw) { + throw new Error('Invalid path') + } + + const text = String(content ?? '') + + if (text.length > 1_000_000) { + throw new Error('Content too large') + } + + const resolved = resolveRequestedPathForIpc(expandUserPath(raw), { purpose: 'Write text file' }) + + if (!directoryExists(path.dirname(resolved))) { + throw new Error('Parent directory does not exist') + } + + await fs.promises.writeFile(resolved, text, 'utf8') + + return { path: resolved } + }) + + // Move a file/folder to the OS trash (recoverable) — the VS Code "Delete" + // default. `shell.trashItem` routes to Finder/Explorer/Files trash per platform. + ipcMain.handle('hermes:fs:trash', async (_event, targetPath) => { + const target = String(targetPath || '').trim() + + if (!target) { + throw new Error('Invalid delete') + } + + await shell.trashItem(target) + + return true + }) +} diff --git a/apps/desktop/electron/git-ipc.ts b/apps/desktop/electron/git-ipc.ts new file mode 100644 index 0000000000..105307d03e --- /dev/null +++ b/apps/desktop/electron/git-ipc.ts @@ -0,0 +1,120 @@ +// IPC surface for git-driven features: worktree management ("Start work"), +// the composer coding rail's repo status, the Codex-style review pane, and +// repo-first project discovery. Extracted from main.ts; the git/gh binary +// resolvers stay injected because main.ts also uses them for self-update and +// plugin installs. +import { ipcMain } from 'electron' + +import { scanGitRepos } from './git-repo-scan' +import { + fileDiffVsHead, + repoStatus, + reviewCommit, + reviewCommitContext, + reviewCreatePr, + reviewDiff, + reviewFetchPrComment, + reviewList, + reviewPrList, + reviewPush, + reviewRevert, + reviewRevParse, + reviewShipInfo, + reviewStage, + reviewUnstage +} from './git-review-ops' +import { + addWorktree, + listBaseBranches, + listBranches, + listWorktrees, + removeWorktree, + switchBranch +} from './git-worktree-ops' + +export interface GitIpcDeps { + resolveGitBinary: () => string + resolveGhBinary: () => string +} + +export function registerGitIpc({ resolveGitBinary, resolveGhBinary }: GitIpcDeps) { + // Git-driven worktree management ("Start work" flow). Errors surface to the + // renderer as rejected promises so it can toast a friendly message. + ipcMain.handle('hermes:git:worktreeList', async (_event, repoPath) => listWorktrees(repoPath, resolveGitBinary())) + + ipcMain.handle('hermes:git:worktreeAdd', async (_event, repoPath, options) => + addWorktree(repoPath, options || {}, resolveGitBinary()) + ) + + ipcMain.handle('hermes:git:worktreeRemove', async (_event, repoPath, worktreePath, options) => + removeWorktree(repoPath, worktreePath, options || {}, resolveGitBinary()) + ) + + ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) => + switchBranch(repoPath, branch, resolveGitBinary()) + ) + + ipcMain.handle('hermes:git:branchList', async (_event, repoPath) => listBranches(repoPath, resolveGitBinary())) + + ipcMain.handle('hermes:git:baseBranchList', async (_event, repoPath) => + listBaseBranches(repoPath, resolveGitBinary()) + ) + + // Compact repo status (branch, ahead/behind, change counts + files) for the + // composer coding rail. Returns null on a non-repo / remote backend so the rail + // hides cleanly rather than erroring. + ipcMain.handle('hermes:git:repoStatus', async (_event, repoPath) => repoStatus(repoPath, resolveGitBinary())) + + // Codex-style review pane: list changed files for a scope, fetch one file's + // unified diff, and stage / unstage / revert. Reads return empty on failure; + // mutations reject so the renderer can toast. + ipcMain.handle('hermes:git:review:list', async (_event, repoPath, scope, baseRef) => + reviewList(repoPath, scope, baseRef, resolveGitBinary()) + ) + ipcMain.handle('hermes:git:review:diff', async (_event, repoPath, filePath, scope, baseRef, staged) => + reviewDiff(repoPath, filePath, scope, baseRef, staged, resolveGitBinary()) + ) + // Working-tree-vs-HEAD diff for one file (the preview's "show the diff" view). + ipcMain.handle('hermes:git:fileDiff', async (_event, repoPath, filePath) => + fileDiffVsHead(repoPath, filePath, resolveGitBinary()) + ) + ipcMain.handle('hermes:git:review:stage', async (_event, repoPath, filePath) => + reviewStage(repoPath, filePath ?? null, resolveGitBinary()) + ) + ipcMain.handle('hermes:git:review:unstage', async (_event, repoPath, filePath) => + reviewUnstage(repoPath, filePath ?? null, resolveGitBinary()) + ) + ipcMain.handle('hermes:git:review:revert', async (_event, repoPath, filePath) => + reviewRevert(repoPath, filePath ?? null, resolveGitBinary()) + ) + ipcMain.handle('hermes:git:review:revParse', async (_event, repoPath, ref) => + reviewRevParse(repoPath, ref, resolveGitBinary()) + ) + ipcMain.handle('hermes:git:review:commit', async (_event, repoPath, message, push) => + reviewCommit(repoPath, message, Boolean(push), resolveGitBinary()) + ) + ipcMain.handle('hermes:git:review:commitContext', async (_event, repoPath) => + reviewCommitContext(repoPath, resolveGitBinary()) + ) + ipcMain.handle('hermes:git:review:push', async (_event, repoPath) => reviewPush(repoPath, resolveGitBinary())) + ipcMain.handle('hermes:git:review:shipInfo', async (_event, repoPath) => reviewShipInfo(repoPath, resolveGhBinary())) + ipcMain.handle('hermes:git:review:prList', async (_event, repoPath, branches, numbers) => + reviewPrList(repoPath, resolveGhBinary(), branches, numbers) + ) + ipcMain.handle('hermes:git:review:fetchPrComment', async (_event, repoPath, url) => + reviewFetchPrComment(repoPath, resolveGhBinary(), url) + ) + ipcMain.handle('hermes:git:review:createPr', async (_event, repoPath) => + reviewCreatePr(repoPath, resolveGitBinary(), resolveGhBinary()) + ) + + // Repo-first project discovery: scan bounded roots for git repos (pure fs walk, + // no native addon). Never throws to the renderer — failures yield an empty list. + ipcMain.handle('hermes:git:scanRepos', async (_event, roots, options) => { + try { + return await scanGitRepos(roots || [], options || {}) + } catch { + return [] + } + }) +} diff --git a/apps/desktop/electron/hardening.ts b/apps/desktop/electron/hardening.ts index b885dda1af..f8c77f3186 100644 --- a/apps/desktop/electron/hardening.ts +++ b/apps/desktop/electron/hardening.ts @@ -3,31 +3,23 @@ import os from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' +// Relative, not `@hermes/shared`: the electron bundle is built by esbuild with +// no tsconfig path resolution (see scripts/bundle-electron-main.mjs), so a bare +// specifier would typecheck and then fail to bundle. +import { + clampDataUrlReadMaxMb, + DATA_URL_READ_DEFAULT_MAX_MB, + DATA_URL_READ_MAX_MAX_MB, + DATA_URL_READ_MIN_MAX_MB +} from '../../shared/src/data-url-read-max' + const DEFAULT_FETCH_TIMEOUT_MS = 15_000 -// Default / floor / ceiling for Desktop's data-URL file load (composer attach, -// image preview, etc.). The whole file is base64-buffered in main, so this is -// a memory guard — not a model limit. Settings → Chat takes a free-form MB -// value; 16 MB ships as default. The ceiling is only a typo guard (very large -// values can OOM / crash the app). -const DATA_URL_READ_DEFAULT_MAX_MB = 16 -const DATA_URL_READ_MIN_MAX_MB = 1 -const DATA_URL_READ_MAX_MAX_MB = 4096 // Remote file.attach sends one base64 JSON-RPC frame. Cap the dedicated attach // reader so the payload still fits uvicorn's raised ws_max_size (384 MiB) // after base64 + framing. Preview stays on the Settings-configurable path. const ATTACHMENT_UPLOAD_DEFAULT_MAX_BYTES = 256 * 1024 * 1024 const TEXT_PREVIEW_SOURCE_MAX_BYTES = 64 * 1024 * 1024 -function clampDataUrlReadMaxMb(value) { - const parsed = Number(value) - - if (!Number.isFinite(parsed)) { - return DATA_URL_READ_DEFAULT_MAX_MB - } - - return Math.min(DATA_URL_READ_MAX_MAX_MB, Math.max(DATA_URL_READ_MIN_MAX_MB, Math.round(parsed))) -} - function dataUrlReadMaxBytesFromMb(maxMb) { return clampDataUrlReadMaxMb(maxMb) * 1024 * 1024 } diff --git a/apps/desktop/electron/hud-ipc.ts b/apps/desktop/electron/hud-ipc.ts new file mode 100644 index 0000000000..3eff9c1488 --- /dev/null +++ b/apps/desktop/electron/hud-ipc.ts @@ -0,0 +1,192 @@ +// IPC surface for HUD mode (the chrome-free floating chat band). Extracted +// from main.ts; the HUD window handle and session-id latch stay injected +// because main.ts owns the window lifecycle and the close broadcast reads the +// latch when handing the session back to the app window. +import { type BrowserWindow, ipcMain } from 'electron' + +import { hudFrostFor, type TranslucencyState } from './translucency' + +export interface HudIpcDeps { + isMac: boolean + isWindows: boolean + glassSupported: boolean + /** Main's authoritative translucency state (Settings → Appearance). */ + getTranslucencyState: () => TranslucencyState + getHudWindow: () => BrowserWindow | null + openHudWindow: (sessionId: null | string, profile: null | string) => void + closeHudWindow: () => void + setHudSessionId: (sessionId: null | string) => void +} + +export function registerHudIpc({ + isMac, + isWindows, + glassSupported, + getTranslucencyState, + getHudWindow, + openHudWindow, + closeHudWindow, + setHudSessionId +}: HudIpcDeps) { + // Whether the band currently covers the window below the bar. The renderer + // is the only party that can know this (it measures the transcript), and it + // is half of the frost decision — the other half is the user's setting, + // which main owns. Latched so a Settings change can re-decide without + // waiting for the HUD to report again. + let bandShowing = false + let applied: null | string = null + let appliedTo: BrowserWindow | null = null + + // Real frosted glass behind the band — the thing CSS backdrop-filter cannot do, + // because Chromium composites a transparent window's page against nothing and + // the desktop is not in its backdrop root. The material IS the window's content + // view, so it frosts the whole rectangle; the HUD's layout leaves no dead + // margins for that reason, and it only turns on while the band is showing + // (idle HUD mode must be the bar and nothing else). + // + // Diffed before issuing: `setVibrancy` carries a 150ms animation that restarts + // if re-issued, so a repeated call would keep the material from ever settling + // (the same churn the chat windows' native-diff contract exists to prevent). + // + // The diff is keyed to the WINDOW as well as the value. A HUD respawn (the + // profile switch in openHudWindow destroys and rebuilds it) hands back a + // fresh window carrying no material, and a latch that only remembered the + // value would recognise its own last answer and skip — leaving the new HUD + // unfrosted until something else happened to change the signature. + const applyHudFrost = () => { + const hudWindow = getHudWindow() + + if (!hudWindow || hudWindow.isDestroyed()) { + applied = null + appliedTo = null + + return + } + + const frost = hudFrostFor(getTranslucencyState(), bandShowing) + const signature = `${frost.vibrancy ?? 'off'}:${frost.backgroundMaterial}` + + if (applied === signature && appliedTo === hudWindow) { + return + } + + applied = signature + appliedTo = hudWindow + + if (isMac && typeof hudWindow.setVibrancy === 'function') { + hudWindow.setVibrancy(frost.vibrancy) + } + + if (isWindows && glassSupported && typeof hudWindow.setBackgroundMaterial === 'function') { + hudWindow.setBackgroundMaterial(frost.backgroundMaterial) + } + } + + ipcMain.handle('hermes:hud:open', async (_event, request) => { + openHudWindow( + typeof request?.sessionId === 'string' ? request.sessionId : null, + typeof request?.profile === 'string' ? request.profile : null + ) + + return { ok: true } + }) + + ipcMain.handle('hermes:hud:frost', (_event, showing) => { + bandShowing = Boolean(showing) + applyHudFrost() + + return { ok: true } + }) + + // Let clicks fall through the HUD wherever it isn't really there. An + // always-on-top window eats every click inside its rectangle, and most of that + // rectangle is a faded-out band over whatever the user is actually working in. + // `forward` keeps mousemove flowing so the renderer can re-arm when the cursor + // reaches the bar. + ipcMain.on('hermes:hud:ignore-mouse', (_event, ignore) => { + const hudWindow = getHudWindow() + + if (hudWindow && !hudWindow.isDestroyed()) { + hudWindow.setIgnoreMouseEvents(Boolean(ignore), { forward: true }) + } + }) + + ipcMain.on('hermes:hud:move-by', (event, delta) => { + const hudWindow = getHudWindow() + + if (!hudWindow || hudWindow.isDestroyed() || event.sender !== hudWindow.webContents) { + return + } + + const dx = Number(delta?.x) + const dy = Number(delta?.y) + const width = Number(delta?.width) + const height = Number(delta?.height) + + if (!Number.isFinite(dx) || !Number.isFinite(dy) || !Number.isFinite(width) || !Number.isFinite(height)) { + return + } + + const [x, y] = hudWindow.getPosition() + + // setBounds — NOT setPosition: on Windows, a transparent frameless window + // silently grows ~1px per setPosition call (worse at >100% DPI). The renderer + // snapshots outerWidth/outerHeight when the composer drag arms and re-pins + // to that size on every moveBy (same pattern as the pet overlay drag). + hudWindow.setBounds({ + x: Math.round(x + dx), + y: Math.round(y + dy), + width: Math.round(width), + height: Math.round(height) + }) + }) + + // Resize from the HUD's corner handle. The window is created non-resizable + // (see spawnHudWindow — a transparent frameless window must not expose a + // system resize hot-zone, or dragging grows it), which on Windows/Linux also + // blocks programmatic setBounds sizing — so briefly flip resizable on while + // the size actually changes, exactly like the pet overlay's wheel-scale does. + ipcMain.on('hermes:hud:set-bounds', (event, bounds) => { + const hudWindow = getHudWindow() + + if (!hudWindow || hudWindow.isDestroyed() || event.sender !== hudWindow.webContents || !bounds) { + return + } + + const win = hudWindow + const width = Math.max(380, Math.round(Number(bounds.width))) + const height = Math.max(160, Math.round(Number(bounds.height))) + const [curW, curH] = win.getSize() + const resizing = width !== curW || height !== curH + + if (resizing && !win.isResizable()) { + win.setResizable(true) + } + + win.setBounds({ x: Math.round(Number(bounds.x)), y: Math.round(Number(bounds.y)), width, height }) + + if (resizing) { + win.setResizable(false) + } + }) + + // The HUD renderer reporting which session it is on, so the close broadcast + // can hand it back to the app window (see hudSessionId). + ipcMain.on('hermes:hud:session', (event, sessionId) => { + const hudWindow = getHudWindow() + + if (hudWindow && !hudWindow.isDestroyed() && event.sender === hudWindow.webContents) { + setHudSessionId(typeof sessionId === 'string' && sessionId ? sessionId : null) + } + }) + + ipcMain.handle('hermes:hud:close', async () => { + closeHudWindow() + + return { ok: true } + }) + + // Main re-applies the frost when the translucency SETTING changes, since the + // band's own report only fires when the band itself moves. + return { applyHudFrost } +} diff --git a/apps/desktop/electron/image-context-menu.test.ts b/apps/desktop/electron/image-context-menu.test.ts deleted file mode 100644 index c36de87b07..0000000000 --- a/apps/desktop/electron/image-context-menu.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import assert from 'node:assert/strict' - -import { test } from 'vitest' - -import { imageContextMenuItems } from './image-context-menu' - -function createActions() { - const calls = { - copyImageAt: [], - openImage: [], - copyImageAddress: [], - saveImage: [] - } - - return { - calls, - actions: { - copyImageAt: (x, y) => calls.copyImageAt.push([x, y]), - openImage: url => calls.openImage.push(url), - copyImageAddress: url => calls.copyImageAddress.push(url), - saveImage: url => calls.saveImage.push(url) - } - } -} - -test('keeps Copy Image available when Chromium omits a large image srcURL', () => { - const { actions, calls } = createActions() - - const items = imageContextMenuItems( - { mediaType: 'image', hasImageContents: true, srcURL: '', x: 100, y: 120 }, - actions - ) - - assert.deepEqual( - items.map(item => item.label), - ['Copy Image'] - ) - - items[0].click() - assert.deepEqual(calls.copyImageAt, [[100, 120]]) -}) - -test('keeps URL-dependent image actions when srcURL is available', () => { - const { actions, calls } = createActions() - const url = 'https://example.com/image.png' - - const items = imageContextMenuItems({ mediaType: 'image', hasImageContents: true, srcURL: url, x: 5, y: 8 }, actions) - - assert.deepEqual( - items.map(item => item.label), - ['Open Image', 'Copy Image', 'Copy Image Address', 'Save Image As...'] - ) - - items[0].click() - items[1].click() - items[2].click() - items[3].click() - - assert.deepEqual(calls.openImage, [url]) - assert.deepEqual(calls.copyImageAt, [[5, 8]]) - assert.deepEqual(calls.copyImageAddress, [url]) - assert.deepEqual(calls.saveImage, [url]) -}) - -test('does not add image actions for a non-image target', () => { - const { actions } = createActions() - - assert.deepEqual( - imageContextMenuItems({ mediaType: 'none', hasImageContents: false, srcURL: '', x: 0, y: 0 }, actions), - [] - ) -}) - -test('does not offer Copy Image when the target has no decoded image contents', () => { - const { actions } = createActions() - - assert.deepEqual( - imageContextMenuItems({ mediaType: 'image', hasImageContents: false, srcURL: '', x: 0, y: 0 }, actions), - [] - ) -}) diff --git a/apps/desktop/electron/image-context-menu.ts b/apps/desktop/electron/image-context-menu.ts deleted file mode 100644 index de817024c0..0000000000 --- a/apps/desktop/electron/image-context-menu.ts +++ /dev/null @@ -1,40 +0,0 @@ -export function imageContextMenuItems(params, actions) { - if (params.mediaType !== 'image' || !params.hasImageContents) { - return [] - } - - const items = [] - const srcURL = params.srcURL || '' - - if (srcURL) { - items.push({ - label: 'Open Image', - click: () => { - if (!srcURL.startsWith('data:')) { - actions.openImage(srcURL) - } - }, - enabled: !srcURL.startsWith('data:') - }) - } - - items.push({ - label: 'Copy Image', - click: () => actions.copyImageAt(params.x, params.y) - }) - - if (srcURL) { - items.push( - { - label: 'Copy Image Address', - click: () => actions.copyImageAddress(srcURL) - }, - { - label: 'Save Image As...', - click: () => actions.saveImage(srcURL) - } - ) - } - - return items -} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index b0b25c49b9..0dca6c0a32 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -29,7 +29,6 @@ import { shell, systemPreferences } from 'electron' -import nodePty from 'node-pty' import { classifyActiveRuntime } from './active-runtime-state' import { stopBackendChild as stopBackendChildImpl, stopBackendTreesForUpdate } from './backend-child' @@ -47,8 +46,10 @@ import { } from './backend-probes' import { waitForDashboardPortAnnouncement } from './backend-ready' import { + isHostKeyChangedBootFailure, isRetryableRemoteBootFailure, shouldLatchBackendStartFailure, + shouldLatchHostKeyChangedFailure, shouldLatchRemoteReauthFailure } from './backend-start-failure' import { @@ -60,7 +61,7 @@ import { import { decideBootstrapRepair } from './bootstrap-repair-guard' import { runBootstrap } from './bootstrap-runner' import { detectBundleSkew } from './bundle-skew' -import { applyConnectionChange, resolveTerminalConnection } from './connection-apply' +import { applyConnectionChange } from './connection-apply' import { apiRequestRegistryConnectionId, authModeFromStatus, @@ -106,7 +107,10 @@ import { normalizeRegistry, rememberSshEnumeration, removeConnection, + resolvedConnectionId, resolveRegistryLocalRoute, + setConnectionLaunchMode, + setLastUsedConnection, setPrimaryConnection, shouldDeferLocalEnumeration, shouldRetrySshInventory, @@ -117,7 +121,6 @@ import { describeCrashReason, installCrashForensics } from './crash-forensics' import { adoptServedDashboardToken } from './dashboard-token' import { loadOrCreateInstallationId, sshOwnershipId } from './desktop-installation' import { formatDesktopLogLine } from './desktop-log-line' -import { installDesktopPluginFromGit, probePluginRepo } from './desktop-plugin-install' import { resolveDesktopRemoteRoute } from './desktop-remote-route' import { buildPosixCleanupScript, @@ -146,7 +149,7 @@ import { stopFind } from './find-in-page' import { createFirstRunSetupGate } from './first-run-setup-gate' -import { readDirForIpc } from './fs-read-dir' +import { registerFsIpc } from './fs-ipc' import { filenameFromContentDisposition, gatewayFilePath, @@ -155,33 +158,7 @@ import { pumpStreamToFile } from './gateway-file-download' import { probeGatewayWebSocket } from './gateway-ws-probe' -import { scanGitRepos } from './git-repo-scan' -import { - fileDiffVsHead, - repoStatus, - reviewCommit, - reviewCommitContext, - reviewCreatePr, - reviewDiff, - reviewFetchPrComment, - reviewList, - reviewPrList, - reviewPush, - reviewRevert, - reviewRevParse, - reviewShipInfo, - reviewStage, - reviewUnstage -} from './git-review-ops' -import { gitRootForIpc } from './git-root' -import { - addWorktree, - listBaseBranches, - listBranches, - listWorktrees, - removeWorktree, - switchBranch -} from './git-worktree-ops' +import { registerGitIpc } from './git-ipc' import { clearStaleGitLocks } from './gitlock' import { readAndConsumeHandoffResult } from './handoff-result' import { @@ -203,16 +180,17 @@ import { writeSecretFileAtomic } from './hardening' import { cursorPointInWindow } from './hud-cursor' +import { registerHudIpc } from './hud-ipc' import { snapHudBounds } from './hud-snap' import { createHudSnapShortcut } from './hud-snap-shortcut' import { buildHudWindowUrl } from './hud-url' -import { imageContextMenuItems } from './image-context-menu' import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window' import { ensureMainWindow } from './main-window-lifecycle' import { createMediaProtocolHandler, MEDIA_PROTOCOL } from './media-protocol' import { oauthGuardMayHardFail, oauthSessionIsLive, + resolveGatedDownloadAuth, resolveJsonBody, resolveOauthRestAuth, resolveReadinessProbeAuth @@ -232,6 +210,7 @@ import { electronProcessStartMarker, parentWatchdogEnv } from './parent-process-identity' +import { registerPetOverlayIpc } from './pet-overlay-ipc' import { buildRegistryProfileRoutes, localRouteFallbackProfiles, @@ -279,6 +258,7 @@ import { import { missingRendererAssets } from './renderer-bundle' import { attachRendererConsoleCapture, formatRendererBoundaryReport } from './renderer-log' import { + buildInstanceWindowUrl, buildSessionWindowUrl, chatWindowWebPreferences, createSessionWindowRegistry, @@ -287,21 +267,19 @@ import { SESSION_WINDOW_MIN_WIDTH } from './session-windows' import { ensureLoginShellPath } from './shell-path' -import { ensureSpawnHelperExecutable } from './spawn-helper-perms' import { createBootstrapCoordinator, sshConfigFingerprint } from './ssh-bootstrap-coordinator' import { collectSshConfigHosts, parseSshGOutput } from './ssh-config' -import { - buildInteractiveSshArgs, - createSshProbeConnection, - pickLocalPort, - redactSecrets, - SshConnection -} from './ssh-connection' +import { createSshProbeConnection, pickLocalPort, redactSecrets, SshConnection } from './ssh-connection' import { createStreamThrottle } from './stream-throttle' +import { registerTerminalIpc } from './terminal-ipc' import { nativeOverlayWidth as computeNativeOverlayWidth, macTitleBarOverlayHeight } from './titlebar-overlay-width' import { + backgroundMaterialFor, + defaultTranslucencyState, glassActive, + glassSupportedOn, normalizeState as normalizeTranslucency, + translucencySupportedOn, vibrancyFor as vibrancyForTranslucency, windowBackingOptions, windowOpacityFor @@ -353,12 +331,7 @@ import { getVenvSitePackagesEntries, resolveVenvHermesCommand } from './windows-hermes-path' -import { - buildWindowsInteractiveCommand, - connectWindowsRemote, - detectRemotePlatform, - helper -} from './windows-remote-lifecycle' +import { connectWindowsRemote, detectRemotePlatform, helper } from './windows-remote-lifecycle' import { alreadyHasNoSandbox, buildNoSandboxRelaunchArgs, @@ -395,6 +368,12 @@ const IS_WSL = isWslEnvironment() // Truthful macOS kernel major (Tahoe = 25). Product version lies (16 vs 26) per // build SDK, so gate Tahoe workarounds on Darwin instead. const DARWIN_MAJOR = IS_MAC ? Number.parseInt(os.release(), 10) || 0 : 0 +// Glass: macOS vibrancy, or Windows 11 22H2+ system backdrop. Computed once +// so the renderer, the persisted default, and every chat window agree. +const GLASS_SUPPORTED = glassSupportedOn(process.platform, os.release()) +// Clear rides setOpacity, a documented no-op on Linux, so neither mode works +// there and Settings drops the row entirely. +const TRANSLUCENCY_SUPPORTED = translucencySupportedOn(process.platform) const APP_ROOT = app.getAppPath() // Device-local preference: block F12 from opening DevTools. @@ -849,7 +828,6 @@ 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 @@ -891,18 +869,24 @@ nativeTheme.themeSource = readPersistedThemeSource() // Window translucency (see-through window). One lever, 0–100; 0 = off (the // default). Two modes share the lever (see electron/translucency.ts and // store/translucency): 'clear' maps it to the native window opacity so the -// desktop shows through the whole window; 'glass' (macOS) keeps the window -// opaque and lets the renderer thin its surfaces over the vibrancy material -// instead — a matte blur with full-contrast text. Persisted so a cold launch -// applies it at window creation, before the renderer reports its value. +// desktop shows through the whole window; 'glass' keeps the window opaque +// and lets the renderer thin its surfaces over a platform material instead +// — a matte blur with full-contrast text. macOS uses vibrancy; Windows 11 +// uses DWM acrylic/mica/tabbed. 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. const TRANSLUCENCY_CONFIG_PATH = path.join(app.getPath('userData'), 'translucency.json') function readPersistedTranslucency() { try { - return normalizeTranslucency(JSON.parse(fs.readFileSync(TRANSLUCENCY_CONFIG_PATH, 'utf8')), IS_MAC) + return normalizeTranslucency(JSON.parse(fs.readFileSync(TRANSLUCENCY_CONFIG_PATH, 'utf8')), GLASS_SUPPORTED) } catch { - return normalizeTranslucency(null, IS_MAC) + // Nothing persisted yet — a first launch. Glass ships on, so the FIRST + // window has to be created with the glass backing already: a window born + // opaque cannot reliably be swapped to glass afterwards (see + // windowBackingOptions). nativeTheme is the only appearance signal main + // has this early; the renderer's first resolved send corrects it. + return defaultTranslucencyState(nativeTheme.shouldUseDarkColors ? 'dark' : 'light', GLASS_SUPPORTED, IS_WINDOWS) } } @@ -930,17 +914,20 @@ function windowOpacity() { // 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. // The backing swap is the glass half: Chromium composites the page against -// the window backing BEFORE macOS composites the window, so glass needs the -// backing dropped for the vibrancy material to reach a transparent page, and +// the window backing BEFORE the OS composites the window, so glass needs the +// backing dropped for the platform material to reach a transparent page, and // every other state needs the opaque themed backing (anti-flash, and it is // what makes clear mode fade to the desktop instead of to black). // // `changed` says which native properties actually need touching. Dragging the // intensity slider emits ~100 updates, and in glass mode NONE of them change -// anything native — the effect is painted by the renderer and windowOpacityFor -// returns 1 throughout. Re-issuing setVibrancy on every tick restarts its -// 150ms animation before macOS can settle the material, which reads as jank -// and flattens the frost levels into each other. +// anything native — the tint is painted by the renderer and windowOpacityFor +// answers off `fade`, not `intensity`, there. Re-issuing setVibrancy on every +// tick restarts its 150ms animation before macOS can settle the material, +// which reads as jank and flattens the frost levels into each other. Windows +// setBackgroundMaterial is instantaneous but still skipped on tint-only ticks. +// The glass Fade lever is the one glass drag that does reach main, and it +// costs exactly what a Clear drag costs: one setOpacity. // // CAUTION (measured, macOS 26 / Electron 40): a runtime // setBackgroundColor('#00000000') is silently LOST on a window whose @@ -962,11 +949,18 @@ function applyWindowTranslucency(win, changed = { backing: true, material: true, win.setBackgroundColor(glassActive(translucencyState) ? '#00000000' : getWindowBackgroundColor()) } - // Glass frost level = the vibrancy material (macOS has no blur-radius - // knob). Animate the hop so a deliberate frost switch feels continuous — - // which only works if we don't re-issue it on unrelated updates. - if (changed.material && IS_MAC && typeof win.setVibrancy === 'function') { - win.setVibrancy(vibrancyForTranslucency(translucencyState), { animationDuration: 150 }) + if (changed.material) { + // Glass frost level = the platform material. Animate the macOS hop so + // a deliberate frost switch feels continuous — which only works if we + // don't re-issue it on unrelated updates. Windows has no equivalent + // animation option; setBackgroundMaterial is instantaneous. + if (IS_MAC && typeof win.setVibrancy === 'function') { + win.setVibrancy(vibrancyForTranslucency(translucencyState), { animationDuration: 150 }) + } + + if (IS_WINDOWS && GLASS_SUPPORTED && typeof win.setBackgroundMaterial === 'function') { + win.setBackgroundMaterial(backgroundMaterialFor(translucencyState)) + } } } @@ -999,6 +993,12 @@ function chatWindowSurfaceOptions() { // user's frost choice whenever they click elsewhere. Only observable // under glass — everywhere else the page buries the material. visualEffectState: IS_MAC ? ('active' as const) : undefined, + // Win11 DWM materials only reach the client area on a transparent window + // (electron#49443). Chat windows on glass-capable Windows are born + // transparent so a live Clear→Glass toggle doesn't need a recreate; the + // opaque themed backgroundColor covers it while glass is off. + ...(IS_WINDOWS && GLASS_SUPPORTED ? { transparent: true } : {}), + backgroundMaterial: IS_WINDOWS && GLASS_SUPPORTED ? backgroundMaterialFor(translucencyState) : undefined, opacity: windowOpacity(), ...windowBackingOptions(translucencyState, getWindowBackgroundColor()) } @@ -3789,16 +3789,20 @@ async function handOffWindowsBootstrapRecovery(reason) { const venvHermes = path.join(venvBin, IS_WINDOWS ? 'hermes.exe' : 'hermes') const venvPython = path.join(venvBin, IS_WINDOWS ? 'python.exe' : 'python') - // Choose the gentle in-place --update when ANY real-install signal is present, - // not just the `hermes.exe` console-script shim. That shim is generated at the - // END of venv setup and is absent in exactly the interrupted/quarantined states - // this recovery exists to heal — gating on it alone forced the destructive - // --repair (full venv recreate) and drove reinstall loops. The venv interpreter - // and the bootstrap-complete marker are present earlier and are better signals. - const haveRealInstall = - fileExists(venvPython) || fileExists(venvHermes) || fileExists(path.join(updateRoot, '.hermes-bootstrap-complete')) - - const updaterArgs = chooseUpdaterArgs(haveRealInstall, branch) + // The updater invokes the venv's Hermes launcher, which in turn requires the + // venv interpreter. A bootstrap-complete marker proves only that setup once + // finished; it can outlive a manually removed or quarantined venv. Sending a + // marker-only install through --update dead-ends at "Could not find the hermes + // CLI" instead of rebuilding the runtime, so only a runnable pair gets the + // gentle update path. Partial or missing runtimes go through full repair. + const updaterArgs = chooseUpdaterArgs( + { + hasBootstrapMarker: fileExists(path.join(updateRoot, '.hermes-bootstrap-complete')), + hasVenvHermes: fileExists(venvHermes), + hasVenvPython: fileExists(venvPython) + }, + branch + ) await releaseBackendLockForUpdate(updateRoot) @@ -4864,7 +4868,8 @@ function fetchJson(url, token, options: any = {}) { // Token-auth download that streams the response body straight to a // user-selected destination (via finalizeGatewayDownload) instead of buffering // the whole file in memory. The connect timeout is cleared once headers arrive -// so a slow save dialog or a large stream doesn't trip it. +// so a slow save dialog or a large stream doesn't trip it. `options.bearer` +// switches the header to Authorization (RFC 8252 native flow), matching fetchJson. function downloadViaTokenToFile(url, token, ctx, options: any = {}) { return new Promise((resolve, reject) => { let parsed @@ -4890,9 +4895,7 @@ function downloadViaTokenToFile(url, token, ctx, options: any = {}) { parsed, { method: 'GET', - headers: { - 'X-Hermes-Session-Token': token - } + headers: options.bearer ? { Authorization: `Bearer ${options.bearer}` } : { 'X-Hermes-Session-Token': token } }, res => { // Headers arrived — the connection phase is done. Drop the idle timeout @@ -6397,92 +6400,30 @@ function installZoomShortcuts(window) { }) } -function installContextMenu(window) { +/** + * The custom (renderer) context menu's main-process half. + * + * The app popups no native menus: the renderer owns the menu UI so labels + * are translated with the rest of the app. Main keeps only what Chromium + * reports here and the renderer cannot see: + * - spell-check facts (misspelled word + suggestions) — forwarded so the + * renderer appends them to its already-open menu, + * - the gesture coordinates — kept for copyImageAt, which needs them. + */ +const lastContextMenuPoint = new Map() + +function installContextMenuBridge(window: BrowserWindow) { window.webContents.on('context-menu', (_event, params) => { - const template = [] - const hasSelection = Boolean(params.selectionText?.trim()) - const hasLink = Boolean(params.linkURL) - const isEditable = Boolean(params.isEditable) + lastContextMenuPoint.set(window.webContents.id, { x: params.x, y: params.y }) - template.push( - ...imageContextMenuItems(params, { - copyImageAt: (x, y) => window.webContents.copyImageAt(x, y), - openImage: openExternalUrl, - copyImageAddress: url => clipboard.writeText(url), - saveImage: url => { - void saveImageFromUrl(url).catch(error => rememberLog(`Save image failed: ${error.message}`)) - } - }) - ) - - if (hasLink) { - if (template.length) { - template.push({ type: 'separator' }) - } - - template.push( - { - label: 'Open Link', - click: () => openExternalUrl(params.linkURL) - }, - { - label: 'Copy Link', - click: () => clipboard.writeText(params.linkURL) - } - ) - } - - // Spell-check suggestions for the misspelled word under the caret. - // Chromium surfaces them on `params.dictionarySuggestions`; we offer the - // top 5 plus a "Add to dictionary" affordance. const suggestions = Array.isArray(params.dictionarySuggestions) ? params.dictionarySuggestions : [] - if (isEditable && params.misspelledWord && suggestions.length > 0) { - if (template.length) { - template.push({ type: 'separator' }) - } - - for (const suggestion of suggestions.slice(0, 5)) { - template.push({ - label: suggestion, - click: () => window.webContents.replaceMisspelling(suggestion) - }) - } - - template.push({ type: 'separator' }) - template.push({ - label: 'Add to dictionary', - click: () => window.webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord) + if (params.isEditable && params.misspelledWord) { + window.webContents.send('hermes:context-menu-spellcheck', { + misspelledWord: params.misspelledWord, + suggestions }) } - - if (hasSelection || isEditable) { - if (template.length) { - template.push({ type: 'separator' }) - } - - if (isEditable) { - template.push( - { role: 'cut', enabled: params.editFlags.canCut }, - { role: 'copy', enabled: params.editFlags.canCopy }, - { role: 'paste', enabled: params.editFlags.canPaste }, - { type: 'separator' }, - { role: 'selectAll', enabled: params.editFlags.canSelectAll } - ) - } else { - template.push({ role: 'copy', enabled: params.editFlags.canCopy }) - } - } - - // Bare right-click on non-editable, non-selected, non-media content (a pane - // body, the sidebar, chrome): the renderer's own context menus own those - // surfaces, and anywhere without one shows nothing — not a lone, useless - // "Select All" from the native fallback. - if (!template.length) { - return - } - - Menu.buildFromTemplate(template).popup({ window }) }) } @@ -7318,6 +7259,13 @@ function readGatewayErrorText(res): Promise { }) } +async function gatedFileAuth(connection) { + const nativeAt = + connection.authMode === 'oauth' ? await ensureNativeAccessToken(connection.baseUrl).catch(() => null) : null + + return resolveGatedDownloadAuth(connection.authMode, nativeAt, connection.token) +} + async function saveGatewayFile(payload: any = {}) { const filePath = gatewayFilePath(payload.path) @@ -7340,9 +7288,17 @@ async function saveGatewayFile(payload: any = {}) { const url = `${connection.baseUrl}${requestPath}` try { - return await (connection.authMode === 'oauth' - ? downloadViaOauthSessionToFile(url, ctx) - : downloadViaTokenToFile(url, connection.token, ctx)) + const auth = await gatedFileAuth(connection) + + if (auth.kind === 'bearer') { + return await downloadViaTokenToFile(url, auth.token, ctx, { bearer: auth.token }) + } + + if (auth.kind === 'cookie') { + return await downloadViaOauthSessionToFile(url, ctx) + } + + return await downloadViaTokenToFile(url, auth.token, ctx) } catch (error) { // Desktop and the remote gateway update independently. A gateway predating // /api/fs/download 404s here; fall back (ONLY on 404) to the older capped @@ -7367,10 +7323,16 @@ async function saveGatewayFileViaDataUrl(connection, profile, filePath, ctx: any ) const url = `${connection.baseUrl}${requestPath}` + const auth = await gatedFileAuth(connection) + let json: any - const json = ( - connection.authMode === 'oauth' ? await fetchJsonViaOauthSession(url) : await fetchJson(url, connection.token) - ) as any + if (auth.kind === 'bearer') { + json = await fetchJson(url, null, { bearer: auth.token }) + } else if (auth.kind === 'cookie') { + json = await fetchJsonViaOauthSession(url) + } else { + json = await fetchJson(url, auth.token) + } const dataUrl = json?.dataUrl @@ -8428,6 +8390,8 @@ function sanitizeConnectionsRegistry(registry = readDesktopConnectionsRegistry() return { version: registry.version, primary: registry.primary, + launchMode: registry.launchMode, + lastUsed: registry.lastUsed, secureTokenStorage, connections: registry.connections.map(sanitizeRegistryConnection) } @@ -8920,11 +8884,7 @@ async function teardownSshConnection(profile) { sshConnections.delete(scope) - for (const [id, info] of [...terminalSessions.entries()]) { - if (info.sshScope === scope) { - disposeTerminalSession(id) - } - } + terminalIpc.disposeTerminalSessionsForSshScope(scope) try { if (state.localPort && state.remotePort) { @@ -9785,6 +9745,13 @@ async function ensureBackend(profile) { } entry.connectionPromise = spawnPoolBackend(key, entry).catch(async error => { + // Land the failure in desktop.log: without this a spawn that dies before + // its child exists (guard rejection, runtime resolution) leaves no trace + // beyond renderer-side rejections users never see in a bundle. + rememberLog( + `Hermes backend for profile "${key}" failed to start: ${error instanceof Error ? error.message : String(error)}` + ) + if (backendPool.get(key) === entry) { backendPool.delete(key) } @@ -9867,6 +9834,12 @@ async function ensureRegistryBackend(connectionId, profile) { forceLocal: true, poolKey: localRoute.poolKey }).catch(async error => { + // Same trace rule as the v1 pool path: a forced-local child whose spawn + // rejects before the child exists must still land in desktop.log. + rememberLog( + `Hermes backend for profile "${profileKey}" (forced-local) failed to start: ${error instanceof Error ? error.message : String(error)}` + ) + if (backendPool.get(localRoute.poolKey) === localEntry) { backendPool.delete(localRoute.poolKey) } @@ -10148,12 +10121,18 @@ async function spawnPoolBackend(profile, entry, opts: { forceLocal?: boolean; po const webDist = resolveWebDist() const readyFile = backend.readyFile ? makeDashboardReadyFile() : null - rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`) - - const parentStartMarker = await desktopParentStartMarker() + // Guard BEFORE the "Starting" line: a profile that only exists on a remote + // backend (remote-primary desktop asked for a forced-local child) rejects + // here, and logging "Starting" first left an orphaned line with no READY + // and no exit — the exact undiagnosable burst signature in remote-gateway + // user bundles (Aug 2026, Dash's report). assertLocalProfileCanStart(profile, profileDeletionGate, key => directoryExists(path.join(HERMES_HOME, 'profiles', key)) ) + + rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`) + + const parentStartMarker = await desktopParentStartMarker() const backendNonce = crypto.randomBytes(16).toString('hex') const parentIdentityEnv = parentWatchdogEnv(process.pid, parentStartMarker, backendNonce) @@ -10699,6 +10678,7 @@ async function startHermes() { } const message = error instanceof Error ? error.message : String(error) + const hostKeyChanged = isHostKeyChangedBootFailure(error) // Only latch LOCAL boot failures. A remote failure (lapsed session / mint // timeout / host briefly unreachable across sleep) is transient and has no @@ -10709,6 +10689,16 @@ async function startHermes() { backendStartFailure = error instanceof Error ? error : new Error(message) } + // A host-key CHANGE is the terminal exception among remote failures: SSH + // fails closed until the user verifies the change and clears the stale + // known_hosts entry, so retrying re-drives the identical doomed boot (one + // bundle showed 157 consecutive failures over 2.5h). Latch it like a local + // failure — reset/repair/apply-config clear the latch after the user fixes + // known_hosts. + if (shouldLatchHostKeyChangedFailure({ attemptedRemote, isReauth: false, isHostKeyChanged: hostKeyChanged })) { + backendStartFailure = error instanceof Error ? error : new Error(message) + } + // A confirmed reauth rejection latches separately: it can't self-heal, and // leaving it unlatched hides the overlay's "Sign in" button on every retry. if (shouldLatchRemoteReauthFailure({ attemptedRemote, isReauth: isReauthRequiredError(error) })) { @@ -10723,9 +10713,14 @@ async function startHermes() { // Renderer contract for the self-heal loop (#82679): a transient // REMOTE failure (dropped SSH/HTTP registered connection, mint // timeout) is retryable — the renderer re-attempts the boot with - // bounded backoff. Local failures and confirmed reauth rejections - // are not: those end in the recovery overlay / sign-in affordance. - retryable: isRetryableRemoteBootFailure({ attemptedRemote, isReauth: isReauthRequiredError(error) }), + // bounded backoff. Local failures, confirmed reauth rejections, and + // host-key changes are not: those end in the recovery overlay / + // sign-in affordance. + retryable: isRetryableRemoteBootFailure({ + attemptedRemote, + isReauth: isReauthRequiredError(error), + isHostKeyChanged: hostKeyChanged + }), running: false }, { allowDecrease: true } @@ -10776,7 +10771,7 @@ function wireCommonWindowHandlers(win, { zoom = true }: { zoom?: boolean } = {}) win.webContents.on('did-finish-load', () => restorePersistedZoomLevel(win)) } - installContextMenu(win) + installContextMenuBridge(win) win.webContents.setWindowOpenHandler(details => { openExternalUrl(details.url) @@ -10919,8 +10914,10 @@ function createSessionWindow(sessionId, { watch = false } = {}) { // Additional full "instance" windows — peers of the primary that render the // COMPLETE app (sidebar, routing, its own draft) against the shared backend, so // a user can run multiple GUI windows at once (⌘⇧N / the "New Window" palette -// command). Unlike the compact session windows they carry no `?win` flag. The -// primary mainWindow stays the notification / deep-link / pet-overlay anchor and +// command). Unlike the compact session windows they carry no `?win` flag; a +// separate `peer=1` marker prevents them from replaying app-launch source +// restoration after joining that shared backend. The primary mainWindow stays +// the notification / deep-link / pet-overlay anchor and // is NOT tracked here. The set holds a strong reference so an open peer isn't // garbage-collected, and drops it on close. const instanceWindows = new Set() @@ -11000,7 +10997,14 @@ function createInstanceWindow() { }) attachRendererConsoleCapture(win, 'instance', rememberLog) - loadWindowUrl(win, DEV_SERVER || pathToFileURL(resolveRendererIndex()).toString(), 'Instance window') + loadWindowUrl( + win, + buildInstanceWindowUrl({ + devServer: DEV_SERVER, + rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex() + }), + 'Instance window' + ) return win } @@ -12004,7 +12008,12 @@ function createWindow() { }) } -ipcMain.handle('hermes:connection', async (_event, profile) => ensureBackend(profile)) +ipcMain.handle('hermes:connection', async (_event, profile) => { + const connection = await ensureBackend(profile) + const connectionId = resolvedConnectionId(readDesktopConnectionsRegistry(), connection) + + return connectionId ? { ...connection, connectionId } : connection +}) // Registry-scoped variant: resolve a backend for (connectionId, profile). // connectionId '' / 'local' / the registry primary all behave sensibly; the // local kind delegates to ensureBackend when the v1 route is local, and @@ -12012,8 +12021,11 @@ ipcMain.handle('hermes:connection', async (_event, profile) => ensureBackend(pro // registry 'local' entry always means this machine). ipcMain.handle('hermes:connection:for', async (_event, payload) => { const { connectionId, profile } = payload && typeof payload === 'object' ? (payload as any) : ({} as any) + const registry = readDesktopConnectionsRegistry() + const id = String(connectionId || '').trim() || registry.primary + const connection = await ensureRegistryBackend(id, profile) - return ensureRegistryBackend(connectionId, profile) + return { ...connection, connectionId: id, registryScoped: true } }) // Reconnect-after-wake recovery. A REMOTE primary backend has no child process, // so the 'exit'/'error' handlers that would clear a dead connection promise never @@ -12189,233 +12201,28 @@ ipcMain.on('hermes:zoom:set-percent', (event, percent) => { setAndPersistZoomLevel(window, percentToZoomLevel(Number(percent))) }) -// --- Pet overlay (pop-out mascot) ----------------------------------------- -// `request` is `{ bounds, screen }`. A fresh pop-out passes viewport-space -// bounds (screen=false): convert to screen space by adding the main window's -// content origin so the pet lands where it sat in-window. A remembered/dragged -// spot passes screen-space bounds (screen=true) and is used as-is. We return the -// resolved screen bounds so the renderer can persist exactly where it opened. -ipcMain.handle('hermes:pet-overlay:open', async (_event, request) => { - const bounds = request && request.bounds ? request.bounds : request - const isScreen = Boolean(request && request.screen) - let screenBounds = bounds - - try { - if (bounds && !isScreen && mainWindow && !mainWindow.isDestroyed()) { - const content = mainWindow.getContentBounds() - screenBounds = { - x: content.x + (bounds.x || 0), - y: content.y + (bounds.y || 0), - width: bounds.width, - height: bounds.height - } - } - } catch { - // Fall back to raw bounds if the window geometry is unavailable. - } - - openPetOverlay(screenBounds) - - return { ok: true, bounds: screenBounds } -}) -ipcMain.handle('hermes:pet-overlay:close', async () => { - closePetOverlay() - - return { ok: true } -}) -// Drag/resize: the overlay reports new absolute screen bounds (it already knows -// the pointer's screen coords). Drag keeps the size constant; the wheel-to-scale -// gesture grows/shrinks it so the sprite is never cropped by the window edge. -// The window is created non-resizable (no stray edge-drag on the transparent -// frameless panel), which on Windows/Linux also blocks programmatic setBounds -// sizing — so briefly flip resizable on whenever the size actually changes. -ipcMain.on('hermes:pet-overlay:set-bounds', (_event, bounds) => { - if (!petOverlayWindow || petOverlayWindow.isDestroyed() || !bounds) { - return - } - - const win = petOverlayWindow - const width = Math.max(80, Math.round(bounds.width)) - const height = Math.max(80, Math.round(bounds.height)) - const [curW, curH] = win.getSize() - const resizing = width !== curW || height !== curH - - if (resizing && !win.isResizable()) { - win.setResizable(true) - } - - win.setBounds({ x: Math.round(bounds.x), y: Math.round(bounds.y), width, height }) - - if (resizing) { - win.setResizable(false) - } -}) -// Click-through: the overlay window is a full rectangle but only the pet pixels -// should be interactive. The renderer toggles this as the cursor enters/leaves -// the sprite so transparent margins pass clicks to whatever is behind. -ipcMain.on('hermes:pet-overlay:ignore-mouse', (_event, ignore) => { - if (petOverlayWindow && !petOverlayWindow.isDestroyed()) { - petOverlayWindow.setIgnoreMouseEvents(Boolean(ignore), { forward: true }) - } -}) -// The overlay is a non-activating panel (focusable:false) so it never steals -// the app's cmd/alt-tab anchor from the main window. But the pop-up composer -// needs the keyboard, so the renderer asks us to flip it focusable + focus it -// while the composer is open, then back to non-activating when it closes. -ipcMain.on('hermes:pet-overlay:set-focusable', (_event, focusable) => { - if (!petOverlayWindow || petOverlayWindow.isDestroyed()) { - return - } - - petOverlayWindow.setFocusable(Boolean(focusable)) - - if (focusable) { - petOverlayWindow.focus() - } -}) -// Main renderer → overlay: forward the latest pet state for the overlay to render. -ipcMain.on('hermes:pet-overlay:state', (_event, payload) => { - if (petOverlayWindow && !petOverlayWindow.isDestroyed()) { - petOverlayWindow.webContents.send('hermes:pet-overlay:state', payload) - } -}) -// Overlay → main renderer: control messages (pop back in, composer submit). -ipcMain.on('hermes:pet-overlay:control', (_event, payload) => { - if (!mainWindow || mainWindow.isDestroyed()) { - return - } - - // Double-click toggles the app window: hide it away if it's up front, bring it - // back if it's minimized/buried. Pure window control — nothing for the - // renderer to do, so don't forward it. - if (payload && payload.type === 'toggle-app') { - if (mainWindow.isMinimized() || !mainWindow.isVisible()) { - mainWindow.show() - mainWindow.focus() - } else { - mainWindow.minimize() - } - - return - } - - // The mail icon means "take me to the app": raise the main window (it may be - // minimized or buried) before the renderer navigates to the latest thread. - if (payload && payload.type === 'open-app') { - if (mainWindow.isMinimized()) { - mainWindow.restore() - } - - mainWindow.show() - mainWindow.focus() - } - - mainWindow.webContents.send('hermes:pet-overlay:control', payload) +// --- Pet overlay (pop-out mascot) — see pet-overlay-ipc.ts. --------------- +registerPetOverlayIpc({ + getMainWindow: () => mainWindow, + getPetOverlayWindow: () => petOverlayWindow, + openPetOverlay, + closePetOverlay }) -// --- HUD mode (chrome-free floating chat) ----------------------------------- -ipcMain.handle('hermes:hud:open', async (_event, request) => { - openHudWindow( - typeof request?.sessionId === 'string' ? request.sessionId : null, - typeof request?.profile === 'string' ? request.profile : null - ) - - return { ok: true } -}) - -// Real frosted glass behind the band — the thing CSS backdrop-filter cannot do, -// because Chromium composites a transparent window's page against nothing and -// the desktop is not in its backdrop root. Vibrancy IS the window's content -// view, so it frosts the whole rectangle; the HUD's layout leaves no dead -// margins for that reason, and the renderer only turns it on while the band is -// showing (idle HUD mode must be the bar and nothing else). -ipcMain.handle('hermes:hud:vibrancy', (_event, on) => { - if (hudWindow && !hudWindow.isDestroyed() && IS_MAC) { - hudWindow.setVibrancy(on ? 'hud' : null) - } - - return { ok: true } -}) - -// Let clicks fall through the HUD wherever it isn't really there. An -// always-on-top window eats every click inside its rectangle, and most of that -// rectangle is a faded-out band over whatever the user is actually working in. -// `forward` keeps mousemove flowing so the renderer can re-arm when the cursor -// reaches the bar. -ipcMain.on('hermes:hud:ignore-mouse', (_event, ignore) => { - if (hudWindow && !hudWindow.isDestroyed()) { - hudWindow.setIgnoreMouseEvents(Boolean(ignore), { forward: true }) +// --- HUD mode (chrome-free floating chat) — see hud-ipc.ts. --------------- +const hudIpc = registerHudIpc({ + isMac: IS_MAC, + isWindows: IS_WINDOWS, + glassSupported: GLASS_SUPPORTED, + getTranslucencyState: () => translucencyState, + getHudWindow: () => hudWindow, + openHudWindow, + closeHudWindow, + setHudSessionId: value => { + hudSessionId = value } }) -ipcMain.on('hermes:hud:move-by', (event, delta) => { - if (!hudWindow || hudWindow.isDestroyed() || event.sender !== hudWindow.webContents) { - return - } - - const dx = Number(delta?.x) - const dy = Number(delta?.y) - const width = Number(delta?.width) - const height = Number(delta?.height) - - if (!Number.isFinite(dx) || !Number.isFinite(dy) || !Number.isFinite(width) || !Number.isFinite(height)) { - return - } - - const [x, y] = hudWindow.getPosition() - - // setBounds — NOT setPosition: on Windows, a transparent frameless window - // silently grows ~1px per setPosition call (worse at >100% DPI). The renderer - // snapshots outerWidth/outerHeight when the composer drag arms and re-pins - // to that size on every moveBy (same pattern as the pet overlay drag). - hudWindow.setBounds({ - x: Math.round(x + dx), - y: Math.round(y + dy), - width: Math.round(width), - height: Math.round(height) - }) -}) - -// Resize from the HUD's corner handle. The window is created non-resizable -// (see spawnHudWindow — a transparent frameless window must not expose a -// system resize hot-zone, or dragging grows it), which on Windows/Linux also -// blocks programmatic setBounds sizing — so briefly flip resizable on while -// the size actually changes, exactly like the pet overlay's wheel-scale does. -ipcMain.on('hermes:hud:set-bounds', (event, bounds) => { - if (!hudWindow || hudWindow.isDestroyed() || event.sender !== hudWindow.webContents || !bounds) { - return - } - - const win = hudWindow - const width = Math.max(380, Math.round(Number(bounds.width))) - const height = Math.max(160, Math.round(Number(bounds.height))) - const [curW, curH] = win.getSize() - const resizing = width !== curW || height !== curH - - if (resizing && !win.isResizable()) { - win.setResizable(true) - } - - win.setBounds({ x: Math.round(Number(bounds.x)), y: Math.round(Number(bounds.y)), width, height }) - - if (resizing) { - win.setResizable(false) - } -}) - -// The HUD renderer reporting which session it is on, so the close broadcast -// can hand it back to the app window (see hudSessionId). -ipcMain.on('hermes:hud:session', (event, sessionId) => { - if (hudWindow && !hudWindow.isDestroyed() && event.sender === hudWindow.webContents) { - hudSessionId = typeof sessionId === 'string' && sessionId ? sessionId : null - } -}) - -ipcMain.handle('hermes:hud:close', async () => { - closeHudWindow() - - return { ok: true } -}) ipcMain.handle('hermes:bootstrap:reset', async () => { // Renderer's "Reload and retry" path. Clear the latched failure and // reset connection state so the next startHermes() call restarts the @@ -12648,6 +12455,18 @@ ipcMain.handle('hermes:connections:set-primary', async (_event, id) => { return { ok: true, registry: sanitizeConnectionsRegistry(registry) } }) +ipcMain.handle('hermes:connections:set-launch-mode', async (_event, mode) => { + const registry = setConnectionLaunchMode(readDesktopConnectionsRegistry(), String(mode || '')) + writeDesktopConnectionsRegistry(registry) + + return { ok: true, registry: sanitizeConnectionsRegistry(registry) } +}) +ipcMain.handle('hermes:connections:set-last-used', async (_event, id) => { + const registry = setLastUsedConnection(readDesktopConnectionsRegistry(), String(id || '')) + writeDesktopConnectionsRegistry(registry) + + return { ok: true, registry: sanitizeConnectionsRegistry(registry) } +}) ipcMain.handle('hermes:connections:test', async (_event, id) => { const registry = readDesktopConnectionsRegistry() const entry = registry.connections.find(c => c.id === String(id || '')) @@ -12842,6 +12661,31 @@ async function probeSshProfileInventory(connection) { } async function enumerateRegistryAgentSources(registry = readDesktopConnectionsRegistry()) { + // One dead source must not wedge the whole roster: ensureRegistryBackend on + // an unreachable remote can block up to the 45s readiness timeout, and the + // Bot Mode poll runs every 5s — each poll queued behind the dead dial, so + // the renderer painted stale rows for the entire outage (and the roster IPC + // hung >30s in live repro). Bound each source's enumeration; a timeout is + // reported like any other unreachable source and retried on the next poll. + const perSourceTimeoutMs = 10_000 + + const withEnumerationDeadline = async (work: Promise): Promise => { + let timer: ReturnType | null = null + + try { + return await Promise.race([ + work, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error('roster enumeration timed out')), perSourceTimeoutMs) + }) + ]) + } finally { + if (timer !== null) { + clearTimeout(timer) + } + } + } + return Promise.all( registry.connections.map(async connection => { let raw: { connection: typeof connection; error?: string; installId?: string; profiles: null | string[] } @@ -12875,7 +12719,10 @@ async function enumerateRegistryAgentSources(registry = readDesktopConnectionsRe } } - const descriptor: any = await ensureRegistryBackend(connection.id, null) + const descriptor: any = await withEnumerationDeadline( + Promise.resolve(ensureRegistryBackend(connection.id, null)) + ) + const body: any = await getJsonForBackend(descriptor, '/api/profiles', { timeoutMs: 8_000 }) // Cached with a TTL, so the 5s roster poll usually pays zero extra @@ -12961,42 +12808,59 @@ ipcMain.handle('hermes:gateway:ws-url-for', async (_event, payload) => { // app's own update pipeline; remote/ssh POST the backend's own // /api/hermes/update endpoint (the dashboard updater), which runs // `hermes update` on THAT machine. -ipcMain.handle('hermes:connections:update-all', async () => { +ipcMain.handle('hermes:connections:update-all', async (_event, payload) => { const registry = readDesktopConnectionsRegistry() + // Optional renderer-side exclusions: the everything-update flow dispatches + // the ACTIVE backend through its own detailed-progress path and chains the + // local client apply LAST (it relaunches the app), so it excludes those ids + // here to avoid double-dispatch. No payload keeps the Settings button's + // original all-rows behavior byte-identical. + const excludeIds = new Set( + Array.isArray((payload as any)?.excludeIds) ? (payload as any).excludeIds.map((id: unknown) => String(id)) : [] + ) + const results = await Promise.all( - registry.connections.map(async connection => { - const base = { connectionId: connection.id, label: connection.label, kind: connection.kind } - const eligibility = updateEligibility(connection) + registry.connections + .filter(connection => !excludeIds.has(connection.id)) + .map(async connection => { + const base = { connectionId: connection.id, label: connection.label, kind: connection.kind } + const eligibility = updateEligibility(connection) - if (!eligibility.eligible) { - return { ...base, ok: false, skipped: true, reason: eligibility.reason } - } - - try { - if (connection.kind === 'local') { - // The app-managed runtime updates through the same pipeline as the - // Settings → Updates button (marker + venv gate + relaunch flow). - const result: any = await applyUpdates({}) - - return { ...base, ok: result?.ok !== false, detail: result?.message || 'update started' } + if (!eligibility.eligible) { + return { ...base, ok: false, skipped: true, reason: eligibility.reason } } - const descriptor: any = await ensureRegistryBackend(connection.id, null) + try { + if (connection.kind === 'local') { + // The app-managed runtime updates through the same pipeline as the + // Settings → Updates button (marker + venv gate + relaunch flow). + const result: any = await applyUpdates({}) - const body: any = await postJsonForBackend(descriptor, '/api/hermes/update', {}, { timeoutMs: 15_000 }) + return { ...base, ok: result?.ok !== false, detail: result?.message || 'update started' } + } - if (body?.ok === false) { - // The backend refused (docker/nix/externally-managed installs) — - // surface ITS message, per-row, instead of failing the batch. - return { ...base, ok: false, skipped: true, reason: body?.error || 'backend-refused', detail: body?.message } + const descriptor: any = await ensureRegistryBackend(connection.id, null) + + const body: any = await postJsonForBackend(descriptor, '/api/hermes/update', {}, { timeoutMs: 15_000 }) + + if (body?.ok === false) { + // The backend refused (docker/nix/externally-managed installs) — + // surface ITS message, per-row, instead of failing the batch. + return { + ...base, + ok: false, + skipped: true, + reason: body?.error || 'backend-refused', + detail: body?.message + } + } + + return { ...base, ok: true, detail: body?.message || 'update started' } + } catch (error: any) { + return { ...base, ok: false, error: String(error?.message || error) } } - - return { ...base, ok: true, detail: body?.message || 'update started' } - } catch (error: any) { - return { ...base, ok: false, error: String(error?.message || error) } - } - }) + }) ) return { ok: true, results } @@ -13548,7 +13412,8 @@ async function handleHermesApiRequest(request) { // profile's. Resolve the backend through the registry (same pool the job // list and WS traffic use) instead of the legacy profile route; a shared // remote/cloud host serves every profile via ?profile=, so scope the path. - // '' / 'local' fall through to the byte-identical v1 route below (#87882). + // An absent/empty id falls through to the byte-identical v1 route below. + // Explicit `local` stays registry-pinned so it cannot inherit a v1 remote. const registryConnectionId = apiRequestRegistryConnectionId(request) if (registryConnectionId) { @@ -13936,6 +13801,58 @@ ipcMain.handle('hermes:saveGatewayFile', (_event, payload) => saveGatewayFile(pa ipcMain.handle('hermes:saveImageFromUrl', (_event, url) => saveImageFromUrl(String(url || ''))) +// The custom context menu's edit verbs. They act on the SENDER's focused +// element, so the renderer restores focus to the editable before invoking. +ipcMain.handle('hermes:context-menu:edit', (event, command) => { + const contents = event.sender + + if (command === 'copy') { + contents.copy() + } else if (command === 'cut') { + contents.cut() + } else if (command === 'paste') { + contents.paste() + } else if (command === 'selectAll') { + contents.selectAll() + } +}) + +// Copy the image under the sender's LAST context-menu gesture. Chromium only +// exposes image bytes through copyImageAt, and only main saw the coordinates. +ipcMain.handle('hermes:context-menu:copy-image', event => { + const point = lastContextMenuPoint.get(event.sender.id) + + if (point) { + event.sender.copyImageAt(point.x, point.y) + } +}) + +ipcMain.handle('hermes:context-menu:spellcheck', (event, action) => { + const kind = action?.kind + const word = String(action?.word || '') + + if (!word) { + return + } + + if (kind === 'replace') { + event.sender.replaceMisspelling(word) + } else if (kind === 'add') { + event.sender.session.addWordToSpellCheckerDictionary(word) + } +}) + +// Guest dictionary add: the webview TAG exposes replaceMisspelling but no +// session API, so the renderer names the guest by webContents id. +ipcMain.handle('hermes:context-menu:guest-add-word', (_event, payload) => { + const word = String(payload?.word || '') + const guest = electronWebContents.fromId(Number(payload?.webContentsId)) + + if (word && guest && !guest.isDestroyed()) { + guest.session.addWordToSpellCheckerDictionary(word) + } +}) + ipcMain.handle('hermes:saveImageBuffer', async (_event, payload) => { const data = payload?.data @@ -14069,12 +13986,20 @@ app.on('before-quit', () => { } }) +// Answered synchronously so preload can publish the verdict before the +// renderer's first script — see the note there on why it cannot decide this +// itself. Registered at module scope, which runs long before any window. +ipcMain.on('hermes:translucency:support', event => { + event.returnValue = { glass: GLASS_SUPPORTED, translucency: TRANSLUCENCY_SUPPORTED } +}) + ipcMain.on('hermes:translucency', (_event, payload) => { - const next = normalizeTranslucency(payload, IS_MAC) + const next = normalizeTranslucency(payload, GLASS_SUPPORTED) const previous = translucencyState if ( next.intensity === previous.intensity && + next.fade === previous.fade && next.mode === previous.mode && next.material === previous.material && next.scope === previous.scope @@ -14095,6 +14020,12 @@ ipcMain.on('hermes:translucency', (_event, payload) => { scheduleTranslucencyWrite() + // The HUD's frost reads the same setting but answers on its own terms (see + // hudFrostFor) — and it is a transparent window, so it is deliberately not + // in the chat fan-out below. It self-diffs, so an unrelated change costs + // nothing native. + hudIpc.applyHudFrost() + if (changed.backing || changed.material || changed.opacity) { for (const win of BrowserWindow.getAllWindows()) { applyWindowTranslucency(win, changed) @@ -14368,565 +14299,30 @@ ipcMain.on('hermes:logs:renderer-error', (_event, report) => { flushDesktopLogBufferSync() }) -function isExecutableFile(filePath) { - if (!filePath || !path.isAbsolute(filePath)) { - return false - } - - try { - fs.accessSync(filePath, fs.constants.X_OK) - - return true - } catch { - return false - } -} - -function posixShellSpec(shellPath) { - const shellName = path.basename(shellPath) - const interactiveArgs = shellName.includes('zsh') || shellName.includes('bash') ? ['-il'] : ['-i'] - - return { args: interactiveArgs, command: shellPath, name: shellName } -} - -// Windows PowerShell 5.1 ships at a fixed System32 path on every Windows box; -// prefer it only after PowerShell 7+ (`pwsh`). -function windowsPowerShellPath() { - const systemRoot = process.env.SystemRoot || process.env.windir || 'C:\\Windows' - const builtin = path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') - - return isExecutableFile(builtin) ? builtin : findOnPath('powershell.exe') -} - -// Map a resolved shell path to its spawn spec, picking interactive flags by -// family: PowerShell drops its logo banner (so the prompt sits flush like the -// POSIX shells), cmd needs nothing, and everything else (zsh/bash/fish/sh…) -// gets POSIX interactive-login flags. -function shellSpecFor(shellPath) { - const name = path.basename(shellPath).toLowerCase() - - if (name.startsWith('pwsh') || name.startsWith('powershell')) { - return { args: ['-NoLogo'], command: shellPath, name } - } - - if (name.startsWith('cmd')) { - return { args: [], command: shellPath, name } - } - - return posixShellSpec(shellPath) -} - -// Best installed Windows shell: PowerShell 7+ (`pwsh`), then Windows PowerShell -// 5.1, then comspec/cmd.exe as the universal fallback. -function windowsShellSpec() { - const command = - findOnPath('pwsh.exe') || findOnPath('pwsh') || windowsPowerShellPath() || process.env.COMSPEC || 'cmd.exe' - - return shellSpecFor(command) -} - -// Resolve the interactive shell for the embedded terminal: an explicit user -// override wins, otherwise auto-detect the best one installed for the platform. -function terminalShellCommand() { - // HERMES_DESKTOP_SHELL is the cross-platform escape hatch (a path or a bare - // name on PATH); $SHELL is honored on POSIX, where it's the user's canonical - // choice, but ignored on Windows, where it's usually a stray MSYS/Git path - // node-pty can't spawn natively. - const override = (process.env.HERMES_DESKTOP_SHELL || (IS_WINDOWS ? '' : process.env.SHELL) || '').trim() - - if (override) { - const resolved = isExecutableFile(override) ? override : findOnPath(override) - - if (resolved) { - return shellSpecFor(resolved) - } - } - - if (IS_WINDOWS) { - return windowsShellSpec() - } - - const shellPath = ['/bin/zsh', '/bin/bash', '/bin/sh'].find(candidate => isExecutableFile(candidate)) - - return posixShellSpec(shellPath || '/bin/sh') -} - -function safeTerminalCwd(cwd) { - const candidate = path.resolve(String(cwd || app.getPath('home'))) - - try { - const stat = fs.statSync(candidate) - - return stat.isDirectory() ? candidate : path.dirname(candidate) - } catch { - return app.getPath('home') - } -} - -function terminalShellEnv() { - const env = { ...process.env } - - // Electron is commonly launched through `npm run dev`; do not leak npm's - // managed prefix into a user's interactive shell (nvm/proto warn loudly). - for (const key of Object.keys(env)) { - if (key === 'npm_config_prefix' || key.startsWith('npm_config_') || key.startsWith('npm_package_')) { - delete env[key] - } - } - - // Strip color/theme-detection vars that ride along when Electron is launched - // from a non-tty agent shell (Cursor's runner sets NO_COLOR/FORCE_COLOR=0 - // /TERM=dumb; some terminals set COLORFGBG which would flip Hermes' TUI into - // light-mode). Our PTY is a real xterm-compat terminal — force truecolor. - delete env.NO_COLOR - delete env.FORCE_COLOR - delete env.COLORFGBG - - env.COLORTERM = 'truecolor' - env.LC_CTYPE = env.LC_CTYPE || 'UTF-8' - env.TERM = 'xterm-256color' - env.TERM_PROGRAM = 'Hermes' - env.TERM_PROGRAM_VERSION = app.getVersion() - - // Let a hermes/--tui launched in this pane know it's embedded in the desktop - // GUI (build_environment_hints surfaces this). Distinct from HERMES_DESKTOP, - // which marks the agent *backend* and gates cron/gateway behavior. - env.HERMES_DESKTOP_TERMINAL = '1' - - return env -} - -function terminalChannel(id, suffix) { - return `hermes:terminal:${id}:${suffix}` -} - -// Best-effort read of a live PTY child's current working directory so a -// reopened tab can restart the shell where the user last `cd`'d, instead of the -// tab's original launch dir. Shell-agnostic (no prompt/OSC config needed) on -// POSIX; Windows has no cheap per-process cwd query without a native module, so -// it returns null and the caller falls back to the launch cwd. -function readProcessCwd(pid) { - return new Promise(resolve => { - if (!Number.isInteger(pid) || pid <= 0) { - resolve(null) - - return - } - - if (process.platform === 'linux') { - fs.promises - .readlink(`/proc/${pid}/cwd`) - .then(target => resolve(target || null)) - .catch(() => resolve(null)) - - return - } - - if (process.platform === 'darwin') { - // lsof ships with macOS; -Fn emits the cwd fd's path on an `n` line. - execFile('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], { timeout: 2000 }, (err, stdout) => { - if (err) { - resolve(null) - - return - } - - const line = String(stdout || '') - .split('\n') - .find(entry => entry.startsWith('n')) - - resolve(line ? line.slice(1) : null) - }) - - return - } - - resolve(null) - }) -} - -function disposeTerminalSession(id) { - const sessionInfo = terminalSessions.get(id) - - if (!sessionInfo) { - return false - } - - terminalSessions.delete(id) - - try { - sessionInfo.pty.kill() - } catch { - // Process may already be gone. - } - - return true -} - -ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dirPath)) - -ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath)) - -// Reveal a path in the OS file manager (Finder / Explorer / Files). -ipcMain.handle('hermes:fs:reveal', async (_event, targetPath) => { - const target = String(targetPath || '').trim() - - if (!target) { - return false - } - - try { - shell.showItemInFolder(target) - - return true - } catch { - return false - } +// Local filesystem + plugin-root IPC (readDir/reveal/rename/trash/…) — see fs-ipc.ts. +registerFsIpc({ + hermesHome: HERMES_HOME, + readActiveDesktopProfile, + expandUserPath, + resolveRequestedPathForIpc, + directoryExists, + resolveGitBinary }) -// Open a DIRECTORY in the OS file manager, creating it first if needed. Unlike -// `reveal` (which selects an existing item and silently no-ops on a missing -// path — the "Open plugins folder" Windows bug), this is for the plugins door, -// which often doesn't exist on first use. `shell.openPath` returns '' on -// success or an error string; both mkdir + openPath failures are surfaced. -ipcMain.handle('hermes:fs:openDir', async (_event, dirPath) => { - const dir = String(dirPath || '').trim() +// Git-driven features (worktrees, review pane, repo scan) — see git-ipc.ts. +registerGitIpc({ resolveGitBinary, resolveGhBinary }) - if (!dir) { - return { ok: false, error: 'no path' } - } - - try { - await fs.promises.mkdir(dir, { recursive: true }) - const error = await shell.openPath(path.normalize(dir)) - - return error ? { ok: false, error } : { ok: true } - } catch (error) { - return { ok: false, error: error instanceof Error ? error.message : String(error) } - } +// Embedded terminal PTY host (hermes:terminal:*) — see terminal-ipc.ts. +const terminalIpc = registerTerminalIpc({ + isWindows: IS_WINDOWS, + findOnPath, + rememberLog, + activeSshTerminalTarget, + ensureBackend: () => ensureBackend(primaryProfileKey()), + getSshConnectionState: scope => sshConnections.get(scope) }) -// The LOCAL Desktop runtime-plugin root: `/desktop-plugins`, -// resolved from the main-process HERMES_HOME (see resolveHermesHome) — NOT from -// the connected backend. A remote backend reports its own `hermes_home` over -// the gateway, which is a path on the REMOTE box; deriving the plugin dir from -// it yields `undefined/desktop-plugins` (or a non-existent remote path) and the -// on-disk plugin door silently breaks (#66899). Electron owns this resolution -// so it stays valid in every connection mode. Created on demand, like openDir. -async function localPluginsRoot(dirName: string): Promise { - // Profile-aware: a named Desktop profile gets its own plugin root under - // profiles//, matching the profile-scoped hermes_home the backend - // reported before this resolver existed. 'default'/unset pins the global root. - const profile = readActiveDesktopProfile() - const base = profile && profile !== 'default' ? path.join(HERMES_HOME, 'profiles', profile) : HERMES_HOME - const dir = path.join(base, dirName) - - try { - await fs.promises.mkdir(dir, { recursive: true }) - } catch { - // Best-effort create; return the path regardless so the reveal action can - // still surface a real openPath error and the scanner can retry later. - } - - return dir -} - -ipcMain.handle('hermes:fs:desktopPluginsRoot', async () => localPluginsRoot('desktop-plugins')) - -// The LOCAL agent-plugin root (`/plugins`), same Electron-local -// resolution as above. This is the desktop half of a UNIFIED plugin package: -// an agent plugin may ship `desktop/plugin.js` alongside its Python code (the -// same shape as `dashboard/manifest.json`), and the renderer's disk door scans -// this root for it — one installable folder serving both SDKs. -ipcMain.handle('hermes:fs:agentPluginsRoot', async () => localPluginsRoot('plugins')) - -ipcMain.handle('hermes:plugin:probe', async (_event, payload) => { - const identifier = String(payload?.identifier || payload?.repo || '').trim() - - if (!identifier) { - return { ok: false, error: 'identifier is required', agent: false, desktop: false, warnings: [] } - } - - return probePluginRepo(resolveGitBinary(), identifier) -}) - -ipcMain.handle('hermes:plugin:installDesktop', async (_event, payload) => { - const identifier = String(payload?.identifier || payload?.repo || '').trim() - - if (!identifier) { - return { ok: false, error: 'identifier is required' } - } - - const desktopPluginsRoot = await localPluginsRoot('desktop-plugins') - - return installDesktopPluginFromGit(resolveGitBinary(), identifier, desktopPluginsRoot, Boolean(payload?.force)) -}) - -// Rename a file/folder in place. The renderer passes the existing path + a new -// base name; the destination is resolved in the SAME parent dir so a rename can -// never move the item elsewhere or traverse out. Rejects on a name collision. -ipcMain.handle('hermes:fs:rename', async (_event, targetPath, newName) => { - const src = String(targetPath || '').trim() - const name = String(newName || '').trim() - - if (!src || !name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) { - throw new Error('Invalid rename') - } - - const dst = path.join(path.dirname(src), name) - - if (dst === src) { - return { path: dst } - } - - if (fs.existsSync(dst)) { - throw new Error(`"${name}" already exists`) - } - - await fs.promises.rename(src, dst) - - return { path: dst } -}) - -// Write a small UTF-8 text file (e.g. a project's IDEA.md at creation). The path -// is hardened (resolveRequestedPathForIpc) and the parent must already exist — -// this never creates directory trees or escapes the allowed roots, and content -// is size-capped so it can't be abused as a bulk-write primitive. -ipcMain.handle('hermes:fs:writeText', async (_event, filePath, content) => { - const raw = String(filePath || '').trim() - - if (!raw) { - throw new Error('Invalid path') - } - - const text = String(content ?? '') - - if (text.length > 1_000_000) { - throw new Error('Content too large') - } - - const resolved = resolveRequestedPathForIpc(expandUserPath(raw), { purpose: 'Write text file' }) - - if (!directoryExists(path.dirname(resolved))) { - throw new Error('Parent directory does not exist') - } - - await fs.promises.writeFile(resolved, text, 'utf8') - - return { path: resolved } -}) - -// Move a file/folder to the OS trash (recoverable) — the VS Code "Delete" -// default. `shell.trashItem` routes to Finder/Explorer/Files trash per platform. -ipcMain.handle('hermes:fs:trash', async (_event, targetPath) => { - const target = String(targetPath || '').trim() - - if (!target) { - throw new Error('Invalid delete') - } - - await shell.trashItem(target) - - return true -}) - -// Git-driven worktree management ("Start work" flow). Errors surface to the -// renderer as rejected promises so it can toast a friendly message. -ipcMain.handle('hermes:git:worktreeList', async (_event, repoPath) => listWorktrees(repoPath, resolveGitBinary())) - -ipcMain.handle('hermes:git:worktreeAdd', async (_event, repoPath, options) => - addWorktree(repoPath, options || {}, resolveGitBinary()) -) - -ipcMain.handle('hermes:git:worktreeRemove', async (_event, repoPath, worktreePath, options) => - removeWorktree(repoPath, worktreePath, options || {}, resolveGitBinary()) -) - -ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) => - switchBranch(repoPath, branch, resolveGitBinary()) -) - -ipcMain.handle('hermes:git:branchList', async (_event, repoPath) => listBranches(repoPath, resolveGitBinary())) - -ipcMain.handle('hermes:git:baseBranchList', async (_event, repoPath) => listBaseBranches(repoPath, resolveGitBinary())) - -// Compact repo status (branch, ahead/behind, change counts + files) for the -// composer coding rail. Returns null on a non-repo / remote backend so the rail -// hides cleanly rather than erroring. -ipcMain.handle('hermes:git:repoStatus', async (_event, repoPath) => repoStatus(repoPath, resolveGitBinary())) - -// Codex-style review pane: list changed files for a scope, fetch one file's -// unified diff, and stage / unstage / revert. Reads return empty on failure; -// mutations reject so the renderer can toast. -ipcMain.handle('hermes:git:review:list', async (_event, repoPath, scope, baseRef) => - reviewList(repoPath, scope, baseRef, resolveGitBinary()) -) -ipcMain.handle('hermes:git:review:diff', async (_event, repoPath, filePath, scope, baseRef, staged) => - reviewDiff(repoPath, filePath, scope, baseRef, staged, resolveGitBinary()) -) -// Working-tree-vs-HEAD diff for one file (the preview's "show the diff" view). -ipcMain.handle('hermes:git:fileDiff', async (_event, repoPath, filePath) => - fileDiffVsHead(repoPath, filePath, resolveGitBinary()) -) -ipcMain.handle('hermes:git:review:stage', async (_event, repoPath, filePath) => - reviewStage(repoPath, filePath ?? null, resolveGitBinary()) -) -ipcMain.handle('hermes:git:review:unstage', async (_event, repoPath, filePath) => - reviewUnstage(repoPath, filePath ?? null, resolveGitBinary()) -) -ipcMain.handle('hermes:git:review:revert', async (_event, repoPath, filePath) => - reviewRevert(repoPath, filePath ?? null, resolveGitBinary()) -) -ipcMain.handle('hermes:git:review:revParse', async (_event, repoPath, ref) => - reviewRevParse(repoPath, ref, resolveGitBinary()) -) -ipcMain.handle('hermes:git:review:commit', async (_event, repoPath, message, push) => - reviewCommit(repoPath, message, Boolean(push), resolveGitBinary()) -) -ipcMain.handle('hermes:git:review:commitContext', async (_event, repoPath) => - reviewCommitContext(repoPath, resolveGitBinary()) -) -ipcMain.handle('hermes:git:review:push', async (_event, repoPath) => reviewPush(repoPath, resolveGitBinary())) -ipcMain.handle('hermes:git:review:shipInfo', async (_event, repoPath) => reviewShipInfo(repoPath, resolveGhBinary())) -ipcMain.handle('hermes:git:review:prList', async (_event, repoPath, branches, numbers) => - reviewPrList(repoPath, resolveGhBinary(), branches, numbers) -) -ipcMain.handle('hermes:git:review:fetchPrComment', async (_event, repoPath, url) => - reviewFetchPrComment(repoPath, resolveGhBinary(), url) -) -ipcMain.handle('hermes:git:review:createPr', async (_event, repoPath) => - reviewCreatePr(repoPath, resolveGitBinary(), resolveGhBinary()) -) - -// Repo-first project discovery: scan bounded roots for git repos (pure fs walk, -// no native addon). Never throws to the renderer — failures yield an empty list. -ipcMain.handle('hermes:git:scanRepos', async (_event, roots, options) => { - try { - return await scanGitRepos(roots || [], options || {}) - } catch { - return [] - } -}) - -// node-pty's published tarball ships the POSIX `spawn-helper` without an exec -// bit; the dev flow resolves node-pty straight from node_modules (nothing -// chmods it there), so the first terminal spawn dies with `posix_spawnp -// failed`. Restore the bit once, lazily, right before the first spawn. Packaged -// builds already stage an executable copy, so this is a no-op there. -let _spawnHelperEnsured = false - -function ensureNodePtySpawnHelper() { - if (_spawnHelperEnsured || IS_WINDOWS) { - return - } - - _spawnHelperEnsured = true - - try { - const nodePtyRoot = path.dirname(require.resolve('node-pty/package.json')) - const { fixed, errors } = ensureSpawnHelperExecutable(nodePtyRoot) - - for (const helperPath of fixed) { - rememberLog(`[terminal] restored +x on node-pty spawn-helper: ${helperPath}`) - } - - for (const failure of errors) { - rememberLog(`[terminal] could not chmod spawn-helper ${failure.path}: ${failure.error}`) - } - } catch (error) { - rememberLog(`[terminal] spawn-helper exec check skipped: ${error instanceof Error ? error.message : String(error)}`) - } -} - -ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => { - ensureNodePtySpawnHelper() - - const id = crypto.randomUUID() - const { args, command, name } = terminalShellCommand() - const cwd = safeTerminalCwd(payload?.cwd) - const cols = Math.max(2, Number.parseInt(String(payload?.cols || 80), 10) || 80) - const rows = Math.max(2, Number.parseInt(String(payload?.rows || 24), 10) || 24) - - const sshTarget = await resolveTerminalConnection(activeSshTerminalTarget, () => ensureBackend(primaryProfileKey())) - const remote = Boolean(sshTarget) - const remoteState = remote ? sshConnections.get(sshTarget.scope) : null - - const remoteCommand = - remoteState?.remotePlatform === 'Windows' - ? buildWindowsInteractiveCommand(String(payload?.cwd || '').trim()) - : undefined - - const ptyProcess = remote - ? nodePty.spawn( - process.platform === 'win32' - ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe') - : 'ssh', - buildInteractiveSshArgs(sshTarget.ssh, String(payload?.cwd || '').trim(), undefined, remoteCommand), - { cols, cwd: app.getPath('home'), env: terminalShellEnv(), name: 'xterm-256color', rows } - ) - : nodePty.spawn(command, args, { cols, cwd, env: terminalShellEnv(), name: 'xterm-256color', rows }) - - terminalSessions.set(id, { - pty: ptyProcess, - webContentsId: event.sender.id, - ...(remote ? { sshScope: sshTarget.scope, remoteCwd: String(payload?.cwd || '') } : {}) - }) - - const send = (suffix, payload) => { - if (event.sender.isDestroyed()) { - return - } - - event.sender.send(terminalChannel(id, suffix), payload) - } - - ptyProcess.onData(data => send('data', data)) - ptyProcess.onExit(({ exitCode, signal }) => { - terminalSessions.delete(id) - send('exit', { code: exitCode, signal: signal || null }) - }) - event.sender.once('destroyed', () => disposeTerminalSession(id)) - - return { cwd: remote ? null : cwd, id, shell: remote ? 'ssh' : name } -}) - -ipcMain.handle('hermes:terminal:write', (_event, id, data) => { - const sessionInfo = terminalSessions.get(String(id || '')) - - if (!sessionInfo) { - return false - } - - sessionInfo.pty.write(String(data || '')) - - return true -}) - -ipcMain.handle('hermes:terminal:resize', (_event, id, size = {}) => { - const sessionInfo = terminalSessions.get(String(id || '')) - - if (!sessionInfo) { - return false - } - - const cols = Math.max(2, Number.parseInt(String(size?.cols || 80), 10) || 80) - const rows = Math.max(2, Number.parseInt(String(size?.rows || 24), 10) || 24) - - sessionInfo.pty.resize(cols, rows) - - return true -}) -ipcMain.handle('hermes:terminal:cwd', async (_event, id) => { - const sessionInfo = terminalSessions.get(String(id || '')) - - if (!sessionInfo) { - return null - } - - return sessionInfo.sshScope !== undefined ? null : readProcessCwd(sessionInfo.pty.pid) -}) - -ipcMain.handle('hermes:terminal:dispose', (_event, id) => disposeTerminalSession(String(id || ''))) +const disposeTerminalSession = terminalIpc.disposeTerminalSession ipcMain.handle('hermes:updates:check', async () => checkUpdates().catch(error => ({ @@ -15629,9 +15025,7 @@ app.on('before-quit', event => { // Kill open PTYs before environment teardown to avoid the node-pty#904 // ThreadSafeFunction SIGABRT race. - for (const id of [...terminalSessions.keys()]) { - disposeTerminalSession(id) - } + terminalIpc.disposeAllTerminalSessions() void backendShutdown.run() }) diff --git a/apps/desktop/electron/native-auth-decisions.test.ts b/apps/desktop/electron/native-auth-decisions.test.ts index d4cfc068cd..6eb7a10dac 100644 --- a/apps/desktop/electron/native-auth-decisions.test.ts +++ b/apps/desktop/electron/native-auth-decisions.test.ts @@ -13,6 +13,7 @@ import { test } from 'vitest' import { oauthGuardMayHardFail, oauthSessionIsLive, + resolveGatedDownloadAuth, resolveJsonBody, resolveOauthRestAuth, resolveReadinessProbeAuth @@ -130,3 +131,20 @@ test('oauthGuardMayHardFail keeps the strict guard when the list is unusable', ( assert.equal(oauthGuardMayHardFail('nonsense' as any), true) assert.equal(oauthGuardMayHardFail([{ supportsPassword: true }]), true) }) + +// --- 6. gated download auth (guards the Files-panel 401 on cookieless native) --- + +test('resolveGatedDownloadAuth matches oauth REST: bearer first, then cookie', () => { + assert.deepEqual(resolveGatedDownloadAuth('oauth', 'native-at'), { kind: 'bearer', token: 'native-at' }) + assert.deepEqual(resolveGatedDownloadAuth('oauth', null), { kind: 'cookie' }) + assert.deepEqual(resolveGatedDownloadAuth('oauth', ''), { kind: 'cookie' }) +}) + +test('resolveGatedDownloadAuth uses the session token for token and local modes', () => { + assert.deepEqual(resolveGatedDownloadAuth('token', 'native-at', 'session-token'), { + kind: 'token', + token: 'session-token' + }) + assert.deepEqual(resolveGatedDownloadAuth('local', null, 'sess'), { kind: 'token', token: 'sess' }) + assert.deepEqual(resolveGatedDownloadAuth(undefined, null, null), { kind: 'token', token: null }) +}) diff --git a/apps/desktop/electron/native-auth-decisions.ts b/apps/desktop/electron/native-auth-decisions.ts index c0978c3ecd..9e620bd906 100644 --- a/apps/desktop/electron/native-auth-decisions.ts +++ b/apps/desktop/electron/native-auth-decisions.ts @@ -2,7 +2,7 @@ * native-auth-decisions.ts * * Pure decision helpers extracted from main.ts for the RFC 8252 native-app - * auth flow. These encode three choices that were each the site of a real + * auth flow. These encode six choices that were each the site of a real * runtime bug — invisible to the mocked flow tests because the tests never * exercised the real main.ts internals. Keeping them pure + unit-tested here * prevents silent regressions: @@ -31,7 +31,12 @@ * can satisfy neither the native-bearer nor the OAuth-partition-cookie * check by design, so the pre-flight guard must not hard-fail it. * - * All five are trivial once named; the value is the test that pins the + * 6. resolveGatedDownloadAuth — file save/read must present the SAME + * credentials as oauth REST. `saveGatewayFile` used to always ride the + * OAuth cookie partition, so a cookieless native (or native-password) + * session could list files via `hermes:api` and still 401 on Download. + * + * All six are trivial once named; the value is the test that pins the * contract so the god-file call sites can't drift back to the buggy shape. */ @@ -106,6 +111,28 @@ export function resolveReadinessProbeAuth( return { kind: 'public' } } +export type GatedDownloadAuth = OauthRestAuth | { kind: 'token'; token: string | null } + +/** + * Decide how a gated file download authenticates. + * + * Must match oauth REST (`resolveOauthRestAuth`): native bearer when present, + * else the OAuth cookie partition. Token/local connections keep the static + * session-token header. A cookie-only download against a cookieless native + * session is the #88987 401 — Files panel listing works, Download does not. + */ +export function resolveGatedDownloadAuth( + authMode: string | null | undefined, + nativeAccessToken?: string | null, + connectionToken?: string | null +): GatedDownloadAuth { + if (authMode === 'oauth') { + return resolveOauthRestAuth(nativeAccessToken) + } + + return { kind: 'token', token: connectionToken ?? null } +} + export interface AdvertisedAuthProvider { name?: string supportsPassword?: boolean diff --git a/apps/desktop/electron/pet-overlay-ipc.ts b/apps/desktop/electron/pet-overlay-ipc.ts new file mode 100644 index 0000000000..9738f4e8e0 --- /dev/null +++ b/apps/desktop/electron/pet-overlay-ipc.ts @@ -0,0 +1,151 @@ +// IPC surface for the pop-out pet overlay (mascot window). Extracted from +// main.ts; window handles stay injected because main.ts owns their lifecycle. +import { type BrowserWindow, ipcMain } from 'electron' + +export interface PetOverlayIpcDeps { + getMainWindow: () => BrowserWindow | null + getPetOverlayWindow: () => BrowserWindow | null + openPetOverlay: (bounds: unknown) => void + closePetOverlay: () => void +} + +export function registerPetOverlayIpc({ + getMainWindow, + getPetOverlayWindow, + openPetOverlay, + closePetOverlay +}: PetOverlayIpcDeps) { + // `request` is `{ bounds, screen }`. A fresh pop-out passes viewport-space + // bounds (screen=false): convert to screen space by adding the main window's + // content origin so the pet lands where it sat in-window. A remembered/dragged + // spot passes screen-space bounds (screen=true) and is used as-is. We return the + // resolved screen bounds so the renderer can persist exactly where it opened. + ipcMain.handle('hermes:pet-overlay:open', async (_event, request) => { + const bounds = request && request.bounds ? request.bounds : request + const isScreen = Boolean(request && request.screen) + const mainWindow = getMainWindow() + let screenBounds = bounds + + try { + if (bounds && !isScreen && mainWindow && !mainWindow.isDestroyed()) { + const content = mainWindow.getContentBounds() + screenBounds = { + x: content.x + (bounds.x || 0), + y: content.y + (bounds.y || 0), + width: bounds.width, + height: bounds.height + } + } + } catch { + // Fall back to raw bounds if the window geometry is unavailable. + } + + openPetOverlay(screenBounds) + + return { ok: true, bounds: screenBounds } + }) + ipcMain.handle('hermes:pet-overlay:close', async () => { + closePetOverlay() + + return { ok: true } + }) + // Drag/resize: the overlay reports new absolute screen bounds (it already knows + // the pointer's screen coords). Drag keeps the size constant; the wheel-to-scale + // gesture grows/shrinks it so the sprite is never cropped by the window edge. + // The window is created non-resizable (no stray edge-drag on the transparent + // frameless panel), which on Windows/Linux also blocks programmatic setBounds + // sizing — so briefly flip resizable on whenever the size actually changes. + ipcMain.on('hermes:pet-overlay:set-bounds', (_event, bounds) => { + const petOverlayWindow = getPetOverlayWindow() + + if (!petOverlayWindow || petOverlayWindow.isDestroyed() || !bounds) { + return + } + + const win = petOverlayWindow + const width = Math.max(80, Math.round(bounds.width)) + const height = Math.max(80, Math.round(bounds.height)) + const [curW, curH] = win.getSize() + const resizing = width !== curW || height !== curH + + if (resizing && !win.isResizable()) { + win.setResizable(true) + } + + win.setBounds({ x: Math.round(bounds.x), y: Math.round(bounds.y), width, height }) + + if (resizing) { + win.setResizable(false) + } + }) + // Click-through: the overlay window is a full rectangle but only the pet pixels + // should be interactive. The renderer toggles this as the cursor enters/leaves + // the sprite so transparent margins pass clicks to whatever is behind. + ipcMain.on('hermes:pet-overlay:ignore-mouse', (_event, ignore) => { + const petOverlayWindow = getPetOverlayWindow() + + if (petOverlayWindow && !petOverlayWindow.isDestroyed()) { + petOverlayWindow.setIgnoreMouseEvents(Boolean(ignore), { forward: true }) + } + }) + // The overlay is a non-activating panel (focusable:false) so it never steals + // the app's cmd/alt-tab anchor from the main window. But the pop-up composer + // needs the keyboard, so the renderer asks us to flip it focusable + focus it + // while the composer is open, then back to non-activating when it closes. + ipcMain.on('hermes:pet-overlay:set-focusable', (_event, focusable) => { + const petOverlayWindow = getPetOverlayWindow() + + if (!petOverlayWindow || petOverlayWindow.isDestroyed()) { + return + } + + petOverlayWindow.setFocusable(Boolean(focusable)) + + if (focusable) { + petOverlayWindow.focus() + } + }) + // Main renderer → overlay: forward the latest pet state for the overlay to render. + ipcMain.on('hermes:pet-overlay:state', (_event, payload) => { + const petOverlayWindow = getPetOverlayWindow() + + if (petOverlayWindow && !petOverlayWindow.isDestroyed()) { + petOverlayWindow.webContents.send('hermes:pet-overlay:state', payload) + } + }) + // Overlay → main renderer: control messages (pop back in, composer submit). + ipcMain.on('hermes:pet-overlay:control', (_event, payload) => { + const mainWindow = getMainWindow() + + if (!mainWindow || mainWindow.isDestroyed()) { + return + } + + // Double-click toggles the app window: hide it away if it's up front, bring it + // back if it's minimized/buried. Pure window control — nothing for the + // renderer to do, so don't forward it. + if (payload && payload.type === 'toggle-app') { + if (mainWindow.isMinimized() || !mainWindow.isVisible()) { + mainWindow.show() + mainWindow.focus() + } else { + mainWindow.minimize() + } + + return + } + + // The mail icon means "take me to the app": raise the main window (it may be + // minimized or buried) before the renderer navigates to the latest thread. + if (payload && payload.type === 'open-app') { + if (mainWindow.isMinimized()) { + mainWindow.restore() + } + + mainWindow.show() + mainWindow.focus() + } + + mainWindow.webContents.send('hermes:pet-overlay:control', payload) + }) +} diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 6b474b56d8..e95c5e389a 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -1,6 +1,17 @@ -import { contextBridge, ipcRenderer, webUtils } from 'electron' +import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron' + +// Which translucency the OS can back. Asked synchronously because the renderer +// needs it before its first paint, and answered by main because deciding it +// needs `os.release()` — a sandboxed preload may only require electron, events, +// timers and url, so importing node:os here throws before contextBridge runs +// and takes the ENTIRE bridge down with it (window.hermesDesktop undefined => +// "Desktop IPC bridge is unavailable"). No reply means no glass, which degrades +// to an ordinary opaque window rather than a page thinned over nothing. +const translucencySupport = ipcRenderer.sendSync('hermes:translucency:support') contextBridge.exposeInMainWorld('hermesDesktop', { + glassSupported: translucencySupport?.glass === true, + translucencySupported: translucencySupport?.translucency === true, getConnection: profile => ipcRenderer.invoke('hermes:connection', profile), // Registry-scoped backend resolution: { connectionId, profile } → descriptor. getConnectionFor: payload => ipcRenderer.invoke('hermes:connection:for', payload), @@ -64,7 +75,10 @@ contextBridge.exposeInMainWorld('hermesDesktop', { setIgnoreMouse: ignore => ipcRenderer.send('hermes:hud:ignore-mouse', ignore), moveBy: delta => ipcRenderer.send('hermes:hud:move-by', delta), setBounds: bounds => ipcRenderer.send('hermes:hud:set-bounds', bounds), - setVibrancy: on => ipcRenderer.invoke('hermes:hud:vibrancy', on), + // Whether the band covers the window below the bar. Main pairs it with the + // user's translucency setting to decide the native frost (macOS vibrancy / + // Windows 11 DWM backdrop) — see hudFrostFor. + setFrost: showing => ipcRenderer.invoke('hermes:hud:frost', showing), // The HUD tells main which session it is on; main hands that back to the // app window when the HUD closes, so the app can re-home onto it. setSession: sessionId => ipcRenderer.send('hermes:hud:session', sessionId), @@ -137,9 +151,12 @@ contextBridge.exposeInMainWorld('hermesDesktop', { save: payload => ipcRenderer.invoke('hermes:connections:save', payload), remove: id => ipcRenderer.invoke('hermes:connections:remove', id), setPrimary: id => ipcRenderer.invoke('hermes:connections:set-primary', id), + setLaunchMode: mode => ipcRenderer.invoke('hermes:connections:set-launch-mode', mode), + setLastUsed: id => ipcRenderer.invoke('hermes:connections:set-last-used', id), test: id => ipcRenderer.invoke('hermes:connections:test', id), // Fan out `hermes update` to every eligible registered connection. - updateAll: () => ipcRenderer.invoke('hermes:connections:update-all'), + // Optional excludeIds skips rows the caller updates through another path. + updateAll: options => ipcRenderer.invoke('hermes:connections:update-all', options), // Registry lifecycle push (main → renderer): a connection was removed or // materially edited, so secondaries scoped to it must be disposed (and, // for edits, re-dialed at the new target). @@ -185,6 +202,16 @@ contextBridge.exposeInMainWorld('hermesDesktop', { readClipboard: () => ipcRenderer.invoke('hermes:readClipboard'), saveGatewayFile: payload => ipcRenderer.invoke('hermes:saveGatewayFile', payload), saveImageFromUrl: url => ipcRenderer.invoke('hermes:saveImageFromUrl', url), + contextMenuEdit: command => ipcRenderer.invoke('hermes:context-menu:edit', command), + contextMenuCopyImage: () => ipcRenderer.invoke('hermes:context-menu:copy-image'), + contextMenuSpellcheck: action => ipcRenderer.invoke('hermes:context-menu:spellcheck', action), + contextMenuGuestAddWord: payload => ipcRenderer.invoke('hermes:context-menu:guest-add-word', payload), + onContextMenuSpellcheck: callback => { + const listener = (_event, payload) => callback(payload) + ipcRenderer.on('hermes:context-menu-spellcheck', listener) + + return () => ipcRenderer.removeListener('hermes:context-menu-spellcheck', listener) + }, saveImageBuffer: (data, ext) => ipcRenderer.invoke('hermes:saveImageBuffer', { data, ext }), saveClipboardImage: () => ipcRenderer.invoke('hermes:saveClipboardImage'), getPathForFile: file => { @@ -218,6 +245,9 @@ contextBridge.exposeInMainWorld('hermesDesktop', { zoom: { // Current zoom of this window, as { level, percent }. get: () => ipcRenderer.invoke('hermes:zoom:get'), + // Synchronous zoom factor (1 = 100%). Coordinate math needs it in the + // same tick as the event it converts, so no IPC round-trip here. + factor: () => webFrame.getZoomFactor(), setPercent: percent => ipcRenderer.send('hermes:zoom:set-percent', percent), // Fires on every zoom change, including the Ctrl/Cmd +/-/0 shortcuts, // so the settings UI can stay in sync with the keyboard. diff --git a/apps/desktop/electron/remote-lifecycle.ts b/apps/desktop/electron/remote-lifecycle.ts index 501786863c..4a672685ec 100644 --- a/apps/desktop/electron/remote-lifecycle.ts +++ b/apps/desktop/electron/remote-lifecycle.ts @@ -28,6 +28,7 @@ import crypto from 'node:crypto' import { parseRemoteProfileListing } from './connection-registry' +import { assertBootstrapNotSuperseded } from './ssh-connection' const LOCKFILE_SCHEMA_VERSION = 2 // Bumped when the desktop<->dashboard reuse contract changes in a way that makes @@ -558,7 +559,7 @@ async function scrapeReadyPort(ssh, logPath, { timeoutMs = DEFAULT_READY_TIMEOUT const remoteLog = expandRemotePath(logPath) while (Date.now() < deadline) { - assertNotAborted(signal) + assertBootstrapNotSuperseded(signal) if (isAlive && !(await isAlive())) { const err: any = new Error('Remote dashboard process exited before announcing its port.') @@ -696,14 +697,6 @@ async function cancelForwardSafe(deps, localPort, remotePort) { } } -function assertNotAborted(signal) { - if (signal?.aborted) { - const error: any = new Error('SSH bootstrap was cancelled.') - error.kind = 'superseded' - throw error - } -} - function isForwardBindCollision(error) { return /address already in use|cannot listen to port|bind.*failed/i.test(String(error?.message || error || '')) } @@ -769,7 +762,7 @@ async function connect(deps) { const log = msg => rememberLog(`[ssh-lifecycle] ${msg}`) - assertNotAborted(signal) + assertBootstrapNotSuperseded(signal) const platform = await probeRemotePlatform(ssh) log(`remote platform ${platform.os}/${platform.arch}`) const hermesPath = await locateHermes(ssh, remoteHermesPath) @@ -810,7 +803,7 @@ async function connect(deps) { lock.hermesHome === hermesHome if (reusable) { - assertNotAborted(signal) + assertBootstrapNotSuperseded(signal) const localPort = await openForward(deps, lock.port) try { @@ -827,7 +820,7 @@ async function connect(deps) { } if (reuseClassification === 'authenticated-stale') { - assertNotAborted(signal) + assertBootstrapNotSuperseded(signal) await cancelForwardSafe(deps, localPort, lock.port) await cleanupStale(ssh, ownershipId, lock) } else if (reuseClassification === 'authenticated-ok') { @@ -840,7 +833,7 @@ async function connect(deps) { 'reused remote dashboard' ) - assertNotAborted(signal) + assertBootstrapNotSuperseded(signal) log(`reusing remote dashboard pid=${lock.pid} port=${lock.port}`) return { @@ -868,12 +861,12 @@ async function connect(deps) { throw error } } else { - assertNotAborted(signal) + assertBootstrapNotSuperseded(signal) await cleanupStale(ssh, ownershipId, lock, pidAlive) } } - assertNotAborted(signal) + assertBootstrapNotSuperseded(signal) const spawnToken = mintToken() const { pid, spawnNonce, logPath, tokenFilePath } = await spawnRemoteDashboard(ssh, { @@ -914,21 +907,21 @@ async function connect(deps) { isAlive: () => remotePidAlive(ssh, pid), signal }) - assertNotAborted(signal) + assertBootstrapNotSuperseded(signal) log(`remote dashboard bound port ${remotePort}`) localPort = await openForward(deps, remotePort) - assertNotAborted(signal) + assertBootstrapNotSuperseded(signal) const baseUrl = `http://127.0.0.1:${localPort}` await waitForHermes(baseUrl, spawnToken) - assertNotAborted(signal) + assertBootstrapNotSuperseded(signal) const token = await adoptOwnedServedToken(adoptServedToken, baseUrl, spawnToken, ssh, pid, 'remote dashboard') - assertNotAborted(signal) + assertBootstrapNotSuperseded(signal) const tokenFingerprint = fingerprintToken(token) await writeLockfile(ssh, ownershipId, { ...ownedSpawn, port: remotePort, tokenFingerprint }) - assertNotAborted(signal) + assertBootstrapNotSuperseded(signal) return { baseUrl, diff --git a/apps/desktop/electron/session-windows.test.ts b/apps/desktop/electron/session-windows.test.ts index 1959cc4458..c0a1178429 100644 --- a/apps/desktop/electron/session-windows.test.ts +++ b/apps/desktop/electron/session-windows.test.ts @@ -3,6 +3,7 @@ import assert from 'node:assert/strict' import { test } from 'vitest' import { + buildInstanceWindowUrl, buildSessionWindowUrl, chatWindowWebPreferences, createSessionWindowRegistry, @@ -88,6 +89,19 @@ test('buildSessionWindowUrl adds the watch flag for spectator windows, before th assert.equal(url, 'http://localhost:5173/?win=secondary&watch=1#/abc') }) +test('buildInstanceWindowUrl marks a full peer without selecting a specialized renderer', () => { + const url = buildInstanceWindowUrl({ devServer: 'http://localhost:5173/' }) + + assert.equal(url, 'http://localhost:5173/?peer=1') + assert.ok(!url.includes('win=')) +}) + +test('buildInstanceWindowUrl marks a packaged full peer', () => { + const url = buildInstanceWindowUrl({ rendererIndexPath: '/opt/app/index.html' }) + + assert.match(url, /^file:\/\/.*index\.html\?peer=1$/) +}) + test('instanceWindowBounds cascades a new window off its source bounds', () => { const bounds = instanceWindowBounds({ x: 100, y: 120, width: 1400, height: 900 }, { width: 1, height: 1 }) diff --git a/apps/desktop/electron/session-windows.ts b/apps/desktop/electron/session-windows.ts index 790f1c03a7..19be87ff1c 100644 --- a/apps/desktop/electron/session-windows.ts +++ b/apps/desktop/electron/session-windows.ts @@ -77,6 +77,23 @@ function buildSessionWindowUrl(sessionId: string, { devServer, rendererIndexPath return `${pathToFileURL(rendererIndexPath).toString()}${query}${route}` } +// Full peer windows render the ordinary app shell, so they deliberately do +// not use the `win` query parameter that selects a specialized renderer. The +// separate marker lets the renderer distinguish a peer from the one primary +// app window: app-launch source restoration belongs to the primary only, while +// a peer keeps the already-running backend it joined during boot. +function buildInstanceWindowUrl({ devServer, rendererIndexPath }: any = {}) { + const query = '?peer=1' + + if (devServer) { + const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer + + return `${base}/${query}` + } + + return `${pathToFileURL(rendererIndexPath).toString()}${query}` +} + // Full "instance" windows (⌘⇧N / the "New Window" command) open a complete app // peer, not a compact chat. Cascade each one off its source window's bounds so a // new window doesn't land exactly on top of the one it was spawned from. Pure so @@ -160,6 +177,7 @@ function createSessionWindowRegistry() { } export { + buildInstanceWindowUrl, buildSessionWindowUrl, chatWindowWebPreferences, createSessionWindowRegistry, diff --git a/apps/desktop/electron/ssh-connection.ts b/apps/desktop/electron/ssh-connection.ts index 5015f979e3..57264ec639 100644 --- a/apps/desktop/electron/ssh-connection.ts +++ b/apps/desktop/electron/ssh-connection.ts @@ -988,7 +988,19 @@ function createSshProbeConnection(config, options: any = {}) { return new SshConnection(config, { ...options, mux: false }) } +// Bootstrap loops poll a remote for readiness; a newer attempt aborts the +// signal so the stale one stops polling and unwinds. `superseded` tells the +// caller this was replaced, not that it failed. +function assertBootstrapNotSuperseded(signal) { + if (signal?.aborted) { + const error: any = new Error('SSH bootstrap was cancelled.') + error.kind = 'superseded' + throw error + } +} + export { + assertBootstrapNotSuperseded, baseSshOptions, buildControlArgs, buildExecArgs, diff --git a/apps/desktop/electron/terminal-ipc.ts b/apps/desktop/electron/terminal-ipc.ts new file mode 100644 index 0000000000..1b5953b859 --- /dev/null +++ b/apps/desktop/electron/terminal-ipc.ts @@ -0,0 +1,377 @@ +// The embedded terminal's PTY host: shell resolution, env scrubbing, session +// registry, and the hermes:terminal:* IPC surface. Extracted from main.ts; the +// factory owns the session map and returns the dispose helpers main.ts needs +// for SSH teardown. findOnPath / logging / connection routing stay injected. +import { execFile } from 'node:child_process' +import crypto from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' + +import { app, ipcMain } from 'electron' +import nodePty from 'node-pty' + +import { resolveTerminalConnection } from './connection-apply' +import { ensureSpawnHelperExecutable } from './spawn-helper-perms' +import { buildInteractiveSshArgs } from './ssh-connection' +import { buildWindowsInteractiveCommand } from './windows-remote-lifecycle' + +export interface TerminalIpcDeps { + isWindows: boolean + findOnPath: (command: string) => null | string + rememberLog: (line: string) => void + activeSshTerminalTarget: () => unknown + ensureBackend: () => Promise + getSshConnectionState: (scope: string) => undefined | { remotePlatform?: string } +} + +export interface TerminalIpcApi { + disposeTerminalSession: (id: string) => boolean + disposeTerminalSessionsForSshScope: (scope: string) => void + disposeAllTerminalSessions: () => void +} + +export function registerTerminalIpc({ + isWindows, + findOnPath, + rememberLog, + activeSshTerminalTarget, + ensureBackend, + getSshConnectionState +}: TerminalIpcDeps): TerminalIpcApi { + const terminalSessions = new Map() + + function isExecutableFile(filePath) { + if (!filePath || !path.isAbsolute(filePath)) { + return false + } + + try { + fs.accessSync(filePath, fs.constants.X_OK) + + return true + } catch { + return false + } + } + + function posixShellSpec(shellPath) { + const shellName = path.basename(shellPath) + const interactiveArgs = shellName.includes('zsh') || shellName.includes('bash') ? ['-il'] : ['-i'] + + return { args: interactiveArgs, command: shellPath, name: shellName } + } + + // Windows PowerShell 5.1 ships at a fixed System32 path on every Windows box; + // prefer it only after PowerShell 7+ (`pwsh`). + function windowsPowerShellPath() { + const systemRoot = process.env.SystemRoot || process.env.windir || 'C:\\Windows' + const builtin = path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') + + return isExecutableFile(builtin) ? builtin : findOnPath('powershell.exe') + } + + // Map a resolved shell path to its spawn spec, picking interactive flags by + // family: PowerShell drops its logo banner (so the prompt sits flush like the + // POSIX shells), cmd needs nothing, and everything else (zsh/bash/fish/sh…) + // gets POSIX interactive-login flags. + function shellSpecFor(shellPath) { + const name = path.basename(shellPath).toLowerCase() + + if (name.startsWith('pwsh') || name.startsWith('powershell')) { + return { args: ['-NoLogo'], command: shellPath, name } + } + + if (name.startsWith('cmd')) { + return { args: [], command: shellPath, name } + } + + return posixShellSpec(shellPath) + } + + // Best installed Windows shell: PowerShell 7+ (`pwsh`), then Windows PowerShell + // 5.1, then comspec/cmd.exe as the universal fallback. + function windowsShellSpec() { + const command = + findOnPath('pwsh.exe') || findOnPath('pwsh') || windowsPowerShellPath() || process.env.COMSPEC || 'cmd.exe' + + return shellSpecFor(command) + } + + // Resolve the interactive shell for the embedded terminal: an explicit user + // override wins, otherwise auto-detect the best one installed for the platform. + function terminalShellCommand() { + // HERMES_DESKTOP_SHELL is the cross-platform escape hatch (a path or a bare + // name on PATH); $SHELL is honored on POSIX, where it's the user's canonical + // choice, but ignored on Windows, where it's usually a stray MSYS/Git path + // node-pty can't spawn natively. + const override = (process.env.HERMES_DESKTOP_SHELL || (isWindows ? '' : process.env.SHELL) || '').trim() + + if (override) { + const resolved = isExecutableFile(override) ? override : findOnPath(override) + + if (resolved) { + return shellSpecFor(resolved) + } + } + + if (isWindows) { + return windowsShellSpec() + } + + const shellPath = ['/bin/zsh', '/bin/bash', '/bin/sh'].find(candidate => isExecutableFile(candidate)) + + return posixShellSpec(shellPath || '/bin/sh') + } + + function safeTerminalCwd(cwd) { + const candidate = path.resolve(String(cwd || app.getPath('home'))) + + try { + const stat = fs.statSync(candidate) + + return stat.isDirectory() ? candidate : path.dirname(candidate) + } catch { + return app.getPath('home') + } + } + + function terminalShellEnv() { + const env = { ...process.env } + + // Electron is commonly launched through `npm run dev`; do not leak npm's + // managed prefix into a user's interactive shell (nvm/proto warn loudly). + for (const key of Object.keys(env)) { + if (key === 'npm_config_prefix' || key.startsWith('npm_config_') || key.startsWith('npm_package_')) { + delete env[key] + } + } + + // Strip color/theme-detection vars that ride along when Electron is launched + // from a non-tty agent shell (Cursor's runner sets NO_COLOR/FORCE_COLOR=0 + // /TERM=dumb; some terminals set COLORFGBG which would flip Hermes' TUI into + // light-mode). Our PTY is a real xterm-compat terminal — force truecolor. + delete env.NO_COLOR + delete env.FORCE_COLOR + delete env.COLORFGBG + + env.COLORTERM = 'truecolor' + env.LC_CTYPE = env.LC_CTYPE || 'UTF-8' + env.TERM = 'xterm-256color' + env.TERM_PROGRAM = 'Hermes' + env.TERM_PROGRAM_VERSION = app.getVersion() + + // Let a hermes/--tui launched in this pane know it's embedded in the desktop + // GUI (build_environment_hints surfaces this). Distinct from HERMES_DESKTOP, + // which marks the agent *backend* and gates cron/gateway behavior. + env.HERMES_DESKTOP_TERMINAL = '1' + + return env + } + + function terminalChannel(id, suffix) { + return `hermes:terminal:${id}:${suffix}` + } + + // Best-effort read of a live PTY child's current working directory so a + // reopened tab can restart the shell where the user last `cd`'d, instead of the + // tab's original launch dir. Shell-agnostic (no prompt/OSC config needed) on + // POSIX; Windows has no cheap per-process cwd query without a native module, so + // it returns null and the caller falls back to the launch cwd. + function readProcessCwd(pid) { + return new Promise(resolve => { + if (!Number.isInteger(pid) || pid <= 0) { + resolve(null) + + return + } + + if (process.platform === 'linux') { + fs.promises + .readlink(`/proc/${pid}/cwd`) + .then(target => resolve(target || null)) + .catch(() => resolve(null)) + + return + } + + if (process.platform === 'darwin') { + // lsof ships with macOS; -Fn emits the cwd fd's path on an `n` line. + execFile('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], { timeout: 2000 }, (err, stdout) => { + if (err) { + resolve(null) + + return + } + + const line = String(stdout || '') + .split('\n') + .find(entry => entry.startsWith('n')) + + resolve(line ? line.slice(1) : null) + }) + + return + } + + resolve(null) + }) + } + + function disposeTerminalSession(id: string) { + const sessionInfo = terminalSessions.get(id) + + if (!sessionInfo) { + return false + } + + terminalSessions.delete(id) + + try { + sessionInfo.pty.kill() + } catch { + // Process may already be gone. + } + + return true + } + + // SSH teardown: close every pane whose PTY rode the disconnected tunnel. + function disposeTerminalSessionsForSshScope(scope: string) { + for (const [id, info] of [...terminalSessions.entries()]) { + if (info.sshScope === scope) { + disposeTerminalSession(id) + } + } + } + + // App shutdown: kill every open PTY before environment teardown. + function disposeAllTerminalSessions() { + for (const id of [...terminalSessions.keys()]) { + disposeTerminalSession(id) + } + } + + // node-pty's published tarball ships the POSIX `spawn-helper` without an exec + // bit; the dev flow resolves node-pty straight from node_modules (nothing + // chmods it there), so the first terminal spawn dies with `posix_spawnp + // failed`. Restore the bit once, lazily, right before the first spawn. Packaged + // builds already stage an executable copy, so this is a no-op there. + let _spawnHelperEnsured = false + + function ensureNodePtySpawnHelper() { + if (_spawnHelperEnsured || isWindows) { + return + } + + _spawnHelperEnsured = true + + try { + const nodePtyRoot = path.dirname(require.resolve('node-pty/package.json')) + const { fixed, errors } = ensureSpawnHelperExecutable(nodePtyRoot) + + for (const helperPath of fixed) { + rememberLog(`[terminal] restored +x on node-pty spawn-helper: ${helperPath}`) + } + + for (const failure of errors) { + rememberLog(`[terminal] could not chmod spawn-helper ${failure.path}: ${failure.error}`) + } + } catch (error) { + rememberLog( + `[terminal] spawn-helper exec check skipped: ${error instanceof Error ? error.message : String(error)}` + ) + } + } + + ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => { + ensureNodePtySpawnHelper() + + const id = crypto.randomUUID() + const { args, command, name } = terminalShellCommand() + const cwd = safeTerminalCwd(payload?.cwd) + const cols = Math.max(2, Number.parseInt(String(payload?.cols || 80), 10) || 80) + const rows = Math.max(2, Number.parseInt(String(payload?.rows || 24), 10) || 24) + + const sshTarget = await resolveTerminalConnection(activeSshTerminalTarget, ensureBackend) + const remote = Boolean(sshTarget) + const remoteState = remote ? getSshConnectionState(sshTarget.scope) : null + + const remoteCommand = + remoteState?.remotePlatform === 'Windows' + ? buildWindowsInteractiveCommand(String(payload?.cwd || '').trim()) + : undefined + + const ptyProcess = remote + ? nodePty.spawn( + process.platform === 'win32' + ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe') + : 'ssh', + buildInteractiveSshArgs(sshTarget.ssh, String(payload?.cwd || '').trim(), undefined, remoteCommand), + { cols, cwd: app.getPath('home'), env: terminalShellEnv(), name: 'xterm-256color', rows } + ) + : nodePty.spawn(command, args, { cols, cwd, env: terminalShellEnv(), name: 'xterm-256color', rows }) + + terminalSessions.set(id, { + pty: ptyProcess, + webContentsId: event.sender.id, + ...(remote ? { sshScope: sshTarget.scope, remoteCwd: String(payload?.cwd || '') } : {}) + }) + + const send = (suffix, payload) => { + if (event.sender.isDestroyed()) { + return + } + + event.sender.send(terminalChannel(id, suffix), payload) + } + + ptyProcess.onData(data => send('data', data)) + ptyProcess.onExit(({ exitCode, signal }) => { + terminalSessions.delete(id) + send('exit', { code: exitCode, signal: signal || null }) + }) + event.sender.once('destroyed', () => disposeTerminalSession(id)) + + return { cwd: remote ? null : cwd, id, shell: remote ? 'ssh' : name } + }) + + ipcMain.handle('hermes:terminal:write', (_event, id, data) => { + const sessionInfo = terminalSessions.get(String(id || '')) + + if (!sessionInfo) { + return false + } + + sessionInfo.pty.write(String(data || '')) + + return true + }) + + ipcMain.handle('hermes:terminal:resize', (_event, id, size = {}) => { + const sessionInfo = terminalSessions.get(String(id || '')) + + if (!sessionInfo) { + return false + } + + const cols = Math.max(2, Number.parseInt(String(size?.cols || 80), 10) || 80) + const rows = Math.max(2, Number.parseInt(String(size?.rows || 24), 10) || 24) + + sessionInfo.pty.resize(cols, rows) + + return true + }) + ipcMain.handle('hermes:terminal:cwd', async (_event, id) => { + const sessionInfo = terminalSessions.get(String(id || '')) + + if (!sessionInfo) { + return null + } + + return sessionInfo.sshScope !== undefined ? null : readProcessCwd(sessionInfo.pty.pid) + }) + + ipcMain.handle('hermes:terminal:dispose', (_event, id) => disposeTerminalSession(String(id || ''))) + + return { disposeTerminalSession, disposeTerminalSessionsForSshScope, disposeAllTerminalSessions } +} diff --git a/apps/desktop/electron/translucency.test.ts b/apps/desktop/electron/translucency.test.ts index 3db509f14b..59cc15828e 100644 --- a/apps/desktop/electron/translucency.test.ts +++ b/apps/desktop/electron/translucency.test.ts @@ -11,26 +11,39 @@ import { describe, expect, it } from 'vitest' import { + backgroundMaterialFor, clampIntensity, DEFAULT_GLASS_MATERIAL, DEFAULT_GLASS_SCOPE, + defaultTranslucencyState, + defaultTranslucencyValues, GLASS_MATERIALS, GLASS_SCOPES, glassActive, type GlassMaterial, + glassMaterialForPicker, + glassMaterialsFor, + glassSupportedOn, glassSurfaceKeep, + hudFrostFor, + normalizeBook, normalizeMaterial, normalizeMode, normalizeScope, normalizeState, + resolveTranslucency, + setTranslucencyValues, TRANSLUCENCY_CURVE, TRANSLUCENCY_MAX, TRANSLUCENCY_MIN, TRANSLUCENCY_OPACITY_FLOOR, type TranslucencyState, + translucencySupportedOn, vibrancyFor, windowBackingOptions, - windowOpacityFor + windowOpacityFor, + WINDOWS_BACKGROUND_MATERIALS, + WINDOWS_GLASS_MIN_BUILD } from './translucency' /** The linear ramp the curve replaced. Endpoints must still agree with it. */ @@ -38,13 +51,15 @@ const legacyOpacity = (intensity: number) => 1 - (intensity / 100) * 0.7 const clear = (intensity: number): TranslucencyState => ({ intensity, + fade: 0, mode: 'clear', material: DEFAULT_GLASS_MATERIAL, scope: DEFAULT_GLASS_SCOPE }) -const glass = (intensity: number, material: GlassMaterial = DEFAULT_GLASS_MATERIAL): TranslucencyState => ({ +const glass = (intensity: number, material: GlassMaterial = DEFAULT_GLASS_MATERIAL, fade = 0): TranslucencyState => ({ intensity, + fade, mode: 'glass', material, scope: DEFAULT_GLASS_SCOPE @@ -78,7 +93,7 @@ describe('clampIntensity', () => { }) describe('normalizeMode', () => { - it('accepts glass on macOS only — there is no vibrancy to ride elsewhere', () => { + it('accepts glass only on a platform that has a native material', () => { expect(normalizeMode('glass', true)).toBe('glass') expect(normalizeMode('glass', false)).toBe('clear') }) @@ -90,7 +105,7 @@ describe('normalizeMode', () => { // Glass is pre-selected so the better half of the feature is the one you // find, which is free because the intensity still starts at 0. - it('pre-selects glass on macOS when nothing is recorded', () => { + it('pre-selects glass when the platform supports it and nothing is recorded', () => { expect(normalizeMode(undefined, true)).toBe('glass') expect(normalizeMode('acrylic', true)).toBe('glass') expect(normalizeMode(42, true)).toBe('glass') @@ -163,11 +178,23 @@ describe('windowOpacityFor', () => { expect(windowOpacityFor(clear(240))).toBe(windowOpacityFor(clear(TRANSLUCENCY_MAX))) }) - it('never fades the native window in glass mode — the renderer paints that effect', () => { + // The tint is painted by the renderer, so the intensity lever must never + // reach setOpacity under glass — that separation is what keeps text sharp. + it('ignores the intensity lever entirely in glass mode', () => { expect(windowOpacityFor(glass(0))).toBe(1) expect(windowOpacityFor(glass(60))).toBe(1) expect(windowOpacityFor(glass(100))).toBe(1) }) + + it('fades a glass window only through its own lever, on the ramp clear uses', () => { + expect(windowOpacityFor(glass(60, DEFAULT_GLASS_MATERIAL, 0))).toBe(1) + expect(windowOpacityFor(glass(60, DEFAULT_GLASS_MATERIAL, 40))).toBe(windowOpacityFor(clear(40))) + expect(windowOpacityFor(glass(100, DEFAULT_GLASS_MATERIAL, 100))).toBe(windowOpacityFor(clear(100))) + }) + + it('leaves fade inert under clear, where the intensity lever already is the opacity', () => { + expect(windowOpacityFor({ ...clear(40), fade: 100 })).toBe(windowOpacityFor(clear(40))) + }) }) describe('glassSurfaceKeep', () => { @@ -224,10 +251,172 @@ describe('vibrancyFor', () => { }) }) +// The HUD is a transparent window, so its frost has no opaque page to hide +// behind: every state that isn't "frost wanted" has to resolve to no material +// at all, or the band leaves a grey slab hanging over another app. +describe('hudFrostFor', () => { + it('wears the chosen frost on both platforms while the band is showing', () => { + expect(hudFrostFor(glass(60, 'header'), true)).toEqual({ vibrancy: 'header', backgroundMaterial: 'mica' }) + expect(hudFrostFor(glass(60, 'under-window'), true)).toEqual({ + vibrancy: 'under-window', + backgroundMaterial: 'acrylic' + }) + }) + + // The material is the whole window rectangle and nothing on the page can + // clip it, so a hidden band must mean no frost — this is the veto that keeps + // idle HUD mode the bar and nothing else. + it('is off whenever the band is not covering the window', () => { + expect(hudFrostFor(glass(60, 'header'), false)).toEqual({ vibrancy: null, backgroundMaterial: 'none' }) + }) + + // ...and the setting is the other veto: Glass off, or the tint at zero, + // means the HUD never frosts however engaged the band is. + it('is off whenever glass itself is off', () => { + expect(hudFrostFor(clear(60), true)).toEqual({ vibrancy: null, backgroundMaterial: 'none' }) + expect(hudFrostFor(glass(0, 'header'), true)).toEqual({ vibrancy: null, backgroundMaterial: 'none' }) + }) + + // Unlike a chat window, which keeps 'sidebar' under its titlebar band in + // every non-glass state. Pinning this is what stops someone "fixing" the + // null into a resting material and painting the slab back. + it('resolves off to no material at all, not to a resting one', () => { + expect(hudFrostFor(clear(60), true).vibrancy).toBeNull() + expect(vibrancyFor(clear(60))).toBe('sidebar') + }) + + // The tint is painted by the renderer, exactly as it is for a chat window — + // dragging it must not re-issue setVibrancy, whose 150ms animation restarts + // on every call and never lets the material settle. + it('does not move any native property as the tint slider is dragged', () => { + for (let intensity = 1; intensity <= 100; intensity += 1) { + expect(hudFrostFor(glass(intensity, 'popover'), true)).toEqual({ + vibrancy: 'popover', + backgroundMaterial: 'tabbed' + }) + } + }) +}) + +describe('glassSupportedOn', () => { + it('is on for macOS regardless of kernel version', () => { + expect(glassSupportedOn('darwin')).toBe(true) + expect(glassSupportedOn('darwin', '24.6.0')).toBe(true) + }) + + it('is on for Windows 11 22H2 and newer, off for everything older', () => { + expect(glassSupportedOn('win32', `10.0.${WINDOWS_GLASS_MIN_BUILD}`)).toBe(true) + expect(glassSupportedOn('win32', `10.0.${WINDOWS_GLASS_MIN_BUILD}.1`)).toBe(true) + expect(glassSupportedOn('win32', `10.0.${WINDOWS_GLASS_MIN_BUILD - 1}`)).toBe(false) + expect(glassSupportedOn('win32', '10.0.19045')).toBe(false) + expect(glassSupportedOn('win32', '10.0')).toBe(false) + expect(glassSupportedOn('win32', '')).toBe(false) + }) + + it('is off on Linux — Electron has no first-party desktop material there', () => { + expect(glassSupportedOn('linux', '6.8.0')).toBe(false) + }) +}) + +describe('backgroundMaterialFor', () => { + it('is none while glass is off so DWM does not keep drawing under the backing', () => { + expect(backgroundMaterialFor(glass(0, 'header'))).toBe('none') + expect(backgroundMaterialFor(clear(60))).toBe('none') + }) + + it('maps the sheer → heavy frost ladder onto acrylic / tabbed / mica', () => { + expect(backgroundMaterialFor(glass(60, 'under-window'))).toBe('acrylic') + expect(backgroundMaterialFor(glass(60, 'popover'))).toBe('tabbed') + expect(backgroundMaterialFor(glass(60, 'titlebar'))).toBe('mica') + }) + + // Windows 11 has three system materials for four rungs, so the two heaviest + // land on mica. The mapping stays total — a saved 'header' still resolves — + // and the picker drops the duplicate instead (see glassMaterialsFor). + it('collapses Glare onto mica with Bright', () => { + expect(backgroundMaterialFor(glass(60, 'header'))).toBe('mica') + expect(backgroundMaterialFor(glass(60, 'header'))).toBe(backgroundMaterialFor(glass(60, 'titlebar'))) + }) + + it('resolves every shipped rung to a real system material', () => { + for (const material of GLASS_MATERIALS) { + expect(WINDOWS_BACKGROUND_MATERIALS, material).toContain(backgroundMaterialFor(glass(60, material))) + } + }) +}) + +describe('translucencySupportedOn', () => { + it('covers the two platforms where setOpacity or a native material exists', () => { + expect(translucencySupportedOn('darwin')).toBe(true) + expect(translucencySupportedOn('win32')).toBe(true) + }) + + // Electron documents setOpacity as doing nothing on Linux, and there is no + // material either — so the setting has no working half to offer there. + it('is off on Linux, where neither mode does anything', () => { + expect(translucencySupportedOn('linux')).toBe(false) + expect(translucencySupportedOn('freebsd')).toBe(false) + }) + + // Win10 loses glass but keeps clear, so the row must survive there. + it('stays on for a Windows build too old for glass', () => { + const oldWindows = `10.0.${WINDOWS_GLASS_MIN_BUILD - 1}` + + expect(glassSupportedOn('win32', oldWindows)).toBe(false) + expect(translucencySupportedOn('win32')).toBe(true) + }) +}) + +describe('the frost rungs a platform offers', () => { + it('offers the whole ladder on macOS', () => { + expect(glassMaterialsFor(false)).toEqual(GLASS_MATERIALS) + }) + + // The census rule, now enforced on Windows too: no two options in the picker + // may composite to the same thing. Bright and Glare are both mica. + it('never offers two rungs that render the same Windows backdrop', () => { + const backdrops = glassMaterialsFor(true).map(material => backgroundMaterialFor(glass(60, material))) + + expect(new Set(backdrops).size).toBe(backdrops.length) + expect(glassMaterialsFor(true).length).toBeLessThan(GLASS_MATERIALS.length) + }) + + it('keeps every distinct Windows backdrop reachable from the picker', () => { + const backdrops = new Set(glassMaterialsFor(true).map(material => backgroundMaterialFor(glass(60, material)))) + const reachable = new Set(GLASS_MATERIALS.map(material => backgroundMaterialFor(glass(60, material)))) + + expect(backdrops).toEqual(reachable) + }) + + // Settings synced from a Mac carry a rung Windows has no button for. The + // picker highlights the button that renders the same backdrop rather than + // showing nothing selected — and does NOT rewrite what the Mac saved. + it('folds a dropped rung onto the button that looks the same', () => { + expect(glassMaterialForPicker('header', true)).toBe('titlebar') + expect(glassMaterialsFor(true)).toContain(glassMaterialForPicker('header', true)) + expect(backgroundMaterialFor(glass(60, glassMaterialForPicker('header', true)))).toBe( + backgroundMaterialFor(glass(60, 'header')) + ) + }) + + it('leaves every rung alone on macOS and every offered rung alone on Windows', () => { + for (const material of GLASS_MATERIALS) { + expect(glassMaterialForPicker(material, false)).toBe(material) + } + + for (const material of glassMaterialsFor(true)) { + expect(glassMaterialForPicker(material, true)).toBe(material) + } + }) +}) + describe('normalizeState', () => { it('parses a modern payload', () => { - expect(normalizeState({ intensity: 40, mode: 'glass', material: 'header', scope: 'sidebar' }, true)).toEqual({ + expect( + normalizeState({ intensity: 40, fade: 15, mode: 'glass', material: 'header', scope: 'sidebar' }, true) + ).toEqual({ intensity: 40, + fade: 15, mode: 'glass', material: 'header', scope: 'sidebar' @@ -239,19 +428,28 @@ describe('normalizeState', () => { it('keeps a legacy intensity-only payload on clear', () => { expect(normalizeState({ intensity: 70 }, true)).toEqual({ intensity: 70, + fade: 0, mode: 'clear', material: DEFAULT_GLASS_MATERIAL, scope: DEFAULT_GLASS_SCOPE }) }) - it('survives junk payloads', () => { - const base = { intensity: 0, material: DEFAULT_GLASS_MATERIAL, scope: DEFAULT_GLASS_SCOPE } + // Fade arrived after glass shipped, so a profile written by the older build + // has no key for it and must come back unfaded rather than undefined. + it('defaults a payload written before fade existed to no fade', () => { + expect(normalizeState({ intensity: 60, mode: 'glass' }, true).fade).toBe(0) + }) - // A fresh macOS profile lands on glass at zero intensity: selected, but off. + it('survives junk payloads', () => { + const base = { intensity: 0, fade: 0, material: DEFAULT_GLASS_MATERIAL, scope: DEFAULT_GLASS_SCOPE } + + // A fresh glass-capable profile lands on glass at zero intensity: selected, but off. expect(normalizeState(null, true)).toEqual({ ...base, mode: 'glass' }) expect(normalizeState('nope', true)).toEqual({ ...base, mode: 'glass' }) - expect(normalizeState({ intensity: 'x', material: 'nope', mode: 'glass', scope: 'nope' }, false)).toEqual({ + expect( + normalizeState({ intensity: 'x', fade: 'x', material: 'nope', mode: 'glass', scope: 'nope' }, false) + ).toEqual({ ...base, mode: 'clear' }) @@ -266,8 +464,8 @@ describe('glassActive', () => { }) }) -// The default must be selected-but-off: a fresh macOS profile shows Glass in -// the picker while the window itself is untouched until the lever moves. +// The default must be selected-but-off: a fresh glass-capable profile shows +// Glass in the picker while the window itself is untouched until the lever moves. describe('a fresh profile', () => { const fresh = normalizeState(null, true) @@ -326,12 +524,22 @@ describe('what an update actually changes natively', () => { expect(nativeDiff(clear(40), clear(41))).toEqual({ backing: false, material: false, opacity: true }) }) + // The one glass drag that reaches main, and it costs what a clear drag costs. + it('is only the opacity while dragging fade under glass', () => { + expect(nativeDiff(glass(60, DEFAULT_GLASS_MATERIAL, 40), glass(60, DEFAULT_GLASS_MATERIAL, 41))).toEqual({ + backing: false, + material: false, + opacity: true + }) + }) + it('is the material alone when the frost level changes', () => { expect(nativeDiff(glass(60, 'under-window'), glass(60, 'header'))).toEqual({ backing: false, material: true, opacity: false }) + expect(backgroundMaterialFor(glass(60, 'under-window'))).not.toBe(backgroundMaterialFor(glass(60, 'header'))) }) // Crossing zero flips glass on/off, which is exactly when the backing has to @@ -344,4 +552,133 @@ describe('what an update actually changes natively', () => { it('is everything when switching between the two modes', () => { expect(nativeDiff(clear(60), glass(60))).toEqual({ backing: true, material: true, opacity: true }) }) + + it('leaves a window alone when glass is selected but off', () => { + // The light default carries one point of fade. Someone who dragged the + // tint to zero asked for an opaque window, and that point must not follow + // them there — off has to mean exactly 1, not 0.9999. + expect(windowOpacityFor({ ...glass(0), fade: 1 })).toBe(1) + expect(windowOpacityFor({ ...glass(0), fade: 40 })).toBe(1) + }) + + it('still fades a window whose glass is actually on', () => { + expect(windowOpacityFor({ ...glass(66), fade: 40 })).toBeLessThan(1) + }) +}) + +/** + * The shipped defaults, per platform. These are the numbers a fresh profile + * gets before anyone opens Settings, so they are the ones most people will + * ever see — and they differ by platform because the lever means different + * things behind macOS vibrancy and Windows acrylic. + */ +describe('the defaults a fresh profile lands on', () => { + const mac = (appearance: 'dark' | 'light') => defaultTranslucencyValues(appearance, false) + const win = (appearance: 'dark' | 'light') => defaultTranslucencyValues(appearance, true) + + it('ships glass on, not a lever resting at zero', () => { + for (const values of [mac('light'), mac('dark'), win('light'), win('dark')]) { + expect(values.intensity).toBeGreaterThan(0) + expect(glassActive({ ...values, mode: 'glass' })).toBe(true) + } + + for (const appearance of ['light', 'dark'] as const) { + expect(defaultTranslucencyState(appearance, true, false).mode).toBe('glass') + expect(defaultTranslucencyState(appearance, true, true).mode).toBe('glass') + } + }) + + it('falls back to clear where no native material exists', () => { + expect(defaultTranslucencyState('dark', false, false).mode).toBe('clear') + }) + + it('tints light more heavily than dark, on both platforms', () => { + // A dark field already separates from what is behind it; a bright one + // needs real thinning before the desktop reads as a layer underneath. + expect(mac('light').intensity).toBeGreaterThan(mac('dark').intensity) + expect(win('light').intensity).toBeGreaterThan(win('dark').intensity) + }) + + it('asks far less of Windows, which composites its own tint in DWM', () => { + expect(win('light').intensity).toBeLessThan(mac('light').intensity) + expect(win('dark').intensity).toBeLessThan(mac('dark').intensity) + }) + + it('never fades a Windows window — setOpacity dims the composited backdrop', () => { + expect(win('light').fade).toBe(0) + expect(win('dark').fade).toBe(0) + }) + + it('defaults each platform onto a frost that platform can actually render', () => { + for (const appearance of ['light', 'dark'] as const) { + expect(glassMaterialsFor(true)).toContain(win(appearance).material) + expect(glassMaterialsFor(false)).toContain(mac(appearance).material) + } + }) + + it('opens the whole window, not just the sidebar rail', () => { + for (const values of [mac('light'), mac('dark'), win('light'), win('dark')]) { + expect(values.scope).toBe('window') + } + }) +}) + +/** + * The per-appearance ladder: appearance slot → base → platform default, per + * key. This is what makes tuning light mode stay in light mode while an + * untouched dark keeps inheriting. + */ +describe('resolving the book for the painted appearance', () => { + const empty = normalizeBook(null, true) + + it('falls all the way through to the platform default', () => { + expect(resolveTranslucency(empty, 'dark', false).intensity).toBe(defaultTranslucencyValues('dark', false).intensity) + expect(resolveTranslucency(empty, 'dark', true).intensity).toBe(defaultTranslucencyValues('dark', true).intensity) + }) + + it('scopes an edit to the appearance it was made in', () => { + const book = setTranslucencyValues(empty, 'light', { intensity: 90 }) + + expect(resolveTranslucency(book, 'light', false).intensity).toBe(90) + expect(resolveTranslucency(book, 'dark', false).intensity).toBe(defaultTranslucencyValues('dark', false).intensity) + }) + + it('carries a v1 state into BOTH appearances via base', () => { + // Someone who tuned a window before appearances were split keeps exactly + // what was on screen, in either appearance, until they edit one of them. + const migrated = normalizeBook({ intensity: 40, mode: 'glass' }, true) + + expect(migrated.base.intensity).toBe(40) + expect(resolveTranslucency(migrated, 'light', false).intensity).toBe(40) + expect(resolveTranslucency(migrated, 'dark', false).intensity).toBe(40) + }) + + it('lets an appearance override base without disturbing the other', () => { + const tuned = setTranslucencyValues(normalizeBook({ intensity: 40, mode: 'glass' }, true), 'dark', { + intensity: 10 + }) + + expect(resolveTranslucency(tuned, 'dark', false).intensity).toBe(10) + expect(resolveTranslucency(tuned, 'light', false).intensity).toBe(40) + }) + + it('inherits per KEY, not per appearance', () => { + // Editing only the tint in dark must leave dark's material still tracking + // base — a partial edit is not a full snapshot of the appearance. + const book = setTranslucencyValues(normalizeBook({ material: 'popover', mode: 'glass' }, true), 'dark', { + intensity: 33 + }) + + const resolved = resolveTranslucency(book, 'dark', false) + + expect(resolved.intensity).toBe(33) + expect(resolved.material).toBe('popover') + }) + + it('keeps mode global — clear vs glass is about the window, not the palette', () => { + const book = setTranslucencyValues({ ...empty, mode: 'clear' }, 'light', { intensity: 50 }) + + expect(resolveTranslucency(book, 'light', false).mode).toBe('clear') + expect(resolveTranslucency(book, 'dark', false).mode).toBe('clear') + }) }) diff --git a/apps/desktop/electron/translucency.ts b/apps/desktop/electron/translucency.ts index cb9b2f28bf..eecaf57f1b 100644 --- a/apps/desktop/electron/translucency.ts +++ b/apps/desktop/electron/translucency.ts @@ -14,25 +14,39 @@ import { glassActive, type TranslucencyState } from '../../shared/src/translucency' export { + backgroundMaterialFor, clampIntensity, DEFAULT_GLASS_MATERIAL, DEFAULT_GLASS_SCOPE, + defaultTranslucencyState, + defaultTranslucencyValues, GLASS_MATERIALS, GLASS_SCOPES, glassActive, type GlassMaterial, + glassMaterialForPicker, + glassMaterialsFor, + glassSupportedOn, glassSurfaceKeep, + hudFrostFor, + normalizeBook, normalizeMaterial, normalizeMode, normalizeScope, normalizeState, + resolveTranslucency, + setTranslucencyValues, TRANSLUCENCY_CURVE, TRANSLUCENCY_MAX, TRANSLUCENCY_MIN, TRANSLUCENCY_OPACITY_FLOOR, type TranslucencyState, + translucencySupportedOn, vibrancyFor, - windowOpacityFor + windowOpacityFor, + WINDOWS_BACKGROUND_MATERIALS, + WINDOWS_GLASS_MIN_BUILD, + type WindowsBackgroundMaterial } from '../../shared/src/translucency' /** diff --git a/apps/desktop/electron/windows-hermes-path.test.ts b/apps/desktop/electron/windows-hermes-path.test.ts index 778042b278..0c49b4a2ac 100644 --- a/apps/desktop/electron/windows-hermes-path.test.ts +++ b/apps/desktop/electron/windows-hermes-path.test.ts @@ -5,9 +5,9 @@ // 1. buildPathExtCandidates() — PATHEXT extensions must be tried BEFORE the // empty extension, or an extensionless Git-Bash `hermes` shim shadows // the real hermes.cmd/hermes.exe. -// 2. chooseUpdaterArgs() — must gate on haveRealInstall (any real-install -// signal), not just the hermes.exe console-script shim, or healthy -// installs get forced into a destructive --repair. +// 2. chooseUpdaterArgs() — must distinguish a runnable updater from stale +// install provenance. The bootstrap marker can outlive the venv, and a +// partial venv cannot run the updater; those states require --repair. // 3. resolveVenvHermesCommand() — must probe the venv python via // canImportHermesCli() before trusting it, or a broken venv gets // re-selected forever instead of falling through to bootstrap. @@ -45,17 +45,43 @@ test('buildPathExtCandidates: non-Windows only tries the bare name', () => { assert.deepEqual(buildPathExtCandidates(undefined, false), ['']) }) -test('chooseUpdaterArgs: gentle --update when a real-install signal is present', () => { - assert.deepEqual(chooseUpdaterArgs(true, 'main'), ['--update', '--branch', 'main']) +test('chooseUpdaterArgs: gentle --update when both updater runtime files exist', () => { + assert.deepEqual(chooseUpdaterArgs({ hasBootstrapMarker: true, hasVenvHermes: true, hasVenvPython: true }, 'main'), [ + '--update', + '--branch', + 'main' + ]) }) -test('chooseUpdaterArgs: destructive --repair only when NO real-install signal is present', () => { - assert.deepEqual(chooseUpdaterArgs(false, 'main'), ['--repair', '--branch', 'main']) +test('chooseUpdaterArgs: marker-only install uses --repair when the venv is gone', () => { + assert.deepEqual( + chooseUpdaterArgs({ hasBootstrapMarker: true, hasVenvHermes: false, hasVenvPython: false }, 'main'), + ['--repair', '--branch', 'main'] + ) }) -test('chooseUpdaterArgs: passes the branch through unchanged in both cases', () => { - assert.deepEqual(chooseUpdaterArgs(true, 'release/1.2'), ['--update', '--branch', 'release/1.2']) - assert.deepEqual(chooseUpdaterArgs(false, 'release/1.2'), ['--repair', '--branch', 'release/1.2']) +test('chooseUpdaterArgs: partial updater runtimes use --repair', () => { + assert.deepEqual(chooseUpdaterArgs({ hasBootstrapMarker: true, hasVenvHermes: false, hasVenvPython: true }, 'main'), [ + '--repair', + '--branch', + 'main' + ]) + assert.deepEqual(chooseUpdaterArgs({ hasBootstrapMarker: true, hasVenvHermes: true, hasVenvPython: false }, 'main'), [ + '--repair', + '--branch', + 'main' + ]) +}) + +test('chooseUpdaterArgs: passes the branch through unchanged in both modes', () => { + assert.deepEqual( + chooseUpdaterArgs({ hasBootstrapMarker: false, hasVenvHermes: true, hasVenvPython: true }, 'release/1.2'), + ['--update', '--branch', 'release/1.2'] + ) + assert.deepEqual( + chooseUpdaterArgs({ hasBootstrapMarker: false, hasVenvHermes: false, hasVenvPython: false }, 'release/1.2'), + ['--repair', '--branch', 'release/1.2'] + ) }) function makeDeps(overrides: Partial[2]> = {}) { diff --git a/apps/desktop/electron/windows-hermes-path.ts b/apps/desktop/electron/windows-hermes-path.ts index 6f3542853e..f94d4b2c39 100644 --- a/apps/desktop/electron/windows-hermes-path.ts +++ b/apps/desktop/electron/windows-hermes-path.ts @@ -11,12 +11,11 @@ * hermes.cmd/hermes.exe; the shim then failed the --version probe and * the desktop fell through to a spurious bootstrap/repair. The fix: * PATHEXT extensions first, empty extension LAST. - * 2. chooseUpdaterArgs() — handOffWindowsBootstrapRecovery() chose - * --update vs the destructive --repair by checking ONLY - * venv\Scripts\hermes.exe (the console-script shim, written at the END - * of venv setup and absent in interrupted states), so it escalated to a - * full venv recreate even on healthy installs. The fix: gate on ANY - * real-install signal, not just the shim. + * 2. chooseUpdaterArgs() — handOffWindowsBootstrapRecovery() must separate + * install provenance from updater viability. A bootstrap-complete marker + * can outlive a deleted venv, while the updater needs BOTH the venv Python + * and Hermes launcher. Marker-only or partial runtimes must use --repair; + * only a runnable pair can use --update. * 3. resolveVenvHermesCommand() — unwrapWindowsVenvHermesCommand() returned * the venv python with NO runtime probe (bypassing the caller's * --version check too), so a venv broken mid-update (e.g. missing @@ -61,23 +60,26 @@ export function buildPathExtCandidates(pathext: string | undefined, isWindows: b } /** - * Choose the Windows bootstrap-recovery updater invocation: the gentle - * in-place --update when ANY real-install signal is present, the - * destructive --repair (full venv recreate) otherwise. + * Choose the Windows bootstrap-recovery invocation. The gentle in-place + * updater can only start when both pieces of its runtime contract exist: the + * venv Python interpreter and the Hermes launcher that drives `hermes update`. + * A bootstrap-complete marker proves install provenance, not current runtime + * usability, and may remain after the venv is removed or quarantined. * - * haveRealInstall must be computed by the caller from ALL real-install - * signals (venv python interpreter, venv hermes shim, bootstrap-complete - * marker) — gating on just the hermes.exe console-script shim alone is the - * regression this function's callers must avoid: that shim is written at - * the END of venv setup and is absent in exactly the interrupted/quarantined - * states this recovery exists to heal. - * - * @param {boolean} haveRealInstall + * @param {BootstrapRecoverySignals} signals * @param {string} branch * @returns {string[]} updater argv, e.g. ['--update', '--branch', 'main']. */ -export function chooseUpdaterArgs(haveRealInstall: boolean, branch: string): string[] { - return haveRealInstall ? ['--update', '--branch', branch] : ['--repair', '--branch', branch] +export interface BootstrapRecoverySignals { + hasBootstrapMarker: boolean + hasVenvHermes: boolean + hasVenvPython: boolean +} + +export function chooseUpdaterArgs(signals: BootstrapRecoverySignals, branch: string): string[] { + const canRunUpdater = signals.hasVenvHermes && signals.hasVenvPython + + return canRunUpdater ? ['--update', '--branch', branch] : ['--repair', '--branch', branch] } /** diff --git a/apps/desktop/electron/windows-remote-lifecycle.ts b/apps/desktop/electron/windows-remote-lifecycle.ts index 5d96853a1a..40d89b3ece 100644 --- a/apps/desktop/electron/windows-remote-lifecycle.ts +++ b/apps/desktop/electron/windows-remote-lifecycle.ts @@ -1,6 +1,6 @@ import crypto from 'node:crypto' -import { redactSecrets, SSH_ERROR } from './ssh-connection' +import { assertBootstrapNotSuperseded, redactSecrets, SSH_ERROR } from './ssh-connection' const LOCKFILE_SCHEMA_VERSION = 2 const PROTOCOL_VERSION = 1 @@ -162,14 +162,6 @@ function reusableWindowsLock(lock, state, profile, reuseToken, runtime) { ) } -function assertCurrent(signal) { - if (signal?.aborted) { - const error: any = new Error('SSH bootstrap was cancelled.') - error.kind = 'superseded' - throw error - } -} - async function processState(ssh, runtime, lock) { return helper(ssh, runtime, 'process-state', [ String(lock.pid), @@ -215,7 +207,7 @@ async function waitReady(ssh, runtime, ownershipId, lock, timeoutMs, signal) { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { - assertCurrent(signal) + assertBootstrapNotSuperseded(signal) let state try { @@ -286,7 +278,7 @@ async function connectWindowsRemote(deps) { readyTimeoutMs = 45_000 } = deps - assertCurrent(signal) + assertBootstrapNotSuperseded(signal) const runtime = await probeWindowsRemote(ssh, remoteHermesPath) const inspection = await helper(ssh, runtime, 'inspect', [runtime.hermesPath]) @@ -356,7 +348,7 @@ async function connectWindowsRemote(deps) { await helper(ssh, runtime, 'remove-lock', [ownershipId]) } - assertCurrent(signal) + assertBootstrapNotSuperseded(signal) const token = crypto.randomBytes(32).toString('hex') const spawnNonce = crypto.randomBytes(8).toString('hex') await helper(ssh, runtime, 'upload-token', [ownershipId, spawnNonce], token) @@ -405,7 +397,7 @@ async function connectWindowsRemote(deps) { await forward(localPort, remotePort) const baseUrl = `http://127.0.0.1:${localPort}` await waitForHermes(baseUrl, token) - assertCurrent(signal) + assertBootstrapNotSuperseded(signal) await helper(ssh, runtime, 'write-lock', [ownershipId], JSON.stringify({ ...owned, port: remotePort })) return { diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 4d152c7a52..b3f55444c0 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -113,13 +113,14 @@ "@xterm/addon-web-links": "0.12.0", "@xterm/addon-webgl": "0.19.0", "@xterm/xterm": "6.0.0", - "blobatar": "0.2.0", + "blobatar": "2.0.0", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "cmdk": "1.1.1", "d3-force": "3.0.0", "dnd-core": "14.0.1", "dompurify": "3.4.13", + "driver.js": "1.8.0", "emojibase-data": "16.0.3", "fflate": "0.8.3", "frimousse": "0.3.0", @@ -129,7 +130,7 @@ "katex": "0.16.47", "mermaid": "11.16.1", "motion": "12.42.2", - "nanostores": "1.4.0", + "nanostores": "1.4.2", "node-pty": "1.1.0", "radix-ui": "1.6.7", "react": "19.2.7", diff --git a/apps/desktop/scripts/stage-native-deps.mjs b/apps/desktop/scripts/stage-native-deps.mjs index c55a8ed3ab..385d031725 100644 --- a/apps/desktop/scripts/stage-native-deps.mjs +++ b/apps/desktop/scripts/stage-native-deps.mjs @@ -437,7 +437,7 @@ const GET_WINDOWS_VERSION = '9.3.0' export function stageGetWindowsInto( srcRoot, destRoot, - { platform = process.platform, arch = process.arch, rebuild } = {} + { platform = process.platform, arch = process.arch, install } = {} ) { // The STAGED_WINDOWS_JS rewrite mirrors this exact version's export surface. // A version bump must fail the build here until the rewrite is re-verified — @@ -495,6 +495,7 @@ export function stageGetWindowsInto( ) : [] let bindingDirs = scanBindingDirs() + let installAttempted = false if (bindingDirs.length === 0 && arch === 'arm64') { // get-windows 9.3.0 publishes win32 prebuilds for ia32/x64 only. // The staged windows.js deliberately fails soft when binding/ is absent, @@ -503,24 +504,25 @@ export function stageGetWindowsInto( '[stage-native-deps] get-windows has no win32-arm64 prebuilt binding; ' + 'staging the fail-soft JS surface without native window enumeration.' ) - } else if (bindingDirs.length === 0 && typeof rebuild === 'function') { + } else if (bindingDirs.length === 0 && typeof install === 'function') { // A plain `npm install` won't re-run an install script for a package // that is already on disk, so every checkout that installed while // get-windows was missing from allowScripts stays bricked even after - // the allowlist is fixed. `npm rebuild` re-runs it. + // the allowlist is fixed. Invoke node-pre-gyp directly: npm treats this + // optional dependency's failed lifecycle as non-fatal and can report a + // successful rebuild without producing the Windows binding. console.log( - '[stage-native-deps] get-windows has no win32 binding; running `npm rebuild get-windows`...' + '[stage-native-deps] get-windows has no win32 binding; running its native installer...' ) - rebuild() + installAttempted = true + install() bindingDirs = scanBindingDirs() } if (bindingDirs.length === 0 && arch !== 'arm64') { - throw new Error( - `[stage-native-deps] get-windows has no win32-${arch} prebuilt binding under lib/binding. ` + - 'Recover from the checkout root with:\n' + - ' npm install-scripts approve get-windows\n' + - ' npm rebuild get-windows' - ) + const reason = installAttempted + ? `native installer completed without producing a win32-${arch} binding under lib/binding` + : `has no win32-${arch} prebuilt binding under lib/binding` + throw new Error(`[stage-native-deps] get-windows ${reason}`) } for (const dir of bindingDirs) { const dest = join(destRoot, 'lib', 'binding', dir) @@ -542,15 +544,35 @@ export function stageGetWindowsInto( return destRoot } -function rebuildGetWindowsViaNpm() { - const result = spawnSync('npm', ['rebuild', 'get-windows'], { - cwd: resolve(projectRoot, '..', '..'), - stdio: 'inherit', - // npm resolves to npm.cmd on Windows, which needs a shell. - shell: process.platform === 'win32' +export function installGetWindowsNativeBinding( + srcRoot, + { resolveInstaller, spawn = spawnSync } = {} +) { + let installerPath + try { + const resolveNodePreGyp = + resolveInstaller ?? + (() => + require.resolve('@mapbox/node-pre-gyp/bin/node-pre-gyp', { + paths: [srcRoot] + })) + installerPath = resolveNodePreGyp() + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + throw new Error(`[stage-native-deps] cannot resolve get-windows native installer: ${detail}`) + } + + const result = spawn(process.execPath, [installerPath, 'install', '--fallback-to-build'], { + cwd: srcRoot, + stdio: 'inherit' }) + if (result.error) { + throw new Error( + `[stage-native-deps] get-windows native installer could not start: ${result.error.message}` + ) + } if (result.status !== 0) { - console.warn(`[stage-native-deps] npm rebuild get-windows exited with ${result.status}`) + throw new Error(`[stage-native-deps] get-windows native installer exited with ${result.status}`) } } @@ -585,10 +607,12 @@ export function stageGetWindows( } // Only a win32 host can produce the win32 binding, so a cross-platform pack - // has nothing to gain from the rebuild. - const rebuild = - platform === 'win32' && process.platform === 'win32' ? rebuildGetWindowsViaNpm : undefined - return stageGetWindowsInto(srcRoot, destRoot, { platform, arch, rebuild }) + // has nothing to gain from the native installer. + const install = + platform === 'win32' && process.platform === 'win32' + ? () => installGetWindowsNativeBinding(srcRoot) + : undefined + return stageGetWindowsInto(srcRoot, destRoot, { platform, arch, install }) } // Allow direct CLI invocation: node scripts/stage-native-deps.mjs [platform] [arch] diff --git a/apps/desktop/scripts/stage-native-deps.test.mjs b/apps/desktop/scripts/stage-native-deps.test.mjs index 6392b52a80..9d20a039fb 100644 --- a/apps/desktop/scripts/stage-native-deps.test.mjs +++ b/apps/desktop/scripts/stage-native-deps.test.mjs @@ -6,6 +6,7 @@ import { pathToFileURL } from 'node:url' import { test } from 'vitest' import { + installGetWindowsNativeBinding, stageGetWindows, stageGetWindowsInto, stageNodePtyInto, @@ -460,7 +461,7 @@ test('win32-arm64 staging omits incompatible bindings and keeps the fail-soft JS } }) -test('win32 staging self-heals through the rebuild hook when the binding is missing', () => { +test('win32 staging self-heals through the native installer when the binding is missing', () => { const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) try { const srcRoot = join(tmp, 'get-windows') @@ -471,7 +472,7 @@ test('win32 staging self-heals through the rebuild hook when the binding is miss makeFakeGetWindows(srcRoot, { bindings: [] }) let calls = 0 - const rebuild = () => { + const install = () => { calls += 1 makeFakeNode( join(srcRoot, 'lib', 'binding', 'napi-9-win32-unknown-x64', 'node-get-windows.node'), @@ -479,7 +480,7 @@ test('win32 staging self-heals through the rebuild hook when the binding is miss ) } - stageGetWindowsInto(srcRoot, destRoot, { platform: 'win32', arch: 'x64', rebuild }) + stageGetWindowsInto(srcRoot, destRoot, { platform: 'win32', arch: 'x64', install }) assert.equal(calls, 1) assert.ok( @@ -490,7 +491,7 @@ test('win32 staging self-heals through the rebuild hook when the binding is miss } }) -test('win32 staging reports the recovery steps when the rebuild hook produces nothing', () => { +test('win32 staging rejects a successful installer that produces no binding', () => { const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) try { const srcRoot = join(tmp, 'get-windows') @@ -503,15 +504,69 @@ test('win32 staging reports the recovery steps when the rebuild hook produces no stageGetWindowsInto(srcRoot, destRoot, { platform: 'win32', arch: 'x64', - rebuild: () => {} + install: () => {} }), - /npm rebuild get-windows/ + (error) => { + assert.match(error.message, /installer completed without producing a win32-x64 binding/) + assert.doesNotMatch(error.message, /npm rebuild/) + return true + } ) } finally { fs.rmSync(tmp, { recursive: true, force: true }) } }) +test('get-windows native install invokes node-pre-gyp directly from the package root', () => { + const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) + try { + const srcRoot = join(tmp, 'get-windows') + const installer = join( + srcRoot, + 'node_modules', + '@mapbox', + 'node-pre-gyp', + 'bin', + 'node-pre-gyp' + ) + fs.mkdirSync(path.dirname(installer), { recursive: true }) + fs.writeFileSync( + join(srcRoot, 'node_modules', '@mapbox', 'node-pre-gyp', 'package.json'), + JSON.stringify({ name: '@mapbox/node-pre-gyp', version: '1.0.11' }) + ) + fs.writeFileSync(installer, '') + + const calls = [] + installGetWindowsNativeBinding(srcRoot, { + spawn: (command, args, options) => { + calls.push({ command, args, options }) + return { status: 0 } + } + }) + + assert.deepEqual(calls, [ + { + command: process.execPath, + args: [fs.realpathSync(installer), 'install', '--fallback-to-build'], + options: { cwd: srcRoot, stdio: 'inherit' } + } + ]) + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('get-windows native install surfaces node-pre-gyp failure', () => { + assert.throws( + () => + installGetWindowsNativeBinding('C:\\fake\\get-windows', { + resolveInstaller: () => 'C:\\fake\\node-pre-gyp', + spawn: () => ({ status: 1 }) + }), + /native installer exited with 1/ + ) +}) + test('staging refuses a get-windows version the lib/windows.js rewrite was not verified against', () => { const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) try { diff --git a/apps/desktop/src/api/client.ts b/apps/desktop/src/api/client.ts new file mode 100644 index 0000000000..e996ab0109 --- /dev/null +++ b/apps/desktop/src/api/client.ts @@ -0,0 +1,154 @@ +import { JsonRpcGatewayClient } from '@hermes/shared' + +import type { HermesApiRequest } from '@/global' + +// Desktop startup fires a burst of read-only data calls (config, profiles, +// model info/options, cron) the moment the backend passes readiness. On a +// profile-heavy or remote install these can each take tens of seconds — e.g. +// /api/profiles runs list_profiles(), which does a recursive skill-tree walk +// per profile — so the 15s default (DEFAULT_FETCH_TIMEOUT_MS in hardening.ts) +// times out a backend that is alive-but-busy, surfacing as a spurious +// "Timed out connecting to Hermes backend" that hangs the UI (#48504). +// +// Give the boot burst a generous per-call timeout instead of raising the +// global default: interactive/runtime calls and the liveness poll (/api/status) +// keep the short default so a genuinely-dead backend is still detected fast. +export const STARTUP_REQUEST_TIMEOUT_MS = 60_000 +const DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS = 30_000 +// prompt.submit is effectively fire-and-forget: turn completion is signaled by +// stream / message.complete events, NOT by the RPC return. A long turn (MoA +// presets running references + aggregator in series, deep reasoning, large tool +// chains) can legitimately take minutes to ACK, so bounding the ack by the +// generic 30s default surfaces a false "request timed out" toast while the turn +// is still running and will succeed (issue #55024). Match the backend's +// agent-turn ceiling (agent.gateway_timeout = 1800s) so the ack timeout only +// ever fires when the turn itself would have been abandoned server-side. +export const PROMPT_SUBMIT_REQUEST_TIMEOUT_MS = 1_800_000 + +export class HermesGateway extends JsonRpcGatewayClient { + constructor() { + super({ + closedErrorMessage: 'Hermes gateway connection closed', + connectErrorMessage: 'Could not connect to Hermes gateway', + createRequestId: nextId => nextId, + notConnectedErrorMessage: 'Hermes gateway is not connected', + requestTimeoutMs: DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS + }) + } +} + +// Profile that profile-scoped REST settings (config/env/skills/tools/model/…) +// should target. Mirrors $activeGatewayProfile, pushed in from the store via +// setApiRequestProfile so this module needs no store import (avoids a cycle). +// Electron main consumes request.profile as request scope. Local calls whose +// REST handlers accept profile reuse the primary dashboard via ?profile=; +// unscoped handlers retain a profile backend. Remote overrides still route to +// their owning backend. Null → primary, so single-profile users are unaffected. +let _apiProfile: null | string = null + +export function setApiRequestProfile(profile: null | string): void { + _apiProfile = profile || null +} + +export function profileScoped(profile?: null | string): { profile?: string } { + const selected = profile === undefined ? _apiProfile : profile + + return selected ? { profile: selected } : {} +} + +/** Profile that profile-scoped REST/WS calls should target (null → primary). + * Read-only twin of setApiRequestProfile for modules (e.g. voice playback) + * that build their own connection URLs and must stay on the same backend. */ +export function getApiRequestProfile(): null | string { + return _apiProfile +} + +// Registry connection serving the active gateway (null → the local pool). +// Pushed from store/gateway's setActive — the single seam BOTH +// ensureGatewayProfile and ensureGatewayAgent funnel through — so WS calls +// that dial their own backend (pluginSocket) resolve it through the SAME +// source of truth those paths maintain for $connection. That makes the plugin +// socket follow registry-agent activations too, not just profile switches. +// Same no-store-import contract as _apiProfile (avoids a cycle). +let _apiConnectionId: null | string = null + +export function setApiRequestConnection(connectionId: null | string): void { + _apiConnectionId = connectionId || null +} + +// Registry connection scope for a REST request. A registered remote gateway +// owns its own state.db — cron jobs and their run sessions live THERE — so +// requests for gateway-owned data must carry the connection id for the main +// process to route them to that host (hermes:api's registry branch). Null +// resolves to no tag, keeping single-source users byte-identical; explicit +// 'local' must remain tagged when the legacy primary points elsewhere. +export function connectionScoped(): { connectionId?: string } { + return _apiConnectionId ? { connectionId: _apiConnectionId } : {} +} + +/** Send a REST request to the renderer's active registry source. Request-level + * routing may override the active source for an explicitly-owned resource. + * + * Helpers under `api/` go through here rather than calling the preload bridge + * directly, so the connection tag cannot be forgotten on a new one — with one + * exception. A capabilityScoped() helper must NOT: that scope says "the local + * pool" by omitting `connectionId` entirely, and an absent key cannot override + * the ambient tag spread underneath it, so a 'local' pin would silently route + * to whatever remote gateway happened to be active. Those helpers call the + * bridge directly and own their routing end to end. */ +export function hermesApi(request: HermesApiRequest): Promise { + return window.hermesDesktop.api({ ...connectionScoped(), ...request }) +} + +// ── Capability scope: (connection, profile) routing for the Capabilities +// surface (skills / toolsets / MCP / hub / env / toolset config) ──────────── +// +// A profile is not a machine-global name — it belongs to ONE gateway. The +// Capabilities surface can be pointed at any (connection, profile) pair +// (SkillsView's scope selector, Bot Mode's fixedProfile/fixedConnection), so +// its REST helpers accept either the legacy string form or an explicit scope +// object: +// +// - `undefined` / string → the legacy profile path, PLUS the active registry +// connection tag (connectionScoped, same contract the cron helpers adopted +// in #87882). Without the tag, a window activated onto a registered remote +// gateway read the LOCAL pool's skills/tools/MCP — the wrong machine. +// - `{ connectionId, profile }` → explicit pin. `''`/`'local'` connection +// ids mean the local pool and deliberately DROP the ambient connection +// tag, so a local-profile pick made while a remote gateway is active still +// routes to the local machine. +export type ProfileScope = null | string | { connectionId?: null | string; profile?: null | string } + +export function capabilityScoped(scope?: ProfileScope): { connectionId?: string; profile?: string } { + if (scope && typeof scope === 'object') { + const profile = (scope.profile ?? '').trim() + const connectionId = (scope.connectionId ?? '').trim() + + return { + ...(profile ? { profile } : {}), + ...(connectionId && connectionId !== 'local' ? { connectionId } : {}) + } + } + + return { ...profileScoped(scope), ...connectionScoped() } +} + +/** Stable cache-key for a capability scope: `profile` for the local/legacy + * path, `connectionId::profile` for an explicit remote pin. Mirrors + * normalizeProfileKey for plain strings so existing keys stay byte-identical. */ +export function profileScopeKey(scope?: ProfileScope): string { + if (scope && typeof scope === 'object') { + const profile = (scope.profile ?? '').trim() || 'default' + const connectionId = (scope.connectionId ?? '').trim() + + return connectionId && connectionId !== 'local' ? `${connectionId}::${profile}` : profile + } + + return (scope ?? '').trim() || 'default' +} + +/** Registry connection id that connection-scoped WS calls should target + * (null → the local pool). Read-only twin of setApiRequestConnection. */ +export function getApiRequestConnection(): null | string { + return _apiConnectionId +} diff --git a/apps/desktop/src/api/config.ts b/apps/desktop/src/api/config.ts new file mode 100644 index 0000000000..38fd4945d6 --- /dev/null +++ b/apps/desktop/src/api/config.ts @@ -0,0 +1,239 @@ +import type { + ConfigSchemaResponse, + CustomEndpointsResponse, + CustomEndpointUpdate, + CustomEndpointValidationResponse, + EnvVarInfo, + HermesConfig, + HermesConfigRecord, + LogsResponse, + OAuthPollResponse, + OAuthProvidersResponse, + OAuthStartResponse, + OAuthSubmitResponse, + StatusResponse +} from '@/types/hermes' + +import { capabilityScoped, hermesApi, type ProfileScope, profileScoped, STARTUP_REQUEST_TIMEOUT_MS } from './client' + +export function getStatus(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/status' + }) +} + +export function getLogs(params: { + component?: string + file?: string + level?: string + lines?: number + search?: string +}): Promise { + const query = new URLSearchParams() + + if (params.file) { + query.set('file', params.file) + } + + if (typeof params.lines === 'number') { + query.set('lines', String(params.lines)) + } + + if (params.level && params.level !== 'ALL') { + query.set('level', params.level) + } + + if (params.component && params.component !== 'all') { + query.set('component', params.component) + } + + if (params.search) { + query.set('search', params.search) + } + + const suffix = query.toString() + + return hermesApi({ + ...profileScoped(), + path: suffix ? `/api/logs?${suffix}` : '/api/logs' + }) +} + +export function getHermesConfig(profile?: string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/config', + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS + }) +} + +export function getHermesConfigRecord(profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: '/api/config' + }) +} + +export function getHermesConfigDefaults(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/config/defaults', + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS + }) +} + +export function getHermesConfigSchema(profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/config/schema' + }) +} + +export function saveHermesConfig(config: HermesConfigRecord, profile?: null | string): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...profileScoped(profile), + path: '/api/config', + method: 'PUT', + body: { config } + }) +} + +export function getEnvVars(profile?: null | string): Promise> { + return hermesApi>({ + ...profileScoped(profile), + path: '/api/env' + }) +} + +export function setEnvVar(key: string, value: string, profile?: ProfileScope): Promise<{ ok: boolean }> { + return window.hermesDesktop.api<{ ok: boolean }>({ + ...capabilityScoped(profile), + path: '/api/env', + method: 'PUT', + body: { key, value } + }) +} + +export function deleteEnvVar(key: string, profile?: ProfileScope): Promise<{ ok: boolean }> { + return window.hermesDesktop.api<{ ok: boolean }>({ + ...capabilityScoped(profile), + path: '/api/env', + method: 'DELETE', + body: { key } + }) +} + +export function revealEnvVar(key: string, profile?: ProfileScope): Promise<{ key: string; value: string }> { + return window.hermesDesktop.api<{ key: string; value: string }>({ + ...capabilityScoped(profile), + path: '/api/env/reveal', + method: 'POST', + body: { key } + }) +} + +export function validateProviderCredential( + key: string, + value: string, + apiKey?: string +): Promise<{ ok: boolean; reachable: boolean; message: string; models?: string[] }> { + return hermesApi<{ ok: boolean; reachable: boolean; message: string; models?: string[] }>({ + ...profileScoped(), + path: '/api/providers/validate', + method: 'POST', + body: { key, value, api_key: apiKey ?? '' } + }) +} + +export function getCustomEndpoints(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/providers/custom-endpoints' + }) +} + +export function saveCustomEndpoint(endpoint: CustomEndpointUpdate): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/providers/custom-endpoints', + method: 'POST', + body: endpoint + }) +} + +export function validateCustomEndpoint(endpoint: CustomEndpointUpdate): Promise { + return hermesApi({ + path: '/api/providers/custom-endpoints/validate', + method: 'POST', + body: endpoint + }) +} + +export function activateCustomEndpoint(id: string): Promise<{ ok: boolean; provider: string; model: string }> { + return hermesApi<{ ok: boolean; provider: string; model: string }>({ + ...profileScoped(), + path: `/api/providers/custom-endpoints/${encodeURIComponent(id)}/activate`, + method: 'POST' + }) +} + +export function deleteCustomEndpoint(id: string): Promise { + return hermesApi({ + ...profileScoped(), + path: `/api/providers/custom-endpoints/${encodeURIComponent(id)}`, + method: 'DELETE' + }) +} + +export function listOAuthProviders(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/providers/oauth' + }) +} + +export function disconnectOAuthProvider(providerId: string): Promise<{ ok: boolean; provider: string }> { + return hermesApi<{ ok: boolean; provider: string }>({ + ...profileScoped(), + path: `/api/providers/oauth/${encodeURIComponent(providerId)}`, + method: 'DELETE' + }) +} + +export function startOAuthLogin(providerId: string, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/providers/oauth/${encodeURIComponent(providerId)}/start`, + method: 'POST', + body: {} + }) +} + +export function submitOAuthCode(providerId: string, sessionId: string, code: string): Promise { + return hermesApi({ + ...profileScoped(), + path: `/api/providers/oauth/${encodeURIComponent(providerId)}/submit`, + method: 'POST', + body: { session_id: sessionId, code } + }) +} + +export function pollOAuthSession( + providerId: string, + sessionId: string, + profile?: ProfileScope +): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/providers/oauth/${encodeURIComponent(providerId)}/poll/${encodeURIComponent(sessionId)}` + }) +} + +export function cancelOAuthSession(sessionId: string): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...profileScoped(), + path: `/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`, + method: 'DELETE' + }) +} diff --git a/apps/desktop/src/api/cron.ts b/apps/desktop/src/api/cron.ts new file mode 100644 index 0000000000..b790b033d7 --- /dev/null +++ b/apps/desktop/src/api/cron.ts @@ -0,0 +1,153 @@ +import type { + AutomationBlueprint, + CronDeliveryTarget, + CronJob, + CronJobCreatePayload, + CronJobUpdates, + SessionInfo +} from '@/types/hermes' + +import { connectionScoped, hermesApi, profileScoped, STARTUP_REQUEST_TIMEOUT_MS } from './client' + +// The cron trigger endpoint intentionally waits for the whole job so its +// response reflects the persisted execution result. Agent jobs can run far +// longer than the Electron fetch default; keep this override local to the one +// synchronous long-operation endpoint rather than weakening all API timeouts. +const CRON_TRIGGER_REQUEST_TIMEOUT_MS = 24 * 60 * 60 * 1000 + +// Cron jobs are stored per-profile (/cron/jobs.json), and the +// backend's list endpoint defaults to 'all'. Pass a concrete profile key to +// list just that profile's jobs, or 'all' for the unified cross-profile view. +// Omitting the arg keeps the legacy 'all' default for non-profile callers. +// profileScoped() still rides along for backend-process routing. +export function getCronJobs(profile?: string): Promise { + const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : '' + + return hermesApi({ + ...profileScoped(), + ...connectionScoped(), + path: `/api/cron/jobs${suffix}`, + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS + }) +} + +export function getCronJob(jobId: string): Promise { + return hermesApi({ + ...profileScoped(), + ...connectionScoped(), + path: `/api/cron/jobs/${encodeURIComponent(jobId)}` + }) +} + +export async function getCronJobRuns(jobId: string, limit = 20): Promise { + const { runs } = await hermesApi<{ runs: SessionInfo[] }>({ + ...profileScoped(), + ...connectionScoped(), + path: `/api/cron/jobs/${encodeURIComponent(jobId)}/runs?limit=${limit}` + }) + + return runs ?? [] +} + +// The single source of truth for cron delivery targets (local + configured +// gateways). Both the manual cron editor and the blueprint dialog use this so +// they never offer a platform that isn't connected. Mirrors the dashboard. +export async function getCronDeliveryTargets(): Promise { + const { targets } = await hermesApi<{ targets: CronDeliveryTarget[] }>({ + ...profileScoped(), + ...connectionScoped(), + path: '/api/cron/delivery-targets' + }) + + return targets ?? [] +} + +export function createCronJob(body: CronJobCreatePayload): Promise { + return hermesApi({ + ...profileScoped(), + ...connectionScoped(), + path: '/api/cron/jobs', + method: 'POST', + body + }) +} + +export function updateCronJob(jobId: string, updates: CronJobUpdates): Promise { + return hermesApi({ + ...profileScoped(), + ...connectionScoped(), + path: `/api/cron/jobs/${encodeURIComponent(jobId)}`, + method: 'PUT', + body: { updates } + }) +} + +export function pauseCronJob(jobId: string): Promise { + return hermesApi({ + ...profileScoped(), + ...connectionScoped(), + path: `/api/cron/jobs/${encodeURIComponent(jobId)}/pause`, + method: 'POST' + }) +} + +export function resumeCronJob(jobId: string): Promise { + return hermesApi({ + ...profileScoped(), + ...connectionScoped(), + path: `/api/cron/jobs/${encodeURIComponent(jobId)}/resume`, + method: 'POST' + }) +} + +export function triggerCronJob(jobId: string): Promise { + return hermesApi({ + ...profileScoped(), + ...connectionScoped(), + path: `/api/cron/jobs/${encodeURIComponent(jobId)}/trigger`, + method: 'POST', + timeoutMs: CRON_TRIGGER_REQUEST_TIMEOUT_MS + }) +} + +export function deleteCronJob(jobId: string): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...profileScoped(), + ...connectionScoped(), + path: `/api/cron/jobs/${encodeURIComponent(jobId)}`, + method: 'DELETE' + }) +} + +// Automation Blueprints — parameterized cron templates the backend serves from +// cron/blueprint_catalog.py. getAutomationBlueprints returns the gallery +// (deliver options already rewritten to this machine's configured gateways); +// instantiateAutomationBlueprint fills the slots and creates a real cron job via +// the same create_job path as createCronJob. +// +// Profile-scoping is intentionally asymmetric: the GET catalog is global (the +// list endpoint takes no profile — only deliver options are rewritten from the +// configured gateways), so it carries only the profileScoped() header for +// routing. instantiate creates a real per-profile job, so it names the target +// profile explicitly via ?profile=. This mirrors the dashboard's api.ts. +export function getAutomationBlueprints(): Promise<{ blueprints: AutomationBlueprint[] }> { + return hermesApi<{ blueprints: AutomationBlueprint[] }>({ + ...profileScoped(), + ...connectionScoped(), + path: '/api/cron/blueprints', + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS + }) +} + +export function instantiateAutomationBlueprint( + body: { blueprint: string; values: Record }, + profile: string +): Promise { + return hermesApi({ + ...profileScoped(), + ...connectionScoped(), + path: `/api/cron/blueprints/instantiate?profile=${encodeURIComponent(profile)}`, + method: 'POST', + body + }) +} diff --git a/apps/desktop/src/api/mcp.ts b/apps/desktop/src/api/mcp.ts new file mode 100644 index 0000000000..f67870e5bf --- /dev/null +++ b/apps/desktop/src/api/mcp.ts @@ -0,0 +1,147 @@ +import type { McpCatalogResponse, McpServerSummary } from '@/types/hermes' + +import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client' + +export interface McpTestResult { + ok: boolean + error?: string + /** `schema_chars` (converted registry-schema size, chars) is additive — + * older backends omit it and the cost overlay shows no token estimate. */ + tools: { name: string; description: string; schema_chars?: number }[] + /** Capability counts (absent on older backends / failed probes). */ + prompts?: number + resources?: number +} + +export interface McpOAuthFlow { + flow_id: string + server_name: string + status: 'starting' | 'authorization_required' | 'approved' | 'error' + authorization_url: string | null + error: string | null + tools?: { name: string; description: string }[] +} + +/** Connect to the server, list its tools, disconnect. Slow (spawns/handshakes + * for real) — well past the 15s default fetch timeout. */ +export function testMcpServer(name: string, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/mcp/servers/${encodeURIComponent(name)}/test`, + method: 'POST', + timeoutMs: 60_000 + }) +} + +/** Replace the whole `mcp_servers` map (the mcp.json editor's save). Unlike + * `saveHermesConfig`, this REPLACES rather than deep-merges, so deletes, + * re-enables (dropping `enabled: false`), and removed nested fields persist. */ +export function saveMcpServers( + servers: Record>, + profile?: ProfileScope +): Promise<{ ok: boolean }> { + return window.hermesDesktop.api<{ ok: boolean }>({ + ...capabilityScoped(profile), + path: '/api/mcp/servers', + method: 'PUT', + body: { servers } + }) +} + +/** Start an MCP OAuth flow and return the authorization URL. */ +export function authMcpServer(name: string, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/mcp/servers/${encodeURIComponent(name)}/auth`, + method: 'POST', + timeoutMs: 60_000 + }) +} + +export function getMcpOAuthFlow(flowId: string, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/mcp/oauth/flows/${encodeURIComponent(flowId)}` + }) +} + +/** Cancel an in-flight MCP OAuth flow server-side, freeing the per-server + * "already in progress" slot so a retry doesn't 409. */ +export function cancelMcpOAuthFlow(flowId: string, profile?: null | string): Promise<{ ok: boolean; status: string }> { + return hermesApi<{ ok: boolean; status: string }>({ + ...profileScoped(profile), + path: `/api/mcp/oauth/flows/${encodeURIComponent(flowId)}`, + method: 'DELETE' + }) +} + +// --------------------------------------------------------------------------- +// MCP servers — structured list / test / enable toggle / catalog (parity with +// `hermes mcp` and the dashboard MCP page). Raw JSON editing stays in +// config.yaml via saveHermesConfig. +// --------------------------------------------------------------------------- + +export function listMcpServers(): Promise<{ servers: McpServerSummary[] }> { + return hermesApi<{ servers: McpServerSummary[] }>({ + ...profileScoped(), + path: '/api/mcp/servers' + }) +} + +/** Add one server to `mcp_servers` (validated + name-collision-checked + * server-side — the same endpoint the dashboard's add form uses). */ +export function addMcpServer(body: { + name: string + url?: string + command?: string + args?: string[] + env?: Record + auth?: string +}): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/mcp/servers', + method: 'POST', + body + }) +} + +/** Remove one server from `mcp_servers` (the inline setup card's rollback + * when a directory install is cancelled after the config write). */ +export function removeMcpServer(name: string): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...profileScoped(), + path: `/api/mcp/servers/${encodeURIComponent(name)}`, + method: 'DELETE' + }) +} + +export function setMcpServerEnabled(name: string, enabled: boolean): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...profileScoped(), + path: `/api/mcp/servers/${encodeURIComponent(name)}/enabled`, + method: 'PUT', + body: { enabled } + }) +} + +export function getMcpCatalog(profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: '/api/mcp/catalog' + }) +} + +export function installMcpCatalogEntry( + name: string, + env: Record = {}, + profile?: ProfileScope +): Promise<{ ok: boolean; name?: string; pid?: number; action?: string; background?: boolean }> { + return window.hermesDesktop.api<{ ok: boolean; name?: string; pid?: number; action?: string; background?: boolean }>({ + ...capabilityScoped(profile), + path: '/api/mcp/catalog/install', + method: 'POST', + body: { name, env, enable: true }, + timeoutMs: 60_000 + }) +} diff --git a/apps/desktop/src/api/messaging.ts b/apps/desktop/src/api/messaging.ts new file mode 100644 index 0000000000..b49bdc93b8 --- /dev/null +++ b/apps/desktop/src/api/messaging.ts @@ -0,0 +1,131 @@ +import type { + MessagingPlatformsResponse, + MessagingPlatformTestResponse, + MessagingPlatformUpdate, + PairingResponse, + PairingUser, + WebhookCreatePayload, + WebhookCreateResponse, + WebhookEnableResponse, + WebhooksResponse +} from '@/types/hermes' + +import { hermesApi, profileScoped } from './client' + +export function getMessagingPlatforms(profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/messaging/platforms' + }) +} + +export function updateMessagingPlatform( + platformId: string, + body: MessagingPlatformUpdate, + profile?: null | string +): Promise<{ ok: boolean; platform: string }> { + return hermesApi<{ ok: boolean; platform: string }>({ + ...profileScoped(profile), + path: `/api/messaging/platforms/${encodeURIComponent(platformId)}`, + method: 'PUT', + body + }) +} + +export function testMessagingPlatform( + platformId: string, + profile?: null | string +): Promise { + return hermesApi({ + ...profileScoped(profile), + path: `/api/messaging/platforms/${encodeURIComponent(platformId)}/test`, + method: 'POST' + }) +} + +// -- Pairing (who may DM the bot) -------------------------------------------- +// Unknown DMers get a one-time code and land in `pending` until an admin +// approves them. Approval grants on the row's `request_id`, never on the code: +// the code is the requester's proof that the channel is theirs and is never +// returned by the API, while an authenticated admin is only ever identifying +// a row they can already see. + +export function getPairing(profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/pairing' + }) +} + +export function approvePairing( + platform: string, + requestId: string, + profile?: null | string +): Promise<{ ok: boolean; user: PairingUser }> { + return hermesApi<{ ok: boolean; user: PairingUser }>({ + ...profileScoped(profile), + path: '/api/pairing/approve', + method: 'POST', + // These endpoints read the profile off the body, not the query string — + // `profileScoped()` alone would approve into the wrong profile's store. + body: { platform, request_id: requestId, ...profileScoped(profile) } + }) +} + +export function revokePairing(platform: string, userId: string, profile?: null | string): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...profileScoped(profile), + path: '/api/pairing/revoke', + method: 'POST', + body: { platform, user_id: userId, ...profileScoped(profile) } + }) +} + +// -- Webhooks (subscription CRUD) -------------------------------------------- +// The webhook receiver is its own gateway platform; subscriptions live in a +// shared JSON store the CLI/dashboard also drive. Enable mutates config and +// best-effort restarts the gateway; subscription changes hot-reload. + +export function getWebhooks(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/webhooks' + }) +} + +export function enableWebhooks(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/webhooks/enable', + method: 'POST' + }) +} + +export function createWebhook(body: WebhookCreatePayload): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/webhooks', + method: 'POST', + body + }) +} + +export function deleteWebhook(name: string): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...profileScoped(), + path: `/api/webhooks/${encodeURIComponent(name)}`, + method: 'DELETE' + }) +} + +export function setWebhookEnabled( + name: string, + enabled: boolean +): Promise<{ enabled: boolean; name: string; ok: boolean }> { + return hermesApi<{ enabled: boolean; name: string; ok: boolean }>({ + ...profileScoped(), + path: `/api/webhooks/${encodeURIComponent(name)}/enabled`, + method: 'PUT', + body: { enabled } + }) +} diff --git a/apps/desktop/src/api/models.ts b/apps/desktop/src/api/models.ts new file mode 100644 index 0000000000..0aa1ca951f --- /dev/null +++ b/apps/desktop/src/api/models.ts @@ -0,0 +1,129 @@ +import type { + AnalyticsResponse, + AuxiliaryModelsResponse, + MoaConfigResponse, + ModelAssignmentRequest, + ModelAssignmentResponse, + ModelInfoResponse, + ModelOptionsResponse +} from '@/types/hermes' + +import { capabilityScoped, hermesApi, type ProfileScope, profileScoped, STARTUP_REQUEST_TIMEOUT_MS } from './client' + +export function getGlobalModelInfo(profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/model/info', + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS + }) +} + +export function getUsageAnalytics(days = 30, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/analytics/usage?days=${Math.max(1, Math.floor(days))}` + }) +} + +export function getGlobalModelOptions( + opts?: { + refresh?: boolean + includeUnconfigured?: boolean + explicitOnly?: boolean + }, + profile?: null | string +): Promise { + const params = new URLSearchParams() + + if (opts?.refresh) { + params.set('refresh', '1') + } + + if (opts?.includeUnconfigured) { + params.set('include_unconfigured', '1') + } + + if (opts?.explicitOnly !== false) { + params.set('explicit_only', '1') + } + + return hermesApi({ + ...profileScoped(profile), + path: params.size > 0 ? `/api/model/options?${params.toString()}` : '/api/model/options', + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS + }) +} + +export interface RecommendedDefaultModel { + provider: string + model: string + /** True/false for Nous (free vs paid tier); null for other providers. */ + free_tier: boolean | null +} + +// Recommended default model for a freshly-authenticated provider. Mirrors the +// curation `hermes model` does — for Nous it honors the free/paid tier so a +// free user gets a free model instead of a paid default. +export function getRecommendedDefaultModel( + provider: string, + profile?: null | string +): Promise { + return hermesApi({ + ...profileScoped(profile), + path: `/api/model/recommended-default?provider=${encodeURIComponent(provider)}` + }) +} + +export function setGlobalModel( + provider: string, + model: string +): Promise<{ ok: boolean; provider: string; model: string }> { + return hermesApi<{ ok: boolean; provider: string; model: string }>({ + ...profileScoped(), + path: '/api/model/set', + method: 'POST', + body: { + scope: 'main', + provider, + model + } + }) +} + +export function getAuxiliaryModels(profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/model/auxiliary' + }) +} + +export function getMoaModels(profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/model/moa' + }) +} + +export function saveMoaModels( + body: MoaConfigResponse, + profile?: null | string +): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/model/moa', + method: 'PUT', + body + }) +} + +export function setModelAssignment( + body: ModelAssignmentRequest, + profile?: null | string +): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/model/set', + method: 'POST', + body + }) +} diff --git a/apps/desktop/src/api/plugins.ts b/apps/desktop/src/api/plugins.ts new file mode 100644 index 0000000000..ff8e4a6b0c --- /dev/null +++ b/apps/desktop/src/api/plugins.ts @@ -0,0 +1,128 @@ +import type { HermesConnection } from '@/global' +import { reconnectBackoffDelayMs } from '@/lib/reconnect-backoff' + +import { getApiRequestConnection, getApiRequestProfile, hermesApi, profileScoped } from './client' + +/** Resolve the ACTIVE backend's connection descriptor, (connectionId, + * profile)-scoped — mirroring how store/profile resolves $connection: a + * registry agent's descriptor comes from getConnectionFor (its SOURCE + * connection), everything else from the profile-keyed local pool. The + * getConnectionFor bridge is optional (older Desktop mains); without it the + * profile-scoped pool lookup is the best available answer. */ +async function activeConnection(): Promise { + const getConnectionFor = window.hermesDesktop.getConnectionFor + const connectionId = getApiRequestConnection() + + if (connectionId && getConnectionFor) { + return getConnectionFor({ connectionId, profile: getApiRequestProfile() }) + } + + return window.hermesDesktop.getConnection(getApiRequestProfile()) +} + +/** Options for a plugin REST call — mirrors the app's own `hermesDesktop.api` + * shape, minus the path (which is namespace-derived). */ +export interface PluginRestOptions { + method?: string + body?: unknown + /** Single-file multipart upload (see HermesApiRequest.upload). */ + upload?: { filename: string; contentType?: string; bytes: ArrayBuffer } + timeoutMs?: number +} + +// Normalize `path` to a leading-slash suffix relative to `/api/plugins/`. +// The namespace is the boundary — reject `..` so a relative segment can't +// normalize out into another plugin's API or a core route. Check the path +// portion only (before any query/hash). +function pluginPathSuffix(caller: string, path: string): string { + const suffix = path.startsWith('/') ? path : `/${path}` + + if (suffix.split(/[?#]/, 1)[0].split('/').includes('..')) { + throw new Error(`${caller}: illegal path traversal in "${path}"`) + } + + return suffix +} + +/** The plugin REST door. Every call is scoped BY CONSTRUCTION to the plugin's + * own backend namespace — `path` is relative to `/api/plugins/` + * ('/board' → `/api/plugins/kanban/board`), so a plugin can't address another + * plugin's API or a core route through it. Profile-aware like every desktop + * REST call. Broader reach (core endpoints, another namespace) is the future + * declared-capability seam; today the namespace IS the boundary. */ +export async function pluginRest(pluginId: string, path: string, opts: PluginRestOptions = {}): Promise { + if (!window.hermesDesktop?.api) { + throw new Error('Hermes desktop bridge unavailable') + } + + const suffix = pluginPathSuffix('pluginRest', path) + + return hermesApi({ + path: `/api/plugins/${pluginId}${suffix}`, + method: opts.method, + body: opts.body, + upload: opts.upload, + timeoutMs: opts.timeoutMs, + ...profileScoped() + }) +} + +/** The plugin WebSocket door — the live twin of `pluginRest`, scoped the same + * way: `path` is relative to `/api/plugins/` ('/events' → the + * plugin's own event stream). Token-mode backends auth via the same query + * credential the app's own sockets use; OAuth remotes resolve null (callers + * keep their polling fallback — every consumer must have one anyway, since a + * socket can drop). Auto-reconnects with backoff until disposed. */ +export function pluginSocket(pluginId: string, path: string, onMessage: (data: unknown) => void): () => void { + const suffix = pluginPathSuffix('pluginSocket', path) + + let socket: null | WebSocket = null + let disposed = false + let attempt = 0 + + const connect = async () => { + const connection = await activeConnection().catch(() => null) + + // No bridge / OAuth cookie auth (WS tickets are single-use, core-managed): + // stay on the polling fallback rather than half-working. + if (disposed || !connection || connection.authMode === 'oauth') { + return + } + + const base = connection.baseUrl.replace(/^http/, 'ws') + const join = suffix.includes('?') ? '&' : '?' + socket = new WebSocket( + `${base}/api/plugins/${pluginId}${suffix}${join}token=${encodeURIComponent(connection.token)}` + ) + + socket.onmessage = event => { + attempt = 0 + + try { + onMessage(JSON.parse(String(event.data))) + } catch { + // Non-JSON frame — plugin streams are JSON by contract; skip it. + } + } + + socket.onclose = () => { + socket = null + + if (!disposed) { + // Full-jitter exponential backoff: same rationale as the gateway + // socket reconnect loops — an immediate-retry loop across many + // desktop clients floods the gateway with connection attempts + // during a restart. + window.setTimeout(() => void connect(), reconnectBackoffDelayMs(attempt, { baseDelayMs: 500, capMs: 30_000 })) + attempt += 1 + } + } + } + + void connect() + + return () => { + disposed = true + socket?.close() + } +} diff --git a/apps/desktop/src/api/profiles.ts b/apps/desktop/src/api/profiles.ts new file mode 100644 index 0000000000..52fc090906 --- /dev/null +++ b/apps/desktop/src/api/profiles.ts @@ -0,0 +1,89 @@ +import type { + ProfileCreatePayload, + ProfileDesktopOverlay, + ProfileSetupCommand, + ProfileSoul, + ProfilesResponse +} from '@/types/hermes' + +import { hermesApi, STARTUP_REQUEST_TIMEOUT_MS } from './client' + +export function getProfiles(): Promise { + return hermesApi({ + path: '/api/profiles', + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS + }) +} + +export function createProfile(body: ProfileCreatePayload): Promise<{ name: string; ok: boolean; path: string }> { + return hermesApi<{ name: string; ok: boolean; path: string }>({ + path: '/api/profiles', + method: 'POST', + body + }) +} + +export function renameProfile(name: string, newName: string): Promise<{ name: string; ok: boolean; path: string }> { + return hermesApi<{ name: string; ok: boolean; path: string }>({ + path: `/api/profiles/${encodeURIComponent(name)}`, + method: 'PATCH', + body: { new_name: newName } + }) +} + +export function deleteProfile(name: string): Promise<{ ok: boolean; path: string }> { + return hermesApi<{ ok: boolean; path: string }>({ + path: `/api/profiles/${encodeURIComponent(name)}`, + method: 'DELETE' + }) +} + +export function getProfileSoul(name: string): Promise { + return hermesApi({ + path: `/api/profiles/${encodeURIComponent(name)}/soul` + }) +} + +export function updateProfileSoul(name: string, content: string): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + path: `/api/profiles/${encodeURIComponent(name)}/soul`, + method: 'PUT', + body: { content } + }) +} + +export function getProfileSetupCommand(name: string): Promise { + return hermesApi({ + path: `/api/profiles/${encodeURIComponent(name)}/setup-command` + }) +} + +/** Export a profile to a shareable .tar.gz on the backend's filesystem. + * `extraFiles` stages extra root-level files (desktop.json — the appearance/ + * interface overlay) into the archive alongside the profile's own artifacts. */ +export function exportProfileArchive( + name: string, + opts: { extraFiles?: Record; output?: string } = {} +): Promise<{ archive: string; ok: boolean }> { + return hermesApi<{ archive: string; ok: boolean }>({ + path: `/api/profiles/${encodeURIComponent(name)}/export`, + method: 'POST', + body: { extra_files: opts.extraFiles ?? {}, output: opts.output ?? '' }, + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS + }) +} + +/** Import a profile .tar.gz as a new profile. Returns the bundled desktop + * appearance overlay too (when the archive carried one) so the caller can + * apply theme/layout without another round-trip. */ +export function importProfileArchive( + archive: string, + name?: string +): Promise<{ desktop: null | ProfileDesktopOverlay; name: string; ok: boolean; path: string }> { + return hermesApi<{ desktop: null | ProfileDesktopOverlay; name: string; ok: boolean; path: string }>({ + path: '/api/profiles/import', + method: 'POST', + body: { archive, name: name || null }, + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS + }) +} diff --git a/apps/desktop/src/api/sessions.ts b/apps/desktop/src/api/sessions.ts new file mode 100644 index 0000000000..e8f17ac226 --- /dev/null +++ b/apps/desktop/src/api/sessions.ts @@ -0,0 +1,465 @@ +import { isMissingRestEndpoint } from '@/lib/gateway-rpc' +import { recordTranscriptTail } from '@/store/transcript-tail' +import type { + PaginatedSessions, + SessionInfo, + SessionMessage, + SessionMessagesResponse, + SessionSearchResponse +} from '@/types/hermes' + +import { hermesApi } from './client' + +const SESSION_LIST_REQUEST_TIMEOUT_MS = 60_000 + +/** + * Trim a page to its window WITHOUT discarding pinned rows. + * + * The list endpoints deliberately back-fill pinned conversations past their + * LIMIT — a pin means "always reachable", so an aged-out pinned chat is + * appended after the recency window. A plain `slice(0, limit)` throws exactly + * those rows away again, which is why pins silently stopped rendering past + * some count: the sidebar could only ever show the pins that happened to fall + * inside the most-recent page. + */ +function pageWindow(sessions: SessionInfo[], limit: number): SessionInfo[] { + if (sessions.length <= limit) { + return sessions + } + + const recent = sessions.slice(0, limit) + + return [...recent, ...sessions.slice(limit).filter(session => session.pinned)] +} + +export async function listSessions( + limit = 40, + minMessages = 0, + archived: 'exclude' | 'include' | 'only' = 'exclude', + order: 'created' | 'recent' = 'recent' +): Promise { + const result = await hermesApi({ + path: + `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}` + + `&archived=${archived}&order=${order}`, + timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS + }) + + return { + ...result, + sessions: pageWindow(result.sessions, limit), + offset: 0 + } +} + +// Unified, read-only session list aggregated across ALL profiles. Served by the +// primary backend straight off each profile's state.db — no per-profile backend +// is spawned. Single-profile users get the same rows as listSessions(), tagged +// profile="default". +// Source scoping lets callers split the unified list into independent slices: +// recents pass `excludeSources: ['cron']`, the cron-jobs section passes +// `source: 'cron'`. Without this a burst of (always-newest) cron sessions +// consumes the whole recents page and starves real conversations. +export interface SessionSourceFilter { + source?: string + excludeSources?: string[] +} + +export async function listAllProfileSessions( + limit = 40, + minMessages = 0, + archived: 'exclude' | 'include' | 'only' = 'exclude', + order: 'created' | 'recent' = 'recent', + profile: 'all' | (string & {}) = 'all', + filter: SessionSourceFilter = {} +): Promise { + const sourceParam = filter.source ? `&source=${encodeURIComponent(filter.source)}` : '' + + const excludeParam = filter.excludeSources?.length + ? `&exclude_sources=${encodeURIComponent(filter.excludeSources.join(','))}` + : '' + + const result = await hermesApi({ + path: + `/api/profiles/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}` + + `&archived=${archived}&order=${order}&profile=${encodeURIComponent(profile)}${sourceParam}${excludeParam}`, + timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS + }) + + return { + ...result, + sessions: pageWindow(result.sessions, limit), + offset: 0 + } +} + +// Batched sidebar slices in one request: recents (scoped to the active profile), +// cron, and messaging. The backend opens each profile's state.db once and runs +// all three filtered queries, replacing three separate listAllProfileSessions +// calls that each reopened + re-counted every profile DB per refresh. Electron +// splices remote profiles per slice (see interceptSessionRequestForRemote). +export interface SidebarSessionSlice { + sessions: SessionInfo[] + /** Per-profile "the window came back full, more rows exist on disk" flags — + * what pagination needs, without a COUNT(*) per profile DB per refresh. */ + profiles_truncated?: Record + /** Per-profile tokens and spend over every session, not just this window. + * Absent from the legacy per-slice endpoint, which has no aggregate. */ + profiles_usage?: Record +} + +/** Which profiles filled their per-profile window in a returned page. The + * legacy per-slice endpoint doesn't report this, so derive it from the rows: + * a profile at (or over) the cap still has more on disk. Pinned rows are + * discounted — they're back-filled past the LIMIT, so counting them fakes a + * full page and leaves a "Load more" that can never resolve. */ +function profilesTruncatedFrom(sessions: SessionInfo[], cap: number): Record { + const counts = new Map() + + for (const session of sessions) { + const key = session.profile || 'default' + + counts.set(key, (counts.get(key) ?? 0) + (session.pinned ? 0 : 1)) + } + + return Object.fromEntries([...counts].map(([name, count]) => [name, count >= cap])) +} + +export interface SidebarSessionsResponse { + recents: SidebarSessionSlice + cron: SidebarSessionSlice + messaging: SidebarSessionSlice + errors?: Array<{ profile: string; error: string }> +} + +export interface SidebarSessionsRequest { + recentsProfile: 'all' | (string & {}) + recentsLimit: number + recentsExclude: string[] + cronLimit: number + messagingLimit: number + messagingExclude: string[] +} + +// The batched /sidebar endpoint shipped later than the per-slice route, so a +// newer desktop can meet an older backend that 404s it ("No such API +// endpoint"). Endpoint-missing is a capability signal, not a transient +// failure: remember it (per renderer lifetime — a runtime home change reloads +// the window and re-probes) and serve every subsequent refresh straight from +// the three proven per-slice calls instead of re-probing a known-dead route +// once per turn/broadcast. +let sidebarBatchEndpointMissing = false + +// Capability flags are per-backend facts. A hard re-home reloads the window +// (module state resets naturally), but a soft gateway switch re-dials in +// place — the next backend may well have the batched route, so the switch +// paths call this to re-probe rather than leak the old backend's capability. +export function resetSidebarBatchCapability() { + sidebarBatchEndpointMissing = false +} + +// Compatibility fallback: reassemble the three sidebar slices from the +// per-slice endpoint, mirroring the batched route's semantics (min_messages=1, +// archived excluded, recency order; every slice scoped to the caller's profile). +// Rides the same Electron remote-splice +// interception as the pre-batching desktop, so remote profiles stay correct. +async function listSidebarSessionsLegacy(req: SidebarSessionsRequest): Promise { + const [recents, cron, messaging] = await Promise.all([ + listAllProfileSessions(req.recentsLimit, 1, 'exclude', 'recent', req.recentsProfile, { + excludeSources: req.recentsExclude + }), + listAllProfileSessions(req.cronLimit, 1, 'exclude', 'recent', req.recentsProfile, { source: 'cron' }), + listAllProfileSessions(req.messagingLimit, 1, 'exclude', 'recent', req.recentsProfile, { + excludeSources: req.messagingExclude + }) + ]) + + const errors = [...(recents.errors ?? []), ...(cron.errors ?? []), ...(messaging.errors ?? [])] + + return { + recents: { + profiles_truncated: profilesTruncatedFrom(recents.sessions, req.recentsLimit), + sessions: recents.sessions + }, + cron: { sessions: cron.sessions }, + messaging: { sessions: messaging.sessions }, + ...(errors.length ? { errors } : {}) + } +} + +/** The PR each of these sessions opened, recovered from its own transcript — + * for sessions whose recorded branch can't answer (they started on trunk and + * did the work in a worktree). Also returns every id it looked at, so the + * caller can remember a miss and never ask again. */ +export function scanSessionPullRequests( + ids: string[] +): Promise<{ pull_requests: Record; scanned: string[] }> { + return hermesApi<{ + pull_requests: Record + scanned: string[] + }>({ + path: '/api/profiles/sessions/pull-requests', + method: 'POST', + body: { ids } + }) +} + +export async function listSidebarSessions(req: SidebarSessionsRequest): Promise { + if (sidebarBatchEndpointMissing) { + return listSidebarSessionsLegacy(req) + } + + const params = new URLSearchParams({ + recents_profile: req.recentsProfile, + recents_limit: String(Math.max(1, req.recentsLimit)), + cron_limit: String(Math.max(1, req.cronLimit)), + messaging_limit: String(Math.max(1, req.messagingLimit)) + }) + + if (req.recentsExclude.length) { + params.set('recents_exclude', req.recentsExclude.join(',')) + } + + if (req.messagingExclude.length) { + params.set('messaging_exclude', req.messagingExclude.join(',')) + } + + let result: SidebarSessionsResponse + + try { + result = await hermesApi({ + path: `/api/profiles/sessions/sidebar?${params.toString()}`, + timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS + }) + } catch (err) { + // Safe to read a 404 as route-missing here: this GET has no path params, + // so it cannot 404 on a bad id. + if (!isMissingRestEndpoint(err)) { + throw err + } + + // Older backend without the batched route (desktop/runtime version skew). + sidebarBatchEndpointMissing = true + + return listSidebarSessionsLegacy(req) + } + + return { + recents: { ...result.recents, sessions: result.recents?.sessions ?? [] }, + cron: { ...result.cron, sessions: result.cron?.sessions ?? [] }, + messaging: { ...result.messaging, sessions: result.messaging?.sessions ?? [] }, + errors: result.errors + } +} + +// Mutations take the owning `profile` so Electron can route them to the correct +// remote backend or local profile scope. Omit for the current/default profile. +export function setSessionArchived(id: string, archived: boolean, profile?: string | null): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...(profile ? { profile } : {}), + path: `/api/sessions/${encodeURIComponent(id)}`, + method: 'PATCH', + body: { archived } + }) +} + +// Mirror a sidebar pin to the backend "keep" flag so the sessions.auto_archive +// sweep (which runs backend-side, blind to Desktop localStorage) never hides a +// pinned chat. Best-effort: the sidebar stays localStorage-driven for its own +// display; this only feeds the backend policy. +export function setSessionPinnedRemote(id: string, pinned: boolean, profile?: string | null): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...(profile ? { profile } : {}), + path: `/api/sessions/${encodeURIComponent(id)}`, + method: 'PATCH', + body: { pinned } + }) +} + +// Mirror a sidebar unread toggle to the backend read-state watermark +// (sessions.last_read_at via SessionDB.set_session_read). Same profile +// routing as the other session mutations: a remote session's row lives only +// on its remote host, so the owning profile must travel with the request. +export function setSessionUnreadRemote(id: string, unread: boolean, profile?: string | null): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...(profile ? { profile } : {}), + path: `/api/sessions/${encodeURIComponent(id)}`, + method: 'PATCH', + body: { unread } + }) +} + +export function searchSessions(query: string): Promise { + return hermesApi({ + path: `/api/sessions/search?q=${encodeURIComponent(query)}` + }) +} + +// Resolves a single session row by id on one backend (the active profile, or +// the given `profile`). The backend resolves exact ids and unique prefixes and +// 404s when the id isn't on that profile — so a cheap by-id lookup replaces the +// cross-profile list scan when locating an unknown id's owner. +export function getSession(id: string, profile?: string | null): Promise { + const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : '' + + return hermesApi({ + ...(profile ? { profile } : {}), + path: `/api/sessions/${encodeURIComponent(id)}${suffix}` + }) +} + +// Reads another profile's transcript. For a remote profile Electron reroutes +// this GET to the remote backend (which serves its own state.db); for a local +// profile the primary opens that profile's state.db via ?profile=. Omit for +// the current/default profile. +export function getSessionMessages( + id: string, + profile?: string | null, + page: { limit?: number; offset?: number; order?: 'latest' | 'oldest'; includeCompacted?: boolean } = {} +): Promise { + const query = new URLSearchParams() + + if (profile) { + query.set('profile', profile) + } + + if (page.limit !== undefined) { + query.set('limit', String(page.limit)) + } + + if (page.offset !== undefined) { + query.set('offset', String(page.offset)) + } + + if (page.order) { + query.set('order', page.order) + } + + if (page.includeCompacted !== undefined) { + query.set('include_compacted', String(page.includeCompacted)) + } + + const suffix = query.size ? `?${query.toString()}` : '' + + return hermesApi({ + ...(profile ? { profile } : {}), + path: `/api/sessions/${encodeURIComponent(id)}/messages${suffix}` + }) +} + +/** + * The initial hydration page: enough tail to fill the transcript window a few + * times over, small enough that opening a long session doesn't ship (and + * convert) hundreds of rows nobody has scrolled to. Older rows load on demand + * via `getOlderSessionMessages` when "Show earlier" exhausts the in-memory + * store (see app/chat/transcript-backfill). + */ +export const LATEST_SESSION_MESSAGES_LIMIT = 120 + +export function getLatestSessionMessages(id: string, profile?: string | null): Promise { + // includeCompacted: durable display history must include rows preserved by + // in-place compaction (active=0, compacted=1); without them the transcript + // silently ends at the compaction boundary and earlier turns are unreachable. + return getSessionMessages(id, profile, { + limit: LATEST_SESSION_MESSAGES_LIMIT, + order: 'latest', + includeCompacted: true + }).then(page => { + // Record whether the tail was truncated (page came back full) and where + // the next older page starts, so "Show earlier" can backfill over REST + // (app/chat/transcript-backfill). Keyed under both the requested id and + // the resolved id — callers hold either. + recordTranscriptTail(id, page, profile) + + if (page.session_id && page.session_id !== id) { + recordTranscriptTail(page.session_id, page, profile) + } + + return page + }) +} + +/** + * One page of messages OLDER than the `offset` newest rows. + * + * Backend semantics (`_handle_session_messages` → `SessionDB.get_messages` + * with `latest=True`): the offset is measured back from the NEWEST message + * and the selected page is returned in chronological order. So after a tail + * hydration of N rows, `getOlderSessionMessages(id, profile, N)` returns the + * page immediately preceding it, ready to prepend. + * + * Legacy backends without pagination support return the full transcript and + * no `pagination` metadata — callers detect that via the missing field and + * treat the response as the complete history (see transcript-backfill). + */ +export function getOlderSessionMessages( + id: string, + profile: string | null | undefined, + offset: number, + limit: number = LATEST_SESSION_MESSAGES_LIMIT +): Promise { + return getSessionMessages(id, profile, { includeCompacted: true, limit, offset, order: 'latest' }) +} + +export async function getAllSessionMessages( + id: string, + profile?: string | null, + options: { maxJsonChars?: number } = {} +): Promise { + const messages: SessionMessage[] = [] + const pageSize = 500 + const maxJsonChars = options.maxJsonChars ?? 32_000_000 + let jsonChars = 0 + let offset = 0 + let resolvedSessionId = id + + while (true) { + const page = await getSessionMessages(id, profile, { + limit: pageSize, + offset, + order: 'oldest', + includeCompacted: true + }) + + resolvedSessionId = page.session_id + jsonChars += (JSON.stringify(page.messages) ?? '').length + + if (jsonChars > maxJsonChars) { + throw new Error( + 'Session transcript exceeds the Desktop safe-load limit; use the Web Dashboard export for this session.' + ) + } + + messages.push(...page.messages) + + // Legacy backends ignore pagination and return the full transcript. + if (!page.pagination || page.messages.length === 0 || page.messages.length < page.pagination.limit) { + break + } + + offset += page.messages.length + } + + return { session_id: resolvedSessionId, messages } +} + +export function deleteSession(id: string, profile?: string | null): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...(profile ? { profile } : {}), + path: `/api/sessions/${encodeURIComponent(id)}`, + method: 'DELETE' + }) +} + +export function renameSession( + id: string, + title: string, + profile?: string | null +): Promise<{ ok: boolean; title: string }> { + return hermesApi<{ ok: boolean; title: string }>({ + ...(profile ? { profile } : {}), + path: `/api/sessions/${encodeURIComponent(id)}`, + method: 'PATCH', + body: { title, ...(profile ? { profile } : {}) } + }) +} diff --git a/apps/desktop/src/api/skills.ts b/apps/desktop/src/api/skills.ts new file mode 100644 index 0000000000..cf29d2daf2 --- /dev/null +++ b/apps/desktop/src/api/skills.ts @@ -0,0 +1,162 @@ +import type { + SkillHubPreview, + SkillHubScanResult, + SkillHubSearchResponse, + SkillHubSourcesResponse, + SkillInfo, + StarmapGraph +} from '@/types/hermes' +import type { ActionResponse } from '@/types/hermes' + +import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client' + +export function getSkills(profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: '/api/skills' + }) +} + +/** Raw SKILL.md text (frontmatter included) for ANY skill — bundled, hub, or + * learned — backing the Capabilities detail pane's full-skill view. */ +export function getSkillContent( + name: string, + profile?: ProfileScope +): Promise<{ content: string; name: string; path: string }> { + return window.hermesDesktop.api<{ content: string; name: string; path: string }>({ + ...capabilityScoped(profile), + path: `/api/skills/content?name=${encodeURIComponent(name)}` + }) +} + +export function setSkillEnabled( + name: string, + enabled: boolean, + profile?: ProfileScope +): Promise<{ ok: boolean; name: string; enabled: boolean }> { + return window.hermesDesktop.api<{ ok: boolean; name: string; enabled: boolean }>({ + ...capabilityScoped(profile), + path: '/api/skills/toggle', + method: 'PUT', + body: { name, enabled } + }) +} + +export function getStarmapGraph(): Promise { + return hermesApi({ + ...profileScoped(), + // Backend REST contract — stays /api/learning even though the UI feature is + // now "star map". Renaming this would break against an un-upgraded backend. + path: '/api/learning/graph' + }) +} + +export interface LearningNodeDetail { + content: string + kind: 'memory' | 'skill' + label: string + ok: boolean +} + +export function getLearningNode(id: string, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/learning/node?id=${encodeURIComponent(id)}` + }) +} + +export function deleteLearningNode(id: string, profile?: ProfileScope): Promise<{ message: string; ok: boolean }> { + return window.hermesDesktop.api<{ message: string; ok: boolean }>({ + ...capabilityScoped(profile), + path: '/api/learning/node', + method: 'DELETE', + body: { id } + }) +} + +export function editLearningNode( + id: string, + content: string, + profile?: ProfileScope +): Promise<{ message: string; ok: boolean }> { + return window.hermesDesktop.api<{ message: string; ok: boolean }>({ + ...capabilityScoped(profile), + path: '/api/learning/node', + method: 'PUT', + body: { content, id } + }) +} + +// --------------------------------------------------------------------------- +// Skills hub — search / preview / scan / install (parity with `hermes skills` +// and the dashboard's Browse-hub tab). Installs spawn background actions whose +// logs are tailed via getActionStatus(). +// --------------------------------------------------------------------------- + +const HUB_REQUEST_TIMEOUT_MS = 45_000 + +export function getSkillHubSources(profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/skills/hub/sources', + timeoutMs: HUB_REQUEST_TIMEOUT_MS + }) +} + +export function searchSkillsHub( + query: string, + source = 'all', + limit = 20, + profile?: null | string +): Promise { + const params = new URLSearchParams({ q: query, source, limit: String(limit) }) + + return hermesApi({ + ...profileScoped(profile), + path: `/api/skills/hub/search?${params.toString()}`, + timeoutMs: HUB_REQUEST_TIMEOUT_MS + }) +} + +export function previewSkillHub(identifier: string, profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: `/api/skills/hub/preview?identifier=${encodeURIComponent(identifier)}`, + timeoutMs: HUB_REQUEST_TIMEOUT_MS + }) +} + +export function scanSkillHub(identifier: string, profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: `/api/skills/hub/scan?identifier=${encodeURIComponent(identifier)}`, + timeoutMs: HUB_REQUEST_TIMEOUT_MS + }) +} + +export function installSkillFromHub(identifier: string, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: '/api/skills/hub/install', + method: 'POST', + body: { identifier } + }) +} + +export function uninstallSkillFromHub(name: string, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: '/api/skills/hub/uninstall', + method: 'POST', + body: { name } + }) +} + +export function updateSkillsFromHub(profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: '/api/skills/hub/update', + method: 'POST', + body: {} + }) +} diff --git a/apps/desktop/src/api/system.ts b/apps/desktop/src/api/system.ts new file mode 100644 index 0000000000..678d09e549 --- /dev/null +++ b/apps/desktop/src/api/system.ts @@ -0,0 +1,247 @@ +import type { + ActionResponse, + ActionStatusResponse, + AudioSpeakResponse, + AudioTranscriptionResponse, + BackendUpdateCheckResponse, + CuratorStatusResponse, + DebugShareResponse, + ElevenLabsVoicesResponse, + MemoryProviderConfig, + MemoryProviderOAuthStatus, + MemoryStatusResponse +} from '@/types/hermes' + +import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client' + +export const AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS = 180_000 +export const AUDIO_SPEAK_MAX_REQUEST_TIMEOUT_MS = 600_000 +const AUDIO_SPEAK_TIMEOUT_MS_PER_CHAR = 35 + +export function audioSpeakRequestTimeoutMs(text: string): number { + const estimated = Math.max( + AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS, + Math.ceil(String(text || '').length * AUDIO_SPEAK_TIMEOUT_MS_PER_CHAR) + ) + + return Math.min(AUDIO_SPEAK_MAX_REQUEST_TIMEOUT_MS, estimated) +} + +export const AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS = 180_000 +export const AUDIO_TRANSCRIBE_MAX_REQUEST_TIMEOUT_MS = 600_000 +// The transcribe payload is the base64 audio data URL itself, so its string +// length tracks clip size. ~0.1ms/char keeps short clips at the floor while +// letting multi-minute recordings scale toward the cap (a base64 char is +// ~0.75 bytes, so at 128kbps ≈ 21k chars/s of audio this budgets ~2s of +// timeout per 1s of audio before the cap clamps it). +const AUDIO_TRANSCRIBE_TIMEOUT_MS_PER_CHAR = 0.1 + +export function audioTranscribeRequestTimeoutMs(dataUrl: string): number { + const estimated = Math.max( + AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS, + Math.ceil(String(dataUrl || '').length * AUDIO_TRANSCRIBE_TIMEOUT_MS_PER_CHAR) + ) + + return Math.min(AUDIO_TRANSCRIBE_MAX_REQUEST_TIMEOUT_MS, estimated) +} + +// surface=declared serves the curated desktop schema; the dashboard consumes the raw plugin schema. +export function getMemoryProviderConfig(provider: string, profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared` + }) +} + +export function saveMemoryProviderConfig( + provider: string, + values: Record, + profile?: null | string +): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...profileScoped(profile), + path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared`, + method: 'PUT', + body: { values } + }) +} + +// Memory-provider OAuth connect (provider-keyed; 404s for providers without an +// OAuth flow). Profile-scoped: the grant lands in the active profile's config. +export function startMemoryProviderOAuth( + provider: string, + profile?: null | string +): Promise { + return hermesApi({ + ...profileScoped(profile), + path: `/api/memory/providers/${encodeURIComponent(provider)}/oauth/start`, + method: 'POST' + }) +} + +export function getMemoryProviderOAuthStatus( + provider: string, + profile?: null | string +): Promise { + return hermesApi({ + ...profileScoped(profile), + path: `/api/memory/providers/${encodeURIComponent(provider)}/oauth/status` + }) +} + +// --------------------------------------------------------------------------- +// Memory data + curator (parity with `hermes memory` / `hermes curator`). +// --------------------------------------------------------------------------- + +export function getMemoryStatus(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/memory' + }) +} + +export function resetMemory(target: 'all' | 'memory' | 'user'): Promise<{ ok: boolean; deleted: string[] }> { + return hermesApi<{ ok: boolean; deleted: string[] }>({ + ...profileScoped(), + path: '/api/memory/reset', + method: 'POST', + body: { target } + }) +} + +export function getCuratorStatus(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/curator' + }) +} + +export function setCuratorPaused(paused: boolean): Promise<{ ok: boolean; paused: boolean }> { + return hermesApi<{ ok: boolean; paused: boolean }>({ + ...profileScoped(), + path: '/api/curator/paused', + method: 'PUT', + body: { paused } + }) +} + +export function runCurator(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/curator/run', + method: 'POST', + body: {} + }) +} + +export function restartGateway(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/gateway/restart', + method: 'POST' + }) +} + +export function updateHermes(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/hermes/update', + method: 'POST' + }) +} + +/** Query the connected backend's own update state. In remote mode this is the + * authoritative source for the backend's behind-count + "what's changed", + * distinct from the Electron client clone's git state. */ +export function checkHermesUpdate(force = false): Promise { + return hermesApi({ + ...profileScoped(), + path: `/api/hermes/update/check${force ? '?force=true' : ''}` + }) +} + +export function getActionStatus(name: string, lines = 200, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/actions/${encodeURIComponent(name)}/status?lines=${Math.max(1, lines)}` + }) +} + +export function transcribeAudio(dataUrl: string, mimeType?: string): Promise { + return hermesApi({ + path: '/api/audio/transcribe', + method: 'POST', + ...profileScoped(), + body: { + data_url: dataUrl, + mime_type: mimeType + }, + // Transcription blocks until provider STT, file handling, and response + // encoding finish. Remote providers and long clips regularly exceed the + // default 15s Electron backend timeout. + timeoutMs: audioTranscribeRequestTimeoutMs(dataUrl) + }) +} + +export function speakText(text: string): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/audio/speak', + method: 'POST', + body: { text }, + // TTS blocks until provider synthesis, file read, and base64 encoding + // finish. Remote providers and large messages regularly exceed the + // default 15s Electron backend timeout. + timeoutMs: audioSpeakRequestTimeoutMs(text) + }) +} + +export function getElevenLabsVoices(profile?: null | string): Promise { + return hermesApi({ + path: '/api/audio/elevenlabs/voices', + ...profileScoped(profile) + }) +} + +/** `gh` CLI presence + auth state, for the composer's GitHub skill pill + * (GitHub is deliberately not an MCP — the github/* skills are the + * integration). Backend caches for 5 minutes; `refresh` bypasses. */ +export function getGhAuthStatus(refresh = false): Promise<{ available: boolean; authenticated: boolean }> { + return hermesApi<{ available: boolean; authenticated: boolean }>({ + ...profileScoped(), + path: `/api/git/gh-auth${refresh ? '?refresh=true' : ''}` + }) +} + +// --------------------------------------------------------------------------- +// Maintenance operations (parity with `hermes doctor` / `hermes security +// audit` / `hermes backup` / `hermes debug share` and the dashboard System +// page). All except debug share are spawn-based background actions tailed via +// getActionStatus(). +// --------------------------------------------------------------------------- + +export function runDoctor(): Promise { + return hermesApi({ path: '/api/ops/doctor', method: 'POST', body: {} }) +} + +export function runSecurityAudit(): Promise { + return hermesApi({ path: '/api/ops/security-audit', method: 'POST', body: {} }) +} + +export function runBackup(): Promise { + return hermesApi({ + path: '/api/ops/backup', + method: 'POST', + body: {} + }) +} + +export function runDebugShare(): Promise { + return hermesApi({ + path: '/api/ops/debug-share', + method: 'POST', + body: {}, + // Synchronous upload of report + logs to the paste service. + timeoutMs: 120_000 + }) +} diff --git a/apps/desktop/src/api/toolsets.ts b/apps/desktop/src/api/toolsets.ts new file mode 100644 index 0000000000..862c0fc521 --- /dev/null +++ b/apps/desktop/src/api/toolsets.ts @@ -0,0 +1,141 @@ +import type { + ActionResponse, + ComputerUseStatus, + TerminalBackendsResponse, + ToolsetConfig, + ToolsetInfo, + ToolsetModelsResponse +} from '@/types/hermes' + +import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client' + +// The optional trailing `profile` on every capability fetcher below is the +// Capabilities view's profile-scope override: it lets the Skills/Tools/MCP +// panels configure ANY profile without swapping the app-wide active profile. +// Omitting it (every pre-existing caller) means `profileScoped(undefined)` +// falls back to the app-wide `_apiProfile`, so behavior is byte-identical. +export function getToolsets(profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: '/api/tools/toolsets' + }) +} + +export function setToolsetEnabled( + name: string, + enabled: boolean, + profile?: ProfileScope +): Promise<{ ok: boolean; name: string; enabled: boolean }> { + return window.hermesDesktop.api<{ ok: boolean; name: string; enabled: boolean }>({ + ...capabilityScoped(profile), + path: `/api/tools/toolsets/${encodeURIComponent(name)}`, + method: 'PUT', + body: { enabled } + }) +} + +export function getToolsetConfig(name: string, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/tools/toolsets/${encodeURIComponent(name)}/config` + }) +} + +export function getToolsetModels( + name: string, + provider?: string, + profile?: ProfileScope +): Promise { + const suffix = provider ? `?provider=${encodeURIComponent(provider)}` : '' + + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/tools/toolsets/${encodeURIComponent(name)}/models${suffix}` + }) +} + +export function selectToolsetModel( + name: string, + model: string, + provider?: string, + profile?: ProfileScope +): Promise<{ ok: boolean; name: string; model: string }> { + return window.hermesDesktop.api<{ ok: boolean; name: string; model: string }>({ + ...capabilityScoped(profile), + path: `/api/tools/toolsets/${encodeURIComponent(name)}/model`, + method: 'PUT', + body: { model, provider } + }) +} + +export interface SelectToolsetProviderResponse { + ok: boolean + name: string + provider: string + /** Present when the selection was scoped to one web capability. */ + capability?: string + /** Present (true) when a managed Nous row was selected but the Portal + * entitlement is missing — the row won't activate until the user signs + * in to Nous Portal. */ + needs_nous_auth?: boolean + /** The managed feature key (e.g. "browser") when needs_nous_auth is set. */ + feature?: string +} + +export function selectToolsetProvider( + name: string, + provider: string, + capability?: 'search' | 'extract', + profile?: ProfileScope +): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/tools/toolsets/${encodeURIComponent(name)}/provider`, + method: 'PUT', + body: capability ? { provider, capability } : { provider } + }) +} + +export function runToolsetPostSetup( + name: string, + key: string, + profile?: ProfileScope +): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/tools/toolsets/${encodeURIComponent(name)}/post-setup`, + method: 'POST', + body: { key } + }) +} + +export function getTerminalBackends(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/tools/terminal/backends' + }) +} + +export function selectTerminalBackend(backend: string): Promise<{ ok: boolean; backend: string }> { + return hermesApi<{ ok: boolean; backend: string }>({ + ...profileScoped(), + path: '/api/tools/terminal/backend', + method: 'PUT', + body: { backend } + }) +} + +export function getComputerUseStatus(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/tools/computer-use/status' + }) +} + +export function grantComputerUsePermissions(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/tools/computer-use/permissions/grant', + method: 'POST' + }) +} diff --git a/apps/desktop/src/app/artifacts/index.tsx b/apps/desktop/src/app/artifacts/index.tsx index 07f2cc7048..73321f5ae7 100644 --- a/apps/desktop/src/app/artifacts/index.tsx +++ b/apps/desktop/src/app/artifacts/index.tsx @@ -492,7 +492,10 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }: }, [artifact.href, artifact.id, artifact.value, onImageError]) return ( -
+
{ cleanup() + $hudMode.set(false) +}) + +// The HUD is a Spotlight bar a few hundred pixels wide: the four voice +// controls fold into one menu there, and the way out of HUD mode joins the +// row instead of floating above the bar in a reserved strip. The docked +// composer keeps every control inline and shows no exit. +describe('HUD mode', () => { + it('keeps the voice controls inline and offers no exit in the docked composer', () => { + renderControls() + + expect(screen.getByLabelText('Voice dictation')).toBeTruthy() + expect(screen.getByLabelText('Read replies aloud')).toBeTruthy() + expect(screen.queryByLabelText('Exit HUD mode')).toBeNull() + expect(screen.queryByLabelText('Voice')).toBeNull() + }) + + it('folds them into one menu and offers the way out in the HUD', () => { + $hudMode.set(true) + renderControls() + + expect(screen.getByLabelText('Voice')).toBeTruthy() + expect(screen.getByLabelText('Exit HUD mode')).toBeTruthy() + + // Folded away, not duplicated — the whole point is the row's width back. + expect(screen.queryByLabelText('Voice dictation')).toBeNull() + expect(screen.queryByLabelText('Read replies aloud')).toBeNull() + }) + + // A collapsed menu that looked idle while the mic was open would be a worse + // trade than the space it saves, so the trigger reports the live state. + it('reports a live voice state on the collapsed trigger', () => { + $hudMode.set(true) + renderControls({ voiceStatus: 'recording' }) + + expect(screen.getByLabelText('Stop dictation')).toBeTruthy() + expect(screen.queryByLabelText('Voice')).toBeNull() + }) +}) + +// A tile can be narrower than the controls cost, and the row is inside an +// overflow-hidden surface — so anything that doesn't fold gets clipped off the +// right edge, send button first. The ladder keeps going past `stacked`: voice +// folds into the same menu the HUD uses, then the model pill drops. Send is +// the last thing standing. +describe('narrow tiles', () => { + it('folds the voice controls into one menu without entering HUD mode', () => { + renderControls({ foldVoice: true }) + + expect(screen.getByLabelText('Voice')).toBeTruthy() + expect(screen.queryByLabelText('Voice dictation')).toBeNull() + expect(screen.queryByLabelText('Read replies aloud')).toBeNull() + + // Folding is a width decision, not the HUD: no exit affordance appears. + expect(screen.queryByLabelText('Exit HUD mode')).toBeNull() + }) + + it('keeps Send at the tightest width, with everything else dropped', () => { + renderControls({ foldVoice: true, minimal: true }) + + expect(screen.getByLabelText('Send')).toBeTruthy() + expect(screen.queryByLabelText('Voice')).toBeNull() + }) + + it('keeps Stop reachable mid-turn at the tightest width', () => { + renderControls({ busy: true, busyAction: 'stop', foldVoice: true, hasComposerPayload: false, minimal: true }) + + expect(screen.getByLabelText('Stop')).toBeTruthy() + }) }) describe('ComposerControls shortcut tooltips', () => { diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx index 286493d006..c48e3eb21b 100644 --- a/apps/desktop/src/app/chat/composer/controls.tsx +++ b/apps/desktop/src/app/chat/composer/controls.tsx @@ -7,26 +7,18 @@ import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' import { AudioLines, Ear, EarOff, iconSize, Layers3, Loader2, Square, Volume2, VolumeX } from '@/lib/icons' import { cn } from '@/lib/utils' +import { $hudMode, closeHud } from '@/store/hud' import { $wakeWord, toggleWakeWord } from '@/store/wake-word' +import { ACTIVE_ICON_BTN, GHOST_ICON_BTN, PRIMARY_ICON_BTN } from './control-classes' import type { ConversationStatus } from './hooks/use-voice-conversation' import { ModelPill } from './model-pill' import type { ChatBarState, VoiceStatus } from './types' +import { VoiceMenu } from './voice-menu' -export const ICON_BTN = 'size-(--composer-control-size) shrink-0 rounded-md' -export const GHOST_ICON_BTN = cn( - ICON_BTN, - 'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground' -) -// Send/voice-conversation primary: solid foreground-on-background circle -// (reads as black-on-white in light mode, white-on-black in dark mode) to -// match the reference composer's high-contrast CTA. Keeps the pill itself -// neutral and lets the action visually dominate the row. -export const PRIMARY_ICON_BTN = cn( - 'size-(--composer-control-primary-size,var(--composer-control-size)) shrink-0 rounded-full p-0', - 'bg-foreground text-background hover:bg-foreground/90', - 'disabled:bg-foreground/30 disabled:text-background disabled:opacity-100' -) +// Re-exported: `context-menu.tsx` and other row neighbours have always reached +// for these here, and the row is where they read as belonging. +export { ACTIVE_ICON_BTN, GHOST_ICON_BTN, ICON_BTN, PRIMARY_ICON_BTN } from './control-classes' interface ConversationProps { active: boolean @@ -47,7 +39,9 @@ export function ComposerControls({ compactModelPill = false, conversation, disabled, + foldVoice = false, hasComposerPayload, + minimal = false, state, voiceStatus, onDictate, @@ -61,7 +55,9 @@ export function ComposerControls({ compactModelPill?: boolean conversation: ConversationProps disabled: boolean + foldVoice?: boolean hasComposerPayload: boolean + minimal?: boolean state: ChatBarState voiceStatus: VoiceStatus onDictate: () => void @@ -70,6 +66,7 @@ export function ComposerControls({ }) { const { t } = useI18n() const c = t.composer + const hudMode = useStore($hudMode) if (conversation.active) { return @@ -80,13 +77,40 @@ export function ComposerControls({ // only when the composer is empty and a turn is running. const showStop = busy && !hasComposerPayload const showQueueButton = busyAction !== 'stop' && hasComposerPayload + // The HUD is a Spotlight bar a few hundred pixels wide, so the four separate + // voice toggles fold into one menu there and leave the row to the input. A + // narrow tile hits the same wall from the other direction and folds for the + // same reason — same controls, same state, different budget. Below that + // even the menu goes: at `minimal` the row is the send button and nothing + // else, which is the one thing that must survive every width. + const foldedVoice = hudMode || foldVoice - return ( -
- + const voiceControls = foldedVoice ? ( + + ) : ( + <> + + ) + + return ( +
+ {minimal ? null : ( + <> + + {voiceControls} + + )} {showQueueButton ? ( }>
) } +function ExitHudButton() { + const { t } = useI18n() + + return ( + + + + ) +} + function ConversationPill({ disabled, level, @@ -269,11 +320,7 @@ function AutoSpeakButton({ active, disabled, onToggle }: { active: boolean; disa + + + + { + triggerHaptic('open') + onStartConversation() + }} + > + + {c.startVoice} + + + {/* Checkbox items, because all three are toggles the user is reading + the CURRENT state of — the reason they were pressed-state buttons + before. A plain row would fold that state away with the menu. */} + { + // Keep the menu open: dictation is a mode you watch, and closing + // on select hides the recording state the trigger just entered. + event.preventDefault() + triggerHaptic(dictating ? 'close' : 'open') + onDictate() + }} + > + {dictationLabel} + + { + event.preventDefault() + triggerHaptic(autoSpeak ? 'close' : 'open') + onToggleAutoSpeak() + }} + > + {autoSpeak ? : } + {autoSpeak ? c.stopSpeakingReplies : c.speakReplies} + + { + event.preventDefault() + triggerHaptic(wakeListening ? 'close' : 'open') + void toggleWakeWord() + }} + > + {wakeListening ? : } + {wakeLabel} + + + + ) +} diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 13ed225186..3f45065a56 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -3,7 +3,7 @@ import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' import type { ReadableAtom } from 'nanostores' import type * as React from 'react' -import { memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { memo, Suspense, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' import { useLocation } from 'react-router' import type { SubmitTextOptions } from '@/app/session/hooks/use-prompt-actions/utils' @@ -23,9 +23,11 @@ import type { ChatMessage } from '@/lib/chat-messages' import { NEW_SESSION_TITLE, quickModelOptions, sessionTitle } from '@/lib/chat-runtime' import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime' import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options' +import { useStoreSelector } from '@/lib/use-session-slice' import { cn } from '@/lib/utils' import { migrateSessionDraft } from '@/store/composer' import { migrateQueuedPrompts, parkQueuedPrompts } from '@/store/composer-queue' +import { $introSplash } from '@/store/intro-splash' import { $pinnedSessionIds } from '@/store/layout' import { $petActive } from '@/store/pet' import { $petOverlayActive } from '@/store/pet-overlay' @@ -43,7 +45,7 @@ import { sessionPinId, shouldMigrateComposerScope } from '@/store/session' -import { sessionTileDelegate } from '@/store/session-states' +import { $focusedStoredSessionId, sessionTileDelegate } from '@/store/session-states' import { $transcriptTailBySessionId } from '@/store/transcript-tail' import { isAuxiliaryWindow, isWatchWindow } from '@/store/windows' import type { ModelOptionsResponse } from '@/types/hermes' @@ -56,10 +58,11 @@ import { ChatSwapOverlay } from './chat-swap-overlay' import { ChatBar, ChatBarFallback } from './composer' import { requestComposerInsert } from './composer/focus' import { droppedFileInlineRefs } from './composer/inline-refs' -import { useComposerScope } from './composer/scope' +import { ComposerSurfaceProvider, useComposerScope, useComposerSurfaceId } from './composer/scope' import type { ChatBarState } from './composer/types' import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions' import { type DragKind, useFileDropZone } from './hooks/use-file-drop-zone' +import { shouldShowIntro } from './intro-visibility' import { ProfileTag } from './profile-tag' import { isRouteSessionMismatch } from './route-session-state' import { useRuntimeMessageRepository } from './runtime-repository' @@ -320,7 +323,17 @@ function ChatRuntimeBoundary({ // Memoized: the tile caller (session-tile.tsx) and the contrib surface re-render // on idle ticks unrelated to the chat; with stable callback props (hoisted to // useCallback at the call sites) memo() lets the whole chat shell skip those. -export const ChatView = memo(function ChatView({ +export const ChatView = memo(function ChatView(props: ChatViewProps) { + const composerSurfaceId = useId() + + return ( + + + + ) +}) + +const ChatViewContent = memo(function ChatViewContent({ className, gateway, modelMenuContent, @@ -355,9 +368,17 @@ export const ChatView = memo(function ChatView({ // atoms) or a tile's session slice — same component either way. const view = useSessionView() const composerScope = useComposerScope() + const composerSurfaceId = useComposerSurfaceId() const isPrimary = view.kind === 'primary' const activeSessionId = useStore(view.$runtimeId) const storedId = useStore(view.$storedId) + // Multi-pane dimming: only the focused surface paints at full strength, so + // two sessions side by side read as "this one, and that one over there". + // A selector, not a plain useStore — the focused id changes on click, and a + // boolean bails every other surface out of the re-render. Sole surface ⇒ + // always focused (the atom falls back to the primary's selection), so a + // single-pane workspace never dims. + const surfaceFocused = useStoreSelector($focusedStoredSessionId, focused => focused === storedId) // Dock anchor for a session drop onto this surface: the workspace pane for the // primary, this tile's pane id for a tile. Read by the session-drop bridge. const sessionAnchor = isPrimary ? 'workspace' : `session-tile:${storedId ?? ''}` @@ -380,6 +401,7 @@ export const ChatView = memo(function ChatView({ const gatewayOpen = gatewayState === 'open' const introPersonality = useStore($introPersonality) const introSeed = useStore($introSeed) + const introSplash = useStore($introSplash) // PERF: ChatView must not subscribe to the view's $messages — the atom is // replaced on every streaming delta flush (~30×/s) and a subscription here // re-renders the entire chat shell (header, chat bar, thread wrapper) per @@ -444,15 +466,18 @@ export const ChatView = memo(function ChatView({ const routeSessionMismatch = isPrimary ? isRouteSessionMismatch(routedSessionId, selectedSessionId, sessions) : false // The compact new-session pop-out skips the wordmark/tagline intro — it's a - // scratch window, not the full-height empty state. - const showIntro = - isPrimary && - !isAuxiliaryWindow() && - freshDraftReady && - !isRoutedSessionView && - !selectedSessionId && - !activeSessionId && - messagesEmpty + // scratch window, not the full-height empty state. The Appearance toggle + // turns it off everywhere else. + const showIntro = shouldShowIntro({ + activeSessionId, + auxiliaryWindow: isAuxiliaryWindow(), + enabled: introSplash, + freshDraftReady, + messagesEmpty, + primary: isPrimary, + routedSessionView: isRoutedSessionView, + selectedSessionId + }) // Session is still loading if the route references a session we haven't // resumed yet. Once `activeSessionId` is set (runtime has resumed), the @@ -558,6 +583,8 @@ export const ChatView = memo(function ChatView({ className )} data-chat-surface="" + data-chat-unfocused={surfaceFocused ? undefined : ''} + data-composer-surface-id={composerSurfaceId} data-composer-target={composerScope.target} data-session-anchor={sessionAnchor} > diff --git a/apps/desktop/src/app/chat/intro-visibility.test.ts b/apps/desktop/src/app/chat/intro-visibility.test.ts new file mode 100644 index 0000000000..7589b66fe1 --- /dev/null +++ b/apps/desktop/src/app/chat/intro-visibility.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' + +import { shouldShowIntro } from './intro-visibility' + +const showing = { + activeSessionId: null, + auxiliaryWindow: false, + enabled: true, + freshDraftReady: true, + messagesEmpty: true, + primary: true, + routedSessionView: false, + selectedSessionId: null +} as const + +describe('shouldShowIntro', () => { + it('shows on a fresh draft in the primary window', () => { + expect(shouldShowIntro(showing)).toBe(true) + }) + + it('hides when the Appearance toggle is off', () => { + expect(shouldShowIntro({ ...showing, enabled: false })).toBe(false) + }) + + it('keeps the toggle authoritative over every other clause', () => { + // Off means off: no window, session, or draft state re-enables the splash. + const inputs = [ + { ...showing, auxiliaryWindow: true, enabled: false }, + { ...showing, enabled: false, freshDraftReady: false }, + { ...showing, enabled: false, primary: false }, + { ...showing, enabled: false, messagesEmpty: false } + ] + + for (const input of inputs) { + expect(shouldShowIntro(input)).toBe(false) + } + }) + + it('hides on surfaces that are not an empty primary draft', () => { + expect(shouldShowIntro({ ...showing, primary: false })).toBe(false) + expect(shouldShowIntro({ ...showing, auxiliaryWindow: true })).toBe(false) + expect(shouldShowIntro({ ...showing, freshDraftReady: false })).toBe(false) + expect(shouldShowIntro({ ...showing, routedSessionView: true })).toBe(false) + expect(shouldShowIntro({ ...showing, selectedSessionId: 'session-1' })).toBe(false) + expect(shouldShowIntro({ ...showing, activeSessionId: 'session-1' })).toBe(false) + expect(shouldShowIntro({ ...showing, messagesEmpty: false })).toBe(false) + }) +}) diff --git a/apps/desktop/src/app/chat/intro-visibility.ts b/apps/desktop/src/app/chat/intro-visibility.ts new file mode 100644 index 0000000000..575df1ba13 --- /dev/null +++ b/apps/desktop/src/app/chat/intro-visibility.ts @@ -0,0 +1,32 @@ +/** + * Whether the empty-chat intro splash renders. + * + * The splash is the full-height empty state of the primary chat: it belongs to + * a fresh draft in the main window and nothing else. Auxiliary and non-primary + * windows are scratch surfaces, a routed or active session already owns the + * view, and any transcript at all means the conversation started. + * + * `enabled` is the user's Appearance toggle and outranks every other clause: + * turning the splash off never depends on which window asks. + */ +export function shouldShowIntro(input: { + activeSessionId: null | string + auxiliaryWindow: boolean + enabled: boolean + freshDraftReady: boolean + messagesEmpty: boolean + primary: boolean + routedSessionView: boolean + selectedSessionId: null | string +}): boolean { + return ( + input.enabled && + input.primary && + !input.auxiliaryWindow && + input.freshDraftReady && + !input.routedSessionView && + !input.selectedSessionId && + !input.activeSessionId && + input.messagesEmpty + ) +} diff --git a/apps/desktop/src/app/chat/right-rail/preview-act.test.ts b/apps/desktop/src/app/chat/right-rail/preview-act.test.ts new file mode 100644 index 0000000000..2b68b1eb99 --- /dev/null +++ b/apps/desktop/src/app/chat/right-rail/preview-act.test.ts @@ -0,0 +1,314 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { $rightRailActiveTabId } from '@/store/layout' +import { closeRightRail, openPreview, type PreviewTarget } from '@/store/preview' + +import { actOnActivePreview } from './preview-act' +import { registerPreviewInput } from './preview-input' +import { registerPreviewNav } from './preview-nav' +import { registerPreviewScriptRunner } from './preview-script-runner' + +function urlTarget(url: string): PreviewTarget { + return { kind: 'url', label: 'Browser', source: url, url } +} + +describe('actOnActivePreview (drive_preview tool)', () => { + // URL targets share the singleton Browser tab id, so anything a test + // registers would answer the next one. + let cleanups: Array<() => void> = [] + + const openBrowserTab = () => { + openPreview(urlTarget('https://example.com'), 'tool-result') + + return $rightRailActiveTabId.get()! + } + + const withRunner = (runner: (code: string) => Promise) => + cleanups.push(registerPreviewScriptRunner(openBrowserTab(), runner)) + + beforeEach(() => { + vi.useRealTimers() + + for (const cleanup of cleanups) { + cleanup() + } + + cleanups = [] + closeRightRail() + window.localStorage.clear() + }) + + it('tells the agent to open a page when no live pane is behind the tab', async () => { + const result = await actOnActivePreview({ kind: 'elements' }) + + expect(result.success).toBe(false) + expect(result.error).toContain('open_preview') + }) + + it('injects the engine and returns the page’s answer', async () => { + let injected = '' + + withRunner(async code => { + injected = code + + return JSON.stringify({ acted: 'clicked button "Save"', success: true }) + }) + + const result = await actOnActivePreview({ kind: 'click', ref: '@e1' }) + + expect(result).toMatchObject({ acted: 'clicked button "Save"', success: true }) + // Self-contained payload: the engine source and the action travel together, + // and the holder keeps refs alive across calls on the same page. + expect(injected).toContain('__hermesActHolder') + expect(injected).toContain('"ref":"@e1"') + }) + + it('re-inventories after a mutating action so the next ref is current', async () => { + const actions: string[] = [] + + withRunner(code => { + // Stand in for the guest page: run the script's own settle/rescan shape + // by answering each act() call in order. + actions.push(...(code.match(/"kind":"(\w+)"/g) ?? [])) + + return Promise.resolve( + JSON.stringify({ + acted: 'clicked', + elements: [{ label: 'Log out', ref: '@e1', role: 'button', selector: '#out' }], + success: true, + url: 'https://example.com/app' + }) + ) + }) + + const result = await actOnActivePreview({ kind: 'click', ref: '@e1' }) + + expect(result.elements?.[0].label).toBe('Log out') + expect(result.url).toBe('https://example.com/app') + }) + + it('does not pay the settle delay for a plain inventory', async () => { + let injected = '' + withRunner(async code => { + injected = code + + return JSON.stringify({ elements: [], success: true }) + }) + + await actOnActivePreview({ kind: 'elements' }) + + expect(injected).toContain('0 <= 0') + }) + + it('reports a page that answers with nothing', async () => { + withRunner(async () => '') + + expect((await actOnActivePreview({ kind: 'click', ref: '@e1' })).error).toContain('did not answer') + }) + + /** A pane that answers the locate trip with a fixed on-screen point, and the + * read-back trip with an empty inventory. Returns the input spy. */ + const withDrivenPane = () => { + const tabId = openBrowserTab() + const send = vi.fn() + + cleanups.push( + registerPreviewScriptRunner(tabId, async code => + code.includes('"kind":"locate"') + ? JSON.stringify({ acted: 'looking at button "Save"', point: { x: 120, y: 80 }, success: true }) + : // `hit` is the page's witness that the real pointerdown arrived. + JSON.stringify({ elements: [], hit: { tag: 'BUTTON', trusted: true }, success: true }) + ) + ) + cleanups.push(registerPreviewInput(tabId, { focus: vi.fn(), send })) + + return send + } + + const sentTypes = (send: ReturnType) => send.mock.calls.map(([event]) => event.type) + + it('clicks with real input, walking the pointer to where the page said', async () => { + const send = withDrivenPane() + + const result = await actOnActivePreview({ kind: 'click', ref: '@e1' }) + const types = sentTypes(send) + + // Stepped rather than teleported: a page learns it is hovered from a stream + // of moves, and one jump to the target skips everything in between. + expect(types.filter(type => type === 'mouseMove').length).toBeGreaterThan(1) + expect(types).toContain('mouseDown') + expect(types).toContain('mouseUp') + expect(send.mock.calls.map(([event]) => event).find(event => event.type === 'mouseDown')).toMatchObject({ + x: 120, + y: 80 + }) + expect(result.acted).toBe('clicked button "Save"') + }) + + it('types by pressing keys, after selecting whatever the field held', async () => { + const send = withDrivenPane() + + await actOnActivePreview({ kind: 'type', ref: '@e1', submit: true, text: 'hi' }) + + const events = send.mock.calls.map(([event]) => event) + const chars = events.filter(event => event.type === 'char').map(event => event.keyCode) + + // One click to focus, then select-all by keyboard — NOT the triple-click + // this used to do. A triple-click is a pointer gesture, so it grabs the + // paragraph under the cursor whenever the target turns out not to be a + // field, and the agent was leaving pages with their body text highlighted. + expect(events.filter(event => event.type === 'mouseDown').map(event => event.clickCount)).toEqual([1]) + expect(events.filter(event => event.type === 'keyDown' && event.keyCode === 'a')[0]).toMatchObject({ + modifiers: ['control', 'meta'] + }) + // The chord must not send a `char` phase, or select-all types a literal 'a'. + expect(chars).toEqual(['h', 'i', 'Enter']) + }) + + it('hovers by walking the pointer over and leaving it there', async () => { + const send = withDrivenPane() + + const result = await actOnActivePreview({ kind: 'hover', ref: '@e1' }) + const types = sentTypes(send) + + expect(types).toContain('mouseMove') + // The whole request is "be on it" — a click here would open the dropdown the + // agent was trying to reveal, or worse, activate it. + expect(types).not.toContain('mouseDown') + expect(result.acted).toBe('hovered over button "Save"') + }) + + // The witness only speaks for verbs that put the button down. Demanding one + // from a key press reported every press as a failure. + it('does not expect a click witness from a verb that never clicks', async () => { + const tabId = openBrowserTab() + + cleanups.push( + registerPreviewScriptRunner(tabId, async code => + code.includes('"kind":"locate"') + ? JSON.stringify({ acted: 'looking at textbox "Search"', point: { x: 40, y: 20 }, success: true }) + : JSON.stringify({ elements: [], hit: null, success: true }) + ) + ) + cleanups.push(registerPreviewInput(tabId, { focus: vi.fn(), send: vi.fn() })) + + for (const action of [ + { key: 'Escape', kind: 'press', ref: '@e1' }, + { kind: 'hover', ref: '@e1' } + ]) { + expect(await actOnActivePreview(action)).toMatchObject({ success: true }) + } + }) + + it('scrolls by wheeling for real, not by scripting the page', async () => { + const tabId = openBrowserTab() + const send = vi.fn() + let scripted = false + + cleanups.push( + registerPreviewScriptRunner(tabId, async code => { + scripted ||= code.includes('"kind":"scroll"') + + return code.includes('scrollHeight') + ? JSON.stringify({ page: 700, point: { x: 500, y: 400 }, span: 4_000, success: true }) + : JSON.stringify({ elements: [], success: true }) + }) + ) + cleanups.push(registerPreviewInput(tabId, { focus: vi.fn(), send })) + + await actOnActivePreview({ kind: 'scroll' }) + + const wheels = send.mock.calls.map(([event]) => event).filter(event => event.type === 'mouseWheel') + + // A stream of notches, not one delta: scroll-linked headers and lazy loaders + // only react to the events, so a scripted scrollBy leaves them asleep. + expect(wheels.length).toBeGreaterThan(1) + // Electron's wheel delta is wheelDelta-signed, so scrolling DOWN is negative. + expect(wheels.every(event => event.deltaY < 0)).toBe(true) + expect(scripted).toBe(false) + }) + + it('says so plainly when the page has nothing to scroll', async () => { + const tabId = openBrowserTab() + + cleanups.push( + registerPreviewScriptRunner(tabId, async () => + JSON.stringify({ page: 700, point: { x: 500, y: 400 }, span: 0, success: true }) + ) + ) + cleanups.push(registerPreviewInput(tabId, { focus: vi.fn(), send: vi.fn() })) + + expect(await actOnActivePreview({ kind: 'scroll' })).toMatchObject({ note: expect.stringContaining('nothing to scroll') }) + }) + + it('fails loudly when the pointer input never reaches the page', async () => { + const tabId = openBrowserTab() + + cleanups.push( + registerPreviewScriptRunner(tabId, async code => + code.includes('"kind":"locate"') + ? JSON.stringify({ acted: 'looking at button "Save"', point: { x: 12, y: 8 }, success: true }) + : JSON.stringify({ elements: [], hit: null, success: true }) + ) + ) + cleanups.push(registerPreviewInput(tabId, { focus: vi.fn(), send: vi.fn() })) + + const result = await actOnActivePreview({ kind: 'click', ref: '@e1' }) + + // Everything else about this action travels on the script channel and would + // report success whether or not a single event landed. + expect(result.success).toBe(false) + expect(result.error).toContain('never reached the page') + }) + + it('says so when the overlay itself swallowed the click', async () => { + const tabId = openBrowserTab() + + cleanups.push( + registerPreviewScriptRunner(tabId, async code => + code.includes('"kind":"locate"') + ? JSON.stringify({ acted: 'looking at button "Save"', point: { x: 12, y: 8 }, success: true }) + : JSON.stringify({ elements: [], hit: { tag: 'HERMES-WATCH', trusted: true }, success: true }) + ) + ) + cleanups.push(registerPreviewInput(tabId, { focus: vi.fn(), send: vi.fn() })) + + expect((await actOnActivePreview({ kind: 'click', ref: '@e1' })).note).toContain('overlay intercepted') + }) + + it('falls back to scripted events when the pane exposes no input channel', async () => { + let injected = '' + + withRunner(async code => { + injected = code + + return JSON.stringify({ acted: 'clicked', success: true }) + }) + + await actOnActivePreview({ kind: 'click', ref: '@e1' }) + + // The one-trip shape: the engine both acts and re-reads, no locate handshake. + expect(injected).toContain('"kind":"click"') + expect(injected).not.toContain('"kind":"locate"') + }) + + it('routes history verbs to the pane instead of the guest page', async () => { + const back = vi.fn() + const runner = vi.fn() + + const tabId = openBrowserTab() + cleanups.push(registerPreviewNav(tabId, { back, forward: vi.fn(), reload: vi.fn() })) + cleanups.push(registerPreviewScriptRunner(tabId, runner)) + + const result = await actOnActivePreview({ kind: 'back' }) + + expect(back).toHaveBeenCalledOnce() + expect(runner).not.toHaveBeenCalled() + expect(result.success).toBe(true) + expect(result.note).toContain('elements') + }) + + it('reports history verbs with no pane to drive', async () => { + expect((await actOnActivePreview({ kind: 'reload' })).error).toContain('open_preview') + }) +}) diff --git a/apps/desktop/src/app/chat/right-rail/preview-act.ts b/apps/desktop/src/app/chat/right-rail/preview-act.ts new file mode 100644 index 0000000000..ee09246643 --- /dev/null +++ b/apps/desktop/src/app/chat/right-rail/preview-act.ts @@ -0,0 +1,575 @@ +/** + * PREVIEW ACT — performs the agent's interactions inside the preview pane's + * guest page, so `drive_preview` drives whatever web app is open in the in-app + * browser. + * + * The guest page is out-of-process; nothing here can touch its DOM directly. + * Two separate channels reach it, and the split matters: + * + * - `executeJavaScript` injects the engine SOURCE (see + * lib/preview-act/act-in-page.ts's self-containment contract) to RESOLVE + * and READ — turn 'btn-sign-in' into a node, measure it, inventory the + * page. It is parked on a window global alongside the book of handles so + * they survive between calls, and it vanishes with the page, so a + * navigation retires them and the engine reports that instead of acting on + * the wrong node. + * - `sendInputEvent` (preview-drive.ts) does the ACTING, as real Chromium + * input. Script can only dispatch synthetic events, which the page can tell + * apart and which never move the browser's own hover or focus target. + * + * Panes with no input channel — a remote HTML preview — fall back to the + * engine's synthetic events, which is worse but still works. + * + * A mutating action settles briefly and returns a fresh inventory, so the + * click → re-read → click loop costs one round trip instead of two. + * + * Dynamic-imported by the gateway event handler so the engine payload stays + * out of the boot path. + */ + +import { actEngineSource, type PreviewActAction, type PreviewActResult } from '@/lib/preview-act/act-in-page' +import { watchInPage } from '@/lib/preview-act/watch-in-page' + +import { clickAt, glideTo, pointerPlaced, pressKey, selectAll, typeText, wheelBy } from './preview-drive' +import { activePreviewInput, type PreviewInputHandle } from './preview-input' +import { activePreviewNav, type PreviewNavHandle } from './preview-nav' +import { activePreviewScriptRunner, type PreviewScriptRunner } from './preview-script-runner' + +/** Verbs the pane owns; a guest page cannot drive its own history. */ +const NAV_ACTIONS: readonly (keyof PreviewNavHandle)[] = ['back', 'forward', 'reload'] + +/** How long a click/type is given to land before the page is re-inventoried. + * Long enough for a framework re-render, short enough not to stall the turn. + * A render that misses this window is not lost — the NEXT action re-reads the + * page anyway, so the cost of being early is a slightly stale inventory, not a + * wrong one. That trade is worth several hundred ms on every single step. */ +const SETTLE_MS = 220 + +/** Cap on one round trip into the guest page. A click that starts a navigation + * tears the document down mid-settle, so the injected promise dies with it and + * `executeJavaScript` never settles — without this the tool eats its whole + * 45s bridge deadline for what was actually a successful click. */ +const ACT_TIMEOUT_MS = 8_000 + +/** Verbs that get the look-then-act treatment: locate the target, walk the + * pointer there, then send real input. Actions with no single target (scroll, + * elements) are absent and skip it. */ +const DRIVEN: readonly string[] = ['click', 'hover', 'press', 'type'] + +/** Verbs that put the mouse button down, and so are the only ones the pointerdown + * witness can speak to. `press` and `hover` never click, so demanding a witness + * from them would report every one of them as a failure. */ +const CLICKS: readonly string[] = ['click', 'type'] + +const NOTHING_OPEN = 'No live page is open in the in-app browser — open one with open_preview first.' + +const NAVIGATED = 'The page stopped answering right after — it is probably navigating. Call elements to see where you landed.' + +/** A fingerprint of the overlay's source, so the guest page can tell that the + * code it is running has changed underneath it. + * + * It needs one because the overlay memoizes its own chrome: the cursor, the + * lock frame and the scan line are built ONCE per page and then reused, so an + * edit to any of their styles lands in the injected source, gets injected, and + * changes nothing — the layers it would have styled were built by the previous + * version and are still on the page. In dev that reads as "HMR didn't pick up + * my CSS"; in production it is a long-lived tab pinned to whichever build first + * touched it, across an app upgrade. + * + * Content-addressed rather than a length or a version literal: the change that + * exposed this was one hex colour for another, which is byte-for-byte the same + * length, and a hand-maintained version is a step someone forgets. */ +const WATCH_TAG = (() => { + const source = watchInPage.toString() + let hash = 5381 + + for (let i = 0; i < source.length; i++) { + hash = ((hash << 5) + hash + source.charCodeAt(i)) | 0 + } + + return hash +})() + +/** Injected once per page: the engine, the overlay, and the two helpers every + * script below shares. Idempotent — re-running it on a live page is a no-op. */ +const preamble = () => ` var w = window; + // Reassigned on every trip rather than cached behind a guard: the source is in + // this payload either way, so the guard saved nothing and pinned a long-lived + // tab to whichever build first touched it. The holder is the exception — it + // carries the aimed element from the locate trip to the act trip. + w.__hermesActHolder = w.__hermesActHolder || {}; + w.__hermesAct = ${actEngineSource()}; + w.__hermesWatch_fn = (${watchInPage.toString()}); + w.__hermesWatchTag = ${WATCH_TAG}; + var holder = w.__hermesActHolder; + var act = function (a) { return w.__hermesAct(document, holder, a); }; + var watch = function (stage, label) { + try { w.__hermesWatch_fn(document, holder, stage, label); } catch (err) {} + }; + var wait = function (ms) { return new Promise(function (resolve) { setTimeout(resolve, ms); }); }; + // Sit out a smooth scroll so the pointer is aimed at a target that has stopped + // moving. A scroll that never started fires no scrollend, so only wait on one + // we can actually see happening; capture catches nested scrollers, whose + // scrollend does not bubble. + var restAfterScroll = function () { + return new Promise(function (resolve) { + var sc = document.scrollingElement || document.documentElement; + var x0 = sc ? sc.scrollLeft : 0; + var y0 = sc ? sc.scrollTop : 0; + requestAnimationFrame(function () { + if (!sc || (sc.scrollLeft === x0 && sc.scrollTop === y0)) { return resolve(); } + var done = false; + var finish = function () { + if (done) { return; } + done = true; + document.removeEventListener('scrollend', finish, true); + clearTimeout(timer); + resolve(); + }; + var timer = setTimeout(finish, 350); + document.addEventListener('scrollend', finish, true); + }); + }); + };` + +/** Bring the target on screen, mark it, and report where it came to rest. */ +function buildLocateScript(action: PreviewActAction, focus: boolean): string { + const locate = { focus, kind: 'locate', ref: action.ref, selector: action.selector } + + return `(function () { +${preamble()} + var locate = ${JSON.stringify(locate)}; + var found = act(locate); + if (!found.success) { return Promise.resolve(JSON.stringify(found)); } + watch('aim'); + // Arm a witness for the real input that is about to arrive. Without it a + // click that never reached the page is indistinguishable from one the page + // ignored, and the agent would report success either way. + w.__hermesHit = null; + document.addEventListener('pointerdown', function (e) { + w.__hermesHit = { tag: e.target ? e.target.tagName : '?', trusted: e.isTrusted === true }; + }, { capture: true, once: true }); + // Measure again once the scroll has stopped: real input is aimed at a fixed + // viewport coordinate, so it has to be where the target ENDS UP. + return restAfterScroll().then(function () { + var settled = act(locate); + var best = settled.success ? settled : found; + var at = best.point; + // Last line of defence before the pointer is sent somewhere real. An element + // that is still outside the viewport after we scrolled to it is hidden, not + // placed, and aiming at it would drive the cursor off into a corner and + // click whatever happens to be under that coordinate. + if (at && (at.x < 0 || at.y < 0 || at.x > window.innerWidth || at.y > window.innerHeight)) { + return JSON.stringify({ + error: 'That element is still off-screen after scrolling to it, so it is hidden rather than clickable. Call elements again for what is really on the page.', + success: false + }); + } + return JSON.stringify(best); + }); +})()` +} + +/** Put up a mark that outlives the action that made it. Every other cue on the + * overlay retires on a timer, which is right for narrating a click and no use + * at all for holding a finding on screen while the agent keeps working. */ +function buildPinScript(action: PreviewActAction, label: string): string { + const locate = { kind: 'locate', ref: action.ref, selector: action.selector } + + return `(function () { +${preamble()} + var found = act(${JSON.stringify(locate)}); + if (!found.success) { return JSON.stringify(found); } + watch('pin', ${JSON.stringify(label)}); + return JSON.stringify({ acted: 'pinned ' + String(found.acted || 'it').replace(/^looking at /, ''), success: true }); +})()` +} + +/** Freeze the whole visible field as marks. Needs a fresh inventory first, so + * the overlay has both the field to draw and the refs to number it by. */ +function buildHoldScript(): string { + return `(function () { +${preamble()} + var found = act({ kind: 'elements' }); + if (!found.success) { return JSON.stringify(found); } + watch('hold'); + found.acted = 'held the field'; + return JSON.stringify(found); +})()` +} + +/** Rattle through the field one box at a time. Needs a fresh inventory for the + * overlay to pick from, and nothing else — the page is never touched. */ +function buildStrobeScript(): string { + return `(function () { +${preamble()} + var found = act({ kind: 'elements' }); + if (!found.success) { return JSON.stringify(found); } + watch('strobe'); + found.acted = 'strobed the field'; + return JSON.stringify(found); +})()` +} + +/** Take one mark down, or — with nothing to aim at — all of them. */ +function buildUnpinScript(action: PreviewActAction): string { + const locate = { kind: 'locate', ref: action.ref, selector: action.selector } + const one = !!(action.ref || action.selector) + + return `(function () { +${preamble()} +${ + one + ? ` var found = act(${JSON.stringify(locate)}); + if (!found.success) { return JSON.stringify(found); } + var gone = 'unpinned ' + String(found.acted || 'it').replace(/^looking at /, '');` + : ` holder.aimed = null; + var gone = 'cleared every pin';` +} + watch('unpin'); + return JSON.stringify({ acted: gone, success: true }); +})()` +} + +/** Mark the hit, let the page react, and hand back a fresh inventory. */ +function buildFinishScript(settleMs: number): string { + return `(function () { +${preamble()} + watch('strike'); + return wait(${settleMs}).then(function () { + // A rescan that throws must still answer — an unresolved promise here costs + // the whole bridge deadline. + try { + var out = act({ kind: 'elements' }); + watch('sweep'); + out.hit = w.__hermesHit || null; + return JSON.stringify(out); + } catch (err) { + return JSON.stringify({ note: 'The page changed before it could be re-read: ' + err, success: true }); + } + }); +})()` +} + +/** The no-real-input fallback: the engine both acts and reads, in one trip. + * Both reads mark the field — the explicit `elements` call and the re-read + * every other verb does on its way out — so the page is marked after every + * single action rather than only when the agent asks for an inventory outright. + * They mark it differently, though, and the difference is what each one MEANS: + * an outright `elements` is the agent reading the whole page, which is the + * moment worth showing, so it strobes. The re-read after a click is + * housekeeping, and strobing there would put five seconds of noise between + * every step of a task, so it sweeps. The two never collide: an `elements` call + * settles in 0ms and returns before the re-read. */ +function buildScriptedScript(action: PreviewActAction, settleMs: number): string { + return `(function () { +${preamble()} + var result = act(${JSON.stringify(action)}); + ${ + action.kind === 'elements' + ? "if (result.success) { watch('strobe'); }" + : '' + } + if (!result.success || ${settleMs} <= 0) { return Promise.resolve(JSON.stringify(result)); } + return wait(${settleMs}).then(function () { + try { + var after = act({ kind: 'elements' }); + watch('sweep'); + // One or the other, never both: a re-read answers with the whole + // inventory only when it is the first look at this page. + result.elements = after.elements; + result.delta = after.delta; + result.url = after.url; + result.title = after.title; + } catch (err) { + result.note = 'The page changed before it could be re-read: ' + err; + } + return JSON.stringify(result); + }); +})()` +} + +/** The outcome of one round trip into the page. `silent` is its own case on + * purpose: a page that stops answering mid-action is navigating, whereas one + * that answers with nothing is broken, and the agent needs to hear the + * difference. */ +type Trip = { error: string; kind: 'failed' } | { kind: 'answered'; result: PreviewActResult } | { kind: 'silent' } + +async function runJson(run: PreviewScriptRunner, code: string): Promise { + const raw = await Promise.race([ + run(code).catch((error: unknown) => new Error(String(error))), + new Promise(resolve => setTimeout(resolve, ACT_TIMEOUT_MS)) + ]) + + if (raw === undefined) { + return { kind: 'silent' } + } + + if (raw instanceof Error) { + return { error: 'The page rejected the action: ' + raw.message, kind: 'failed' } + } + + if (typeof raw !== 'string' || !raw) { + return { error: 'The page did not answer the action.', kind: 'failed' } + } + + return { kind: 'answered', result: JSON.parse(raw) as PreviewActResult } +} + +/** Past tense of the verb the agent asked for, against what it actually hit. */ +function describeDone(action: PreviewActAction, target: string): string { + if (action.kind === 'type') { + return 'typed into ' + target + (action.submit ? ' and submitted' : '') + } + + if (action.kind === 'press') { + return 'pressed ' + (action.key || '') + ' on ' + target + } + + if (action.kind === 'hover') { + return 'hovered over ' + target + } + + return 'clicked ' + target +} + +/** Look at the target, walk the pointer over, and act on it for real. */ +async function driveAction( + run: PreviewScriptRunner, + input: PreviewInputHandle, + action: PreviewActAction +): Promise { + // A key press must not be preceded by a click — that would activate the + // control rather than type into it — so the page hands it focus instead. + const trip = await runJson(run, buildLocateScript(action, action.kind === 'press')) + + if (trip.kind === 'failed') { + return { error: trip.error, success: false } + } + + if (trip.kind === 'silent') { + return { acted: action.kind, note: NAVIGATED, success: true } + } + + const found = trip.result + + if (!found.success) { + return found + } + + if (!found.point) { + return { error: 'Could not work out where that element is on screen.', success: false } + } + + await glideTo(input, found.point) + + if (action.kind === 'click') { + await clickAt(input) + } else if (action.kind === 'type') { + if (found.typable === false) { + return { + error: `${String(found.acted || 'That').replace(/^looking at /, '')} is not a text field, so typing into it would only select the text under the pointer. Click it if it opens one, then type into that.`, + success: false + } + } + + input.focus() + await clickAt(input) + // Select-all inside the now-focused field, so typing replaces what is there + // the way it would for a person. NOT a triple-click: that is a pointer + // gesture and selects the paragraph under the cursor whenever the target + // turns out not to be a field. + await selectAll(input) + await typeText(input, action.text ?? '') + + if (action.submit) { + await pressKey(input, 'Enter') + } + } else if (action.kind === 'press') { + input.focus() + await pressKey(input, action.key || 'Enter') + } + // hover is the glide and nothing else — the pointer is already sitting on the + // target, which is the whole request. + + const target = String(found.acted || '').replace(/^looking at /, '') + const after = await runJson(run, buildFinishScript(SETTLE_MS)) + const acted = describeDone(action, target) + + // The action itself already happened as real input, so a page that will not + // answer the read-back is a page that navigated — never a failed click. + if (after.kind !== 'answered') { + return { acted, note: NAVIGATED, success: true } + } + + const { hit, ...result } = after.result as PreviewActResult & { hit?: { tag: string; trusted: boolean } | null } + + // The witness the locate trip armed. No record means the input never reached + // the document, which the agent must hear about — every other signal here + // travels on the script channel and would report success regardless. + if (!hit && CLICKS.indexOf(action.kind) !== -1) { + return { + ...result, + error: 'The pointer input never reached the page, so nothing was ' + acted.split(' ')[0] + '.', + success: false + } + } + + return { ...result, acted, note: hitNote(hit), success: true } +} + +/** Flag a click the overlay intercepted, which would otherwise look like a page + * that simply ignored it. */ +function hitNote(hit?: { tag: string; trusted: boolean } | null): string | undefined { + return hit && hit.tag === 'HERMES-WATCH' + ? 'The action overlay intercepted the click instead of the page.' + : undefined +} + +/** How far a screenful is, whether there is anywhere to go, and a spot to wheel + * over — the renderer cannot see the guest viewport to work any of it out. */ +function buildScrollAnchorScript(): string { + return `(function () { +${preamble()} + var sc = document.scrollingElement || document.documentElement; + var track = sc.clientHeight || window.innerHeight; + return Promise.resolve(JSON.stringify({ + page: Math.round(window.innerHeight * 0.9), + point: { x: Math.round(window.innerWidth / 2), y: Math.round(track / 2) }, + span: sc.scrollHeight - track, + success: true + })); +})()` +} + +/** Scroll the page the way a hand does — wheel it. The scripted path calls + * `scrollBy`, which moves the page without the page ever seeing an input + * event, so scroll-linked headers, reveal animations and infinite-scroll + * loaders all stay asleep through a scroll that looked like it happened. */ +async function driveScroll( + run: PreviewScriptRunner, + input: PreviewInputHandle, + action: PreviewActAction, +): Promise { + const far = action.amount ?? 0 + + const trip = await runJson( + run, + buildScrollAnchorScript() + ) + + if (trip.kind === 'failed') { + return { error: trip.error, success: false } + } + + if (trip.kind === 'silent') { + return { acted: 'scrolled', note: NAVIGATED, success: true } + } + + const anchor = trip.result as PreviewActResult & { page?: number; span?: number } + + if (!anchor.span) { + return { ...anchor, acted: 'scrolled the page', note: 'The page has nothing to scroll — it all fits already.' } + } + + // A person does not move the mouse to scroll; the wheel turns wherever their + // hand already is. Only send it somewhere if it has never been anywhere. + if (!pointerPlaced() && anchor.point) { + await glideTo(input, anchor.point) + } + + await wheelBy(input, action.amount ?? anchor.page ?? 600) + + const after = await runJson(run, buildFinishScript(SETTLE_MS)) + + if (after.kind !== 'answered') { + return { acted: 'scrolled the page', note: NAVIGATED, success: true } + } + + return { ...after.result, acted: 'scrolled the page', success: true } +} + +/** Run one action against the ACTIVE preview tab's page. `kind` is a bare + * string: the verb arrives off the wire, and the history ones never reach + * the in-page engine. */ +export async function actOnActivePreview( + action: Omit & { kind: string } +): Promise { + const nav = NAV_ACTIONS.find(verb => verb === action.kind) + + if (nav) { + const handle = activePreviewNav() + + if (!handle) { + return { error: NOTHING_OPEN, success: false } + } + + handle[nav]() + + // Navigation is fire-and-forget through the webview; the new document has + // its own refs, so the agent has to re-inventory either way. + return { acted: nav, note: 'Page is loading — call elements to see what is on it.', success: true } + } + + const run = activePreviewScriptRunner() + + if (!run) { + return { error: NOTHING_OPEN, success: false } + } + + const typed = action as PreviewActAction + + // Annotation, not interaction: nothing is clicked, nothing settles, and the + // page is not re-read, so these skip the whole act-then-inventory path. + if (typed.kind === 'pin' || typed.kind === 'unpin' || typed.kind === 'hold' || typed.kind === 'strobe') { + const mark = + typed.kind === 'hold' + ? buildHoldScript() + : typed.kind === 'strobe' + ? buildStrobeScript() + : typed.kind === 'pin' + ? buildPinScript(typed, typed.text || '') + : buildUnpinScript(typed) + + const trip = await runJson(run, mark) + + if (trip.kind === 'failed') { + return { error: trip.error, success: false } + } + + return trip.kind === 'answered' ? trip.result : { acted: typed.kind, note: NAVIGATED, success: true } + } + + const input = activePreviewInput() + + if (input && DRIVEN.indexOf(typed.kind) !== -1) { + return driveAction(run, input, typed) + } + + // A plain page scroll is a wheel gesture. Jumping to an end is not — no hand + // wheels to the bottom of a long article — so `to` stays a scripted glide. + const plain = typed.kind === 'scroll' && !typed.to && !typed.ref && !typed.selector + + if (plain && input) { + return driveScroll(run, input, typed) + } + + const settle = typed.kind === 'elements' ? 0 : SETTLE_MS + const scripted = await runJson(run, buildScriptedScript(typed, settle)) + + if (scripted.kind === 'failed') { + return { error: scripted.error, success: false } + } + + // The action almost certainly landed — a page that stops answering right + // after a click is one that navigated. Say so instead of failing it. + return scripted.kind === 'silent' ? { acted: typed.kind, note: NAVIGATED, success: true } : scripted.result +} + +// Self-accept so an edit here, or to the in-page sources this module +// stringifies, doesn't reload the whole renderer out from under a live session. +// The bridge re-requests this module per action in dev, so it picks the change +// up without one (see loadPreviewEngine in gateway-event/desktop-bridge.ts). +if (import.meta.hot) { + import.meta.hot.accept() +} diff --git a/apps/desktop/src/app/chat/right-rail/preview-browser-bar.test.tsx b/apps/desktop/src/app/chat/right-rail/preview-browser-bar.test.tsx index e35ed0936f..64a8cb578b 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-browser-bar.test.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-browser-bar.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { normalizePreviewAddress, PreviewBrowserBar } from './preview-browser-bar' @@ -12,6 +12,7 @@ const baseProps = { onBack: vi.fn(), onForward: vi.fn(), onNavigate: vi.fn(), + onOpenExternal: vi.fn(), onReload: vi.fn(), onToggleConsole: vi.fn(), onToggleDevTools: vi.fn(), @@ -22,9 +23,16 @@ function address(rendered: ReturnType) { return rendered.getByRole('textbox', { name: 'Address' }) as HTMLInputElement } +const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] } + +function installBridge(writeClipboard: ReturnType) { + desktopWindow.hermesDesktop = { writeClipboard } as unknown as Window['hermesDesktop'] +} + afterEach(() => { cleanup() vi.clearAllMocks() + delete desktopWindow.hermesDesktop }) describe('normalizePreviewAddress', () => { @@ -77,6 +85,8 @@ describe('PreviewBrowserBar', () => { expect(rendered.getByRole('button', { name: 'Back' })).toBeTruthy() expect(rendered.getByRole('button', { name: 'Forward' })).toBeTruthy() expect(rendered.getByRole('button', { name: 'Reload page' })).toBeTruthy() + expect(rendered.getByRole('button', { name: 'Copy URL' })).toBeTruthy() + expect(rendered.getByRole('button', { name: 'Open in browser' })).toBeTruthy() expect(rendered.getByRole('button', { name: 'Show preview console' })).toBeTruthy() expect(rendered.getByRole('button', { name: 'Open preview DevTools' })).toBeTruthy() expect(address(rendered)).toBeTruthy() @@ -99,7 +109,8 @@ describe('PreviewBrowserBar', () => { it.each([ ['Back', 'onBack'], ['Forward', 'onForward'], - ['Reload page', 'onReload'] + ['Reload page', 'onReload'], + ['Open in browser', 'onOpenExternal'] ] as const)('fires %s', (label, handler) => { const spy = vi.fn() const rendered = render() @@ -227,4 +238,39 @@ describe('PreviewBrowserBar', () => { expect(container.querySelector('.codicon-refresh')?.className).not.toContain('codicon-modifier-spin') }) + + it('copies the live address from the copy-URL button', async () => { + const writeClipboard = vi.fn().mockResolvedValue(undefined) + + installBridge(writeClipboard) + render() + + fireEvent.click(screen.getByRole('button', { name: 'Copy URL' })) + + await waitFor(() => expect(writeClipboard).toHaveBeenCalledWith('https://example.com')) + }) + + it('copies the new address after the page navigates', async () => { + const writeClipboard = vi.fn().mockResolvedValue(undefined) + + installBridge(writeClipboard) + const rendered = render() + + rendered.rerender() + fireEvent.click(screen.getByRole('button', { name: 'Copy URL' })) + + await waitFor(() => expect(writeClipboard).toHaveBeenCalledWith('https://example.com/next')) + }) + + it('renders the copy control inside the address field wrapper, not as a bar glyph', () => { + render() + + const copyButton = screen.getByRole('button', { name: 'Copy URL' }) + const address = screen.getByRole('textbox', { name: 'Address' }) + + // Same positioned wrapper as the field = visually inside it, like the + // code-block copy icon (inline appearance, overlay on the field's edge). + expect(copyButton.parentElement?.contains(address)).toBe(true) + expect(copyButton.className).toContain('absolute') + }) }) diff --git a/apps/desktop/src/app/chat/right-rail/preview-browser-bar.tsx b/apps/desktop/src/app/chat/right-rail/preview-browser-bar.tsx index da83371719..f5dccb9374 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-browser-bar.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-browser-bar.tsx @@ -1,5 +1,6 @@ /** - * BROWSER BAR — back / forward / reload / address for a URL preview. + * BROWSER BAR: back / forward / reload / address / open-in-browser for a URL + * preview. * * The Browser tab had no way to move: no history, and the only address on * screen was a read-only label. Every other embedded browser (VS Code's Simple @@ -16,6 +17,7 @@ import { useState } from 'react' import { Codicon } from '@/components/ui/codicon' +import { CopyButton } from '@/components/ui/copy-button' import { Input } from '@/components/ui/input' import { PaneStripGlyph } from '@/components/ui/pane-tab' import { useI18n } from '@/i18n' @@ -29,6 +31,7 @@ interface PreviewBrowserBarProps { onBack: () => void onForward: () => void onNavigate: (url: string) => void + onOpenExternal: () => void onReload: () => void onToggleConsole: () => void onToggleDevTools: () => void @@ -91,6 +94,7 @@ export function PreviewBrowserBar({ onBack, onForward, onNavigate, + onOpenExternal, onReload, onToggleConsole, onToggleDevTools, @@ -135,31 +139,51 @@ export function PreviewBrowserBar({ label={copy.reload} onSelect={onReload} /> - setDraft(null)} - onChange={event => setDraft(event.target.value)} - onFocus={event => { - setDraft(url) - event.currentTarget.select() - }} - onKeyDown={event => { - if (event.key === 'Enter') { - commit(event.currentTarget.value) - event.currentTarget.blur() - } + {/* The copy control lives INSIDE the field, on its right edge — the + same pre-faded inline icon code blocks use, not a toolbar button. + It copies what the field shows: on a remote gateway, that is the + reach-resolved address. */} +
+ setDraft(null)} + onChange={event => setDraft(event.target.value)} + onFocus={event => { + setDraft(url) + event.currentTarget.select() + }} + onKeyDown={event => { + if (event.key === 'Enter') { + commit(event.currentTarget.value) + event.currentTarget.blur() + } - if (event.key === 'Escape') { - setDraft(null) - event.currentTarget.blur() - } - }} - placeholder={copy.addressPlaceholder} - size="xs" - spellCheck={false} - value={draft ?? url} + if (event.key === 'Escape') { + setDraft(null) + event.currentTarget.blur() + } + }} + placeholder={copy.addressPlaceholder} + size="xs" + spellCheck={false} + value={draft ?? url} + /> + +
+ } + label={t.preview.openInBrowser} + onSelect={onOpenExternal} /> new Promise(resolve => setTimeout(resolve, ms)) + +/** Decelerating, like a hand arriving at a target rather than a linear sweep. */ +const easeOut = (t: number) => 1 - Math.pow(1 - t, 3) + +/** Walk the pointer to `to`, letting the page hover everything on the way. */ +export async function glideTo(input: PreviewInputHandle, to: DrivePoint): Promise { + const from = pointer + + for (let step = 1; step <= GLIDE_STEPS; step++) { + const progress = easeOut(step / GLIDE_STEPS) + + input.send({ + type: 'mouseMove', + x: Math.round(from.x + (to.x - from.x) * progress), + y: Math.round(from.y + (to.y - from.y) * progress) + }) + await wait(GLIDE_MS / GLIDE_STEPS) + } + + pointer = to + placed = true +} + +/** Press and release at the pointer's current spot. `clicks` of 3 selects the + * text under it, which is how a field gets cleared without a modifier key. */ +export async function clickAt(input: PreviewInputHandle, clicks = 1): Promise { + for (let click = 1; click <= clicks; click++) { + input.send({ button: 'left', clickCount: click, type: 'mouseDown', x: pointer.x, y: pointer.y }) + input.send({ button: 'left', clickCount: click, type: 'mouseUp', x: pointer.x, y: pointer.y }) + await wait(KEY_MS) + } +} + +/** Wheel `down` pixels' worth of notches at the pointer's current spot; negative + * scrolls up. Electron's wheel delta is the legacy `wheelDelta` sign — positive + * moves the content down, i.e. scrolls UP — so it is the inverse of the number + * a caller asks for, and of DOM `WheelEvent.deltaY`. */ +export async function wheelBy(input: PreviewInputHandle, down: number): Promise { + let sent = 0 + + for (let step = 1; step <= WHEEL_STEPS; step++) { + const so_far = Math.round(down * easeOut(step / WHEEL_STEPS)) + const notch = so_far - sent + + sent = so_far + + if (notch) { + input.send({ deltaX: 0, deltaY: -notch, type: 'mouseWheel', x: pointer.x, y: pointer.y }) + } + + await wait(WHEEL_MS / WHEEL_STEPS) + } +} + +/** Send one key the long way round, so a page watching any of the three sees it. */ +export async function pressKey(input: PreviewInputHandle, key: string): Promise { + input.send({ keyCode: key, type: 'keyDown' }) + input.send({ keyCode: key, type: 'char' }) + input.send({ keyCode: key, type: 'keyUp' }) + await wait(KEY_MS) +} + +/** Select-all inside whatever has focus, which for a focused field is that + * field's own text and nothing else. This replaced a triple-click: a triple + * click is a POINTER gesture, so it selects whatever paragraph sits under the + * cursor whenever the target turns out not to be a field, and the agent was + * leaving pages with their body text highlighted. There is no `char` phase — + * a chord is not text entry, and sending one types a literal 'a'. */ +export async function selectAll(input: PreviewInputHandle): Promise { + const chord = ['control', 'meta'] + + input.send({ keyCode: 'a', modifiers: chord, type: 'keyDown' }) + input.send({ keyCode: 'a', modifiers: chord, type: 'keyUp' }) + await wait(KEY_MS) +} + +/** Type `text` a character at a time into whatever currently has focus. */ +export async function typeText(input: PreviewInputHandle, text: string): Promise { + for (const character of text) { + await pressKey(input, character) + } +} diff --git a/apps/desktop/src/app/chat/right-rail/preview-input.ts b/apps/desktop/src/app/chat/right-rail/preview-input.ts new file mode 100644 index 0000000000..c0f1dbb5a0 --- /dev/null +++ b/apps/desktop/src/app/chat/right-rail/preview-input.ts @@ -0,0 +1,56 @@ +/** + * PREVIEW INPUT REGISTRY — real input into the preview pane's guest page, the + * difference between the agent DRIVING the browser and merely poking its DOM. + * + * `executeJavaScript` can only ever dispatch synthetic events: `isTrusted` is + * false, the browser's own hover target never moves, `:hover` rules never + * match, and hover-gated menus never open — so a click lands on a dropdown item + * that was never rendered. `sendInputEvent` goes in through Chromium's input + * pipeline instead, producing the same events a hand on the mouse would. + * + * It has to be called on the `` ELEMENT. Sending to the embedder's + * webContents does not reach a guest (electron/electron#20333), which is why + * this is a per-pane registry rather than something main could do. + * + * Coordinates are relative to the webview, and the webview IS the guest + * viewport — so a rect the act engine measured inside the page needs no + * conversion on the way back out. + */ + +import { $rightRailActiveTabId } from '@/store/layout' +import { $previewTabs } from '@/store/preview' + +/** The subset of Electron's input events the agent needs to drive a page. */ +export type PreviewInputEvent = + | { button: 'left'; clickCount: number; type: 'mouseDown' | 'mouseUp'; x: number; y: number } + | { deltaX: number; deltaY: number; type: 'mouseWheel'; x: number; y: number } + | { keyCode: string; modifiers?: string[]; type: 'char' | 'keyDown' | 'keyUp' } + | { type: 'mouseMove'; x: number; y: number } + +export interface PreviewInputHandle { + /** Give the guest keyboard focus, so key events reach its active element. */ + focus: () => void + send: (event: PreviewInputEvent) => void +} + +const handles = new Map() + +/** Register a live pane's input channel; returns an idempotent unregister. */ +export function registerPreviewInput(tabId: string, handle: PreviewInputHandle): () => void { + handles.set(tabId, handle) + + return () => { + if (handles.get(tabId) === handle) { + handles.delete(tabId) + } + } +} + +/** The ACTIVE preview tab's input channel. Null = nothing real to drive, and + * the caller falls back to synthesizing events inside the page. */ +export function activePreviewInput(): PreviewInputHandle | null { + const tabs = $previewTabs.get() + const tab = tabs.find(t => t.id === $rightRailActiveTabId.get()) ?? tabs[0] + + return (tab && handles.get(tab.id)) || null +} diff --git a/apps/desktop/src/app/chat/right-rail/preview-mind.ts b/apps/desktop/src/app/chat/right-rail/preview-mind.ts new file mode 100644 index 0000000000..3f7a1856c4 --- /dev/null +++ b/apps/desktop/src/app/chat/right-rail/preview-mind.ts @@ -0,0 +1,29 @@ +/** + * IDLE PULSE — keeps the preview overlay alive while the model reasons. + * + * Everything else the overlay draws is narration of an action, so the pane goes + * dark for the gap between actions — which is most of the time the agent spends + * on a page, and the part where it is deciding what to do with what it just + * read. A page that stops responding the moment the agent is thinking hardest + * reads as the agent having wandered off. + * + * This is deliberately NOT part of the act pipeline: a turn boundary only has + * to poke the overlay the last action left behind. See preview-nudge.ts. + */ + +import { $busy } from '@/store/session' + +import { nudgeOverlay } from './preview-nudge' + +// Module-level, matching how review.ts and coding-status.ts watch this edge. It +// is inert without a live pane, so there is nothing to mount or tear down. +let running = $busy.get() + +$busy.subscribe(busy => { + if (busy === running) { + return + } + + running = busy + nudgeOverlay(busy ? 'think' : 'rest') +}) diff --git a/apps/desktop/src/app/chat/right-rail/preview-nav.ts b/apps/desktop/src/app/chat/right-rail/preview-nav.ts index 954c1a5e9b..5e03db3005 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-nav.ts +++ b/apps/desktop/src/app/chat/right-rail/preview-nav.ts @@ -9,6 +9,9 @@ * sitting in Hermes' own DOM, where `activeElement` is authoritative. */ +import { $rightRailActiveTabId } from '@/store/layout' +import { $previewTabs } from '@/store/preview' + /** Marks a live browser pane so a gesture can find the one holding focus. */ export const PREVIEW_BROWSER_ATTR = 'data-preview-browser' @@ -31,6 +34,15 @@ export function registerPreviewNav(tabId: string, handle: PreviewNavHandle): () } } +/** The ACTIVE preview tab's commands, for callers with no focus to key off — + * the agent's drive_preview, which runs while focus is in the composer. */ +export function activePreviewNav(): PreviewNavHandle | null { + const tabs = $previewTabs.get() + const tab = tabs.find(t => t.id === $rightRailActiveTabId.get()) ?? tabs[0] + + return (tab && handles.get(tab.id)) || null +} + /** Run `command` on the browser pane holding DOM focus. False = focus is * elsewhere in the app, so the caller falls back to the app-level meaning. */ export function commandFocusedPreview(command: keyof PreviewNavHandle): boolean { diff --git a/apps/desktop/src/app/chat/right-rail/preview-nudge.ts b/apps/desktop/src/app/chat/right-rail/preview-nudge.ts new file mode 100644 index 0000000000..8a8f419987 --- /dev/null +++ b/apps/desktop/src/app/chat/right-rail/preview-nudge.ts @@ -0,0 +1,38 @@ +/** + * OVERLAY NUDGE — the one way to say a stage to an overlay that is ALREADY on + * the page. + * + * The act pipeline (preview-act.ts) injects the whole engine because it has to: + * it needs to resolve a ref, measure a node and read the page back. The stages + * that only narrate — the idle pulse, the read wipe — need none of that. They + * are one function call against something the last action already left on the + * page, so re-shipping the engine to make it would put the payload on the wire + * on every turn boundary. + * + * A page the agent has never acted on has no overlay, and the nudge is a no-op + * there. That is the right answer rather than a gap: chrome on a page the agent + * never touched would be a lie about what it did. + */ + +import type { WatchStage } from '@/lib/preview-act/watch-in-page' + +import { activePreviewScriptRunner } from './preview-script-runner' + +/** Run one stage against the active pane's overlay, if it has one. */ +export function nudgeOverlay(stage: WatchStage): void { + const run = activePreviewScriptRunner() + + if (!run) { + return + } + + void run(`(function () { + var w = window; + var fn = w.__hermesWatch_fn; + if (!fn) { return 'cold'; } + try { fn(document, w.__hermesActHolder || {}, ${JSON.stringify(stage)}); } catch (err) {} + return 'ok'; +})()`).catch(() => { + // The page navigated out from under us. The next action re-injects. + }) +} diff --git a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx index 11cfc29340..817cec638a 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx @@ -1,7 +1,12 @@ +// Side-effect import: watches the turn edge so the overlay keeps a pulse while +// the model reasons. Lives here because the pane is what makes it reachable. +import './preview-mind' + import { useStore } from '@nanostores/react' import type { PointerEvent as ReactPointerEvent } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { openGuestContextMenu } from '@/app/context-menu/store' import { PanelEmpty } from '@/app/overlays/panel' import { Tip } from '@/components/ui/tooltip' import { type Translations, useI18n } from '@/i18n' @@ -27,23 +32,52 @@ import { import { type ConsoleEntry } from './preview-console-state' import { previewConsoleState } from './preview-console-store' import { LocalFilePreview, PreviewEmptyState } from './preview-file' +import { type PreviewInputEvent, registerPreviewInput } from './preview-input' import { PREVIEW_BROWSER_ATTR, registerPreviewNav } from './preview-nav' import { registerPreviewPageReader } from './preview-reader' +import { registerPreviewScriptRunner } from './preview-script-runner' type PreviewWebview = HTMLElement & { canGoBack?: () => boolean canGoForward?: () => boolean closeDevTools?: () => void + copy?: () => void + cut?: () => void executeJavaScript?: (code: string) => Promise getTitle?: () => string getURL?: () => string + getWebContentsId?: () => number goBack?: () => void goForward?: () => void + inspectElement?: (x: number, y: number) => void isDevToolsOpened?: () => boolean loadURL?: (url: string) => Promise openDevTools?: () => void + paste?: () => void reload?: () => void reloadIgnoringCache?: () => void + replaceMisspelling?: (word: string) => void + selectAll?: () => void + sendInputEvent?: (event: PreviewInputEvent) => void +} + +/** The raw Chromium params riding the webview tag's `context-menu` event. */ +interface GuestContextMenuParams { + dictionarySuggestions?: string[] + editFlags?: { + canCopy?: boolean + canCut?: boolean + canPaste?: boolean + canSelectAll?: boolean + } + hasImageContents?: boolean + isEditable?: boolean + linkURL?: string + misspelledWord?: string + selectionText?: string + srcURL?: string + x: number + y: number } interface PreviewPaneProps { @@ -427,6 +461,51 @@ export function PreviewPane({ embedded = false, onRestartServer, reloadRequest = }) }, [isWebPreview, tabId]) + // Publish the SCRIPT runner for this tab: the one channel into the guest + // page, shared by the tour tool (injected driver.js walkthroughs) and the + // drive_preview tool (clicking, typing, scrolling the page the user sees). + useEffect(() => { + if (!isWebPreview || !tabId) { + return + } + + return registerPreviewScriptRunner(tabId, async code => { + const webview = webviewRef.current + + if (!webview?.executeJavaScript) { + throw new Error('preview webview is not ready') + } + + return webview.executeJavaScript(code) + }) + }, [isWebPreview, tabId]) + + // Publish the INPUT channel for this tab. Same idea as the script runner, but + // it carries real Chromium input rather than script — the agent's clicks and + // keystrokes arrive as trusted events, so the page hovers, focuses and reacts + // exactly as it would under a human hand. + useEffect(() => { + if (!isWebPreview || isRemoteHtml || !tabId) { + return + } + + return registerPreviewInput(tabId, { + focus: () => webviewRef.current?.focus?.(), + send: event => { + const webview = webviewRef.current + + // Never optional-chain this call away: a missing method would make every + // agent click a silent no-op that still reports success, because the + // overlay and the read-back both run on the separate script channel. + if (typeof webview?.sendInputEvent !== 'function') { + throw new Error('preview webview cannot take input events') + } + + webview.sendInputEvent(event) + } + }) + }, [isRemoteHtml, isWebPreview, tabId]) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (!consoleOpen) { @@ -723,7 +802,82 @@ export function PreviewPane({ embedded = false, onRestartServer, reloadRequest = const onDevToolsOpened = () => setDevtoolsOpen(true) const onDevToolsClosed = () => setDevtoolsOpen(false) + // Right-clicks INSIDE the guest page. The tag surfaces Chromium's full + // context-menu params (link, image, editable, selection, spellcheck), so + // the app coordinator renders the same translated menu it shows + // everywhere else. + // + // Coordinates: params.x/y are WINDOW-relative device-independent pixels + // — the guest offset is already included, and CSS values are multiplied + // by the window zoom factor. Measured live (zoom 0.9): a click whose + // true window CSS point was (901, 272) arrived as params (811, 246) = + // (901*0.9, 272*0.9). Dividing by the zoom factor recovers CSS + // coordinates; adding the webview rect on top double-counted the offset + // and dropped the menu far right+below the click. + const onGuestContextMenu = (event: Event) => { + const detail = event as Event & { params?: GuestContextMenuParams } + const params = detail.params + + if (!params) { + return + } + + const zoom = window.hermesDesktop?.zoom?.factor?.() || 1 + // Window CSS point of the click (the menu anchors here). + const windowX = params.x / zoom + const windowY = params.y / zoom + // Guest CSS point (inspectElement wants coordinates INSIDE the page): + // subtract the webview's own offset from the window point. + const rect = webview.getBoundingClientRect() + const guestX = Math.max(0, Math.round(windowX - rect.left)) + const guestY = Math.max(0, Math.round(windowY - rect.top)) + + openGuestContextMenu( + windowX, + windowY, + { + dictionarySuggestions: Array.isArray(params.dictionarySuggestions) ? params.dictionarySuggestions : [], + // Chromium's availability verdict for the edit verbs. Absent only + // if a future Electron drops it — then everything stays enabled, + // which is the pre-editFlags behavior, not a lockout. + editFlags: { + canCopy: params.editFlags?.canCopy ?? true, + canCut: params.editFlags?.canCut ?? true, + canPaste: params.editFlags?.canPaste ?? true, + canSelectAll: params.editFlags?.canSelectAll ?? true + }, + hasImageContents: Boolean(params.hasImageContents), + isEditable: Boolean(params.isEditable), + linkURL: params.linkURL || '', + misspelledWord: params.misspelledWord || '', + selectionText: params.selectionText || '', + srcURL: params.srcURL || '' + }, + { + addToDictionary: (word: string) => { + const webContentsId = webview.getWebContentsId?.() + + if (typeof webContentsId === 'number') { + void window.hermesDesktop?.contextMenuGuestAddWord?.({ webContentsId, word }) + } + }, + copyImage: () => void window.hermesDesktop?.contextMenuCopyImage?.(), + // The tag's edit commands act on the focused webContents, and the + // menu click just parked focus on the HOST body — measured live: + // selectAll() with host focus selected the address bar + chat + // instead of the page. Focus the webview first, every verb. + editCommand: (command: 'copy' | 'cut' | 'paste' | 'selectAll') => { + webview.focus() + webview[command]?.() + }, + inspectElement: () => webview.inspectElement?.(guestX, guestY), + replaceMisspelling: (word: string) => webview.replaceMisspelling?.(word) + } + ) + } + webview.addEventListener('console-message', onConsole) + webview.addEventListener('context-menu', onGuestContextMenu) webview.addEventListener('devtools-closed', onDevToolsClosed) webview.addEventListener('devtools-opened', onDevToolsOpened) webview.addEventListener('did-fail-load', onFail) @@ -736,6 +890,7 @@ export function PreviewPane({ embedded = false, onRestartServer, reloadRequest = return () => { webview.removeEventListener('console-message', onConsole) + webview.removeEventListener('context-menu', onGuestContextMenu) webview.removeEventListener('devtools-closed', onDevToolsClosed) webview.removeEventListener('devtools-opened', onDevToolsOpened) webview.removeEventListener('did-fail-load', onFail) @@ -803,6 +958,7 @@ export function PreviewPane({ embedded = false, onRestartServer, reloadRequest = onBack={goBack} onForward={goForward} onNavigate={navigateTo} + onOpenExternal={() => void window.hermesDesktop?.openExternal(currentUrl)} onReload={reloadPreview} onToggleConsole={() => consoleState.setOpen(open => !open)} onToggleDevTools={toggleDevTools} diff --git a/apps/desktop/src/app/chat/right-rail/preview-reader.ts b/apps/desktop/src/app/chat/right-rail/preview-reader.ts index 7f48c8cbb1..97a372db18 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-reader.ts +++ b/apps/desktop/src/app/chat/right-rail/preview-reader.ts @@ -15,6 +15,8 @@ import { $rightRailActiveTabId } from '@/store/layout' import { $previewTabs } from '@/store/preview' +import { nudgeOverlay } from './preview-nudge' + export interface PreviewReadOptions { /** Characters to return from `start` (capped at PREVIEW_READ_MAX_CHARS). */ count?: number @@ -89,6 +91,13 @@ export async function readActivePreview(opts: PreviewReadOptions = {}): Promise< try { const page = await reader() + // Say it on the page. Reading is by far the cheapest thing the agent + // does — a few hundredths of a second against a model round trip either + // side of it — so a run of reads used to leave the pane dark for the + // twenty seconds it took to page through a document, immediately after + // the one moment that showed anything. + nudgeOverlay('read') + return windowText( { kind: target.kind, path: target.path, title: page.title || target.label, url: page.url || target.url }, page.text, diff --git a/apps/desktop/src/app/chat/right-rail/preview-script-runner.ts b/apps/desktop/src/app/chat/right-rail/preview-script-runner.ts new file mode 100644 index 0000000000..69a0efa2a4 --- /dev/null +++ b/apps/desktop/src/app/chat/right-rail/preview-script-runner.ts @@ -0,0 +1,38 @@ +/** + * PREVIEW SCRIPT RUNNER REGISTRY — the one way anything in the app reaches into + * the preview pane's guest page, the script analog of preview-nav's handle + * registry. + * + * A live browser pane registers its webview's `executeJavaScript` here, keyed + * by tab id; `activePreviewScriptRunner` resolves the ACTIVE tab from the + * store. Both guest-page features ride it — the tour tool (preview-tour.ts) + * and the interaction tool (preview-act.ts) — so their heavy payloads stay out + * of the pane component's static import graph and only load when used. + */ + +import { $rightRailActiveTabId } from '@/store/layout' +import { $previewTabs } from '@/store/preview' + +/** Runs JS source in the pane's guest page, resolving its completion value. */ +export type PreviewScriptRunner = (code: string) => Promise + +const runners = new Map() + +/** Register a live preview's script runner; returns an idempotent unregister. */ +export function registerPreviewScriptRunner(tabId: string, runner: PreviewScriptRunner): () => void { + runners.set(tabId, runner) + + return () => { + if (runners.get(tabId) === runner) { + runners.delete(tabId) + } + } +} + +/** The ACTIVE preview tab's script runner. Null = no live page behind it. */ +export function activePreviewScriptRunner(): PreviewScriptRunner | null { + const tabs = $previewTabs.get() + const tab = tabs.find(t => t.id === $rightRailActiveTabId.get()) ?? tabs[0] + + return (tab && runners.get(tab.id)) || null +} diff --git a/apps/desktop/src/app/chat/right-rail/preview-tour.ts b/apps/desktop/src/app/chat/right-rail/preview-tour.ts new file mode 100644 index 0000000000..a033a7c1f8 --- /dev/null +++ b/apps/desktop/src/app/chat/right-rail/preview-tour.ts @@ -0,0 +1,67 @@ +/** + * PREVIEW TOUR — runs tour actions inside the preview pane's guest page, so a + * tour can walk through ANY web app open in the in-app browser, not just + * Hermes itself. + * + * The guest page is out-of-process; nothing here can touch its DOM directly. + * Instead the first action injects a self-contained bundle over + * `executeJavaScript` — the vendored driver.js IIFE, its stylesheet, and the + * same engine/collector SOURCE the app surface runs (see lib/tour/engine.ts's + * self-containment contract) — parked on window globals so subsequent actions + * reuse the live driver instance. Injection is idempotent and vanishes with + * the page (a navigation resets the tour, which is the right behavior). + * + * Dynamic-imported by run-tour.ts so the raw driver.js payload stays out of + * the boot path. + */ + +import driverCss from 'driver.js/dist/driver.css?raw' +import driverIife from 'driver.js/dist/driver.js.iife.js?raw' + +import { collectTourTargets } from '@/lib/tour/collect-targets' +import { runTourEngine, type TourAction, type TourResult } from '@/lib/tour/engine' + +import { activePreviewScriptRunner } from './preview-script-runner' + +/** Build the idempotent inject-and-run script for one tour action. */ +function buildTourScript(action: TourAction): string { + return `(function () { + var w = window; + if (!w.__hermesTourEngine) { + ${driverIife} + w.__hermesTourHolder = {}; + w.__hermesTourCollect = (${collectTourTargets.toString()}); + w.__hermesTourEngine = (${runTourEngine.toString()}); + } + if (!document.getElementById('__hermes-tour-style')) { + var style = document.createElement('style'); + style.id = '__hermes-tour-style'; + style.textContent = ${JSON.stringify(driverCss)}; + (document.head || document.documentElement).appendChild(style); + } + return JSON.stringify(w.__hermesTourEngine( + w.driver.js.driver, + w.__hermesTourHolder, + ${JSON.stringify(action)}, + w.__hermesTourCollect, + document + )); +})()` +} + +/** Run one tour action in the ACTIVE preview tab's page. */ +export async function runPreviewTour(action: TourAction): Promise { + const run = activePreviewScriptRunner() + + if (!run) { + return { error: 'No live page is open in the preview pane — open one first.', success: false } + } + + const raw = await run(buildTourScript(action)) + + if (typeof raw !== 'string' || !raw) { + return { error: 'The page did not answer the tour action.', success: false } + } + + return JSON.parse(raw) as TourResult +} diff --git a/apps/desktop/src/app/chat/session-status-dot.tsx b/apps/desktop/src/app/chat/session-status-dot.tsx index e5e97e743f..3e278556b1 100644 --- a/apps/desktop/src/app/chat/session-status-dot.tsx +++ b/apps/desktop/src/app/chat/session-status-dot.tsx @@ -59,10 +59,13 @@ const DOT_VARIANTS: Record = { role: 'status', title: r => r.backgroundRunning }, - // Emerald — the turn finished while the user was looking elsewhere. + // Emerald — the turn finished while the user was looking elsewhere. The + // color is theme-derived (`--ui-success`, a success green rotated toward the + // accent) so eight finished dots can't sit in the sidebar fighting a palette + // they don't belong to. Under a green accent it stays emerald. unread: { ariaLabel: r => r.finishedUnread, - className: `${DOT_BASE} bg-emerald-500`, + className: `${DOT_BASE} bg-(--ui-success)`, role: 'status', title: r => r.finishedUnread }, diff --git a/apps/desktop/src/app/chat/session-tile-attachments.test.tsx b/apps/desktop/src/app/chat/session-tile-attachments.test.tsx index b9a97a8611..b756f821e4 100644 --- a/apps/desktop/src/app/chat/session-tile-attachments.test.tsx +++ b/apps/desktop/src/app/chat/session-tile-attachments.test.tsx @@ -13,6 +13,8 @@ import { import { $connection, $sessions } from '@/store/session' import { $sessionStates, type SessionTileDelegate, setSessionTileDelegate } from '@/store/session-states' +import { deferred } from '../../test/deferred' + const requestGateway = vi.fn() vi.mock('@/app/gateway/hooks/use-gateway-request', () => ({ @@ -44,16 +46,6 @@ const STAGED_PATH = '/root/.hermes/attachments/photo.png' const THUMBNAIL = 'data:image/png;base64,dGh1bWJuYWls' const FULL_SOURCE = 'data:image/png;base64,b3JpZ2luYWw=' -function deferred() { - let resolve!: (value: T) => void - - const promise = new Promise(res => { - resolve = res - }) - - return { promise, resolve } -} - function makeAttachment(occurrenceId = createComposerAttachmentOccurrenceId()): ComposerAttachment { return { detail: HOST_PATH, diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx index 589630026a..88432e9b54 100644 --- a/apps/desktop/src/app/chat/session-tile.tsx +++ b/apps/desktop/src/app/chat/session-tile.tsx @@ -101,7 +101,8 @@ function buildTileView(storedSessionId: string): SessionView { $reasoningEffort: computed($state, state => state?.reasoningEffort ?? ''), $runtimeId, // Constant for the tile's lifetime — a plain atom, not a computed. - $storedId: atom(storedSessionId) + $storedId: atom(storedSessionId), + $turnStartedAt: computed($state, state => state?.turnStartedAt ?? null) } } diff --git a/apps/desktop/src/app/chat/session-view.tsx b/apps/desktop/src/app/chat/session-view.tsx index 474633c61b..ff084c5bcd 100644 --- a/apps/desktop/src/app/chat/session-view.tsx +++ b/apps/desktop/src/app/chat/session-view.tsx @@ -13,7 +13,8 @@ import { $currentProvider, $currentReasoningEffort, $messages, - $selectedStoredSessionId + $selectedStoredSessionId, + $turnStartedAt } from '@/store/session' import { $sessionStates } from '@/store/session-states' @@ -48,6 +49,10 @@ export interface SessionView { $awaitingResponse: ReadableAtom $messagesEmpty: ReadableAtom $lastVisibleIsUser: ReadableAtom + /** Epoch ms this surface's current turn began, null when idle. Per-surface + * for the same reason $busy is: a tile's activity timer must count its own + * turn, not whichever session the global mirror last reflected. */ + $turnStartedAt: ReadableAtom $cwd: ReadableAtom $model: ReadableAtom $provider: ReadableAtom @@ -100,7 +105,8 @@ export const PRIMARY_SESSION_VIEW: SessionView = { $provider: primaryField(state => state.provider, $currentProvider), $reasoningEffort: primaryField(state => state.reasoningEffort, $currentReasoningEffort), $runtimeId: $activeSessionId, - $storedId: $selectedStoredSessionId + $storedId: $selectedStoredSessionId, + $turnStartedAt: primaryField(state => state.turnStartedAt, $turnStartedAt) } const SessionViewContext = createContext(PRIMARY_SESSION_VIEW) diff --git a/apps/desktop/src/app/chat/sidebar/connection-switcher.test.tsx b/apps/desktop/src/app/chat/sidebar/connection-switcher.test.tsx new file mode 100644 index 0000000000..fa0af2324f --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/connection-switcher.test.tsx @@ -0,0 +1,365 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { atom } from 'nanostores' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { DesktopConnectionsRegistry } from '@/global' +import { $findInPage } from '@/store/find-in-page' + +import { ConnectionSwitcher } from './connection-switcher' + +// Radix menus use pointer capture; jsdom does not implement it. +Element.prototype.hasPointerCapture ??= () => false +Element.prototype.setPointerCapture ??= () => undefined +Element.prototype.releasePointerCapture ??= () => undefined +Element.prototype.scrollIntoView ??= () => undefined +globalThis.ResizeObserver ??= class ResizeObserver { + disconnect() {} + observe() {} + unobserve() {} +} + +vi.mock('@/store/connections', () => ({ + $activeConnectionId: atom('local'), + $connectionsRegistry: atom(null), + $pendingConnectionId: atom(null), + initializeConnectionsRegistry: vi.fn(async () => null), + refreshConnectionsRegistry: vi.fn(async () => null), + selectConnection: vi.fn(async () => undefined) +})) + +vi.mock('@/store/boot', () => ({ + $desktopBoot: atom({ + error: null, + fakeMode: false, + message: 'Starting', + phase: 'renderer.init', + progress: 2, + running: true, + timestamp: 0, + visible: true + }) +})) + +vi.mock('@/store/windows', () => ({ + isAuxiliaryWindow: vi.fn(() => false), + isPeerInstanceWindow: vi.fn(() => false) +})) + +vi.mock('@/i18n', () => ({ + useI18n: () => ({ + t: { + profiles: { + switchConnectionFailed: (name: string) => `Could not connect to ${name}`, + switchToConnection: (name: string) => `Switch to ${name}`, + connectGateway: 'Manage gateways…' + }, + settings: { + connections: { + noSearchResults: 'No gateways match your search.', + searchPlaceholder: 'Search gateways…', + kindCloud: 'Hermes Cloud', + kindLocal: 'Local', + kindRemote: 'Remote gateway', + kindSsh: 'SSH', + title: 'Registered gateways' + } + } + } + }) +})) + +const connectionStore = await import('@/store/connections') +const bootStore = await import('@/store/boot') +const windowStore = await import('@/store/windows') +const $activeConnectionId = connectionStore.$activeConnectionId as ReturnType> +const $connectionsRegistry = connectionStore.$connectionsRegistry +const $desktopBoot = bootStore.$desktopBoot +const $pendingConnectionId = connectionStore.$pendingConnectionId +const initializeConnectionsRegistry = vi.mocked(connectionStore.initializeConnectionsRegistry) +const refreshConnectionsRegistry = vi.mocked(connectionStore.refreshConnectionsRegistry) +const selectConnection = vi.mocked(connectionStore.selectConnection) +const isAuxiliaryWindow = vi.mocked(windowStore.isAuxiliaryWindow) +const isPeerInstanceWindow = vi.mocked(windowStore.isPeerInstanceWindow) +const onConnect = vi.fn() + +const connection = (id: string, label: string, kind: 'local' | 'remote' = 'remote') => ({ + id, + kind, + label, + tokenPreview: null, + tokenSet: false +}) + +const registry = (connections: ReturnType[]): DesktopConnectionsRegistry => ({ + connections, + primary: connections[0]?.id ?? 'local', + secureTokenStorage: true, + version: 2 +}) + +afterEach(() => { + cleanup() + vi.clearAllMocks() + $connectionsRegistry.set(null) + $activeConnectionId.set('local') + $desktopBoot.set({ + error: null, + fakeMode: false, + message: 'Starting', + phase: 'renderer.init', + progress: 2, + running: true, + timestamp: 0, + visible: true + }) + $pendingConnectionId.set(null) + $findInPage.set({ active: false, query: '', matchOrdinal: 0, matchCount: 0 }) + isAuxiliaryWindow.mockReturnValue(false) + isPeerInstanceWindow.mockReturnValue(false) +}) + +describe('ConnectionSwitcher', () => { + it('waits for primary boot fetches before restoring the launch source', async () => { + $connectionsRegistry.set(registry([connection('local', 'This device', 'local'), connection('homelab', 'Homelab')])) + render() + + expect(refreshConnectionsRegistry).toHaveBeenCalledTimes(1) + expect(initializeConnectionsRegistry).not.toHaveBeenCalled() + + $desktopBoot.set({ + ...$desktopBoot.get(), + phase: 'renderer.ready', + progress: 100, + running: false, + visible: false + }) + + await waitFor(() => expect(initializeConnectionsRegistry).toHaveBeenCalledTimes(1)) + }) + + it('keeps a full peer on the shared backend instead of replaying app-launch source restoration', async () => { + isPeerInstanceWindow.mockReturnValue(true) + $desktopBoot.set({ + ...$desktopBoot.get(), + phase: 'renderer.ready', + progress: 100, + running: false, + visible: false + }) + + render() + + await waitFor(() => expect(refreshConnectionsRegistry).toHaveBeenCalledTimes(1)) + expect(initializeConnectionsRegistry).not.toHaveBeenCalled() + }) + + it('keeps a secondary session window from replaying app-launch source restoration', async () => { + isAuxiliaryWindow.mockReturnValue(true) + $desktopBoot.set({ + ...$desktopBoot.get(), + phase: 'renderer.ready', + progress: 100, + running: false, + visible: false + }) + + render() + + await waitFor(() => expect(refreshConnectionsRegistry).toHaveBeenCalledTimes(1)) + expect(initializeConnectionsRegistry).not.toHaveBeenCalled() + }) + + it('adds no source chrome for a local-only setup', () => { + $connectionsRegistry.set(registry([connection('local', 'This device', 'local')])) + render() + + expect(screen.queryByRole('group', { name: 'Registered gateways' })).toBeNull() + }) + + it('shows a named source selector instead of profile-like gateway glyphs', () => { + $connectionsRegistry.set( + registry([ + connection('local', 'This device', 'local'), + connection('homelab', 'Homelab'), + connection('work-vps', 'Work VPS') + ]) + ) + render() + + const trigger = screen.getByRole('button', { name: 'Registered gateways: This device' }) + + expect(trigger.textContent).toContain('This device') + expect(trigger.getAttribute('data-variant')).toBe('ghost') + expect(trigger.querySelector('[data-connection-kind="local"] svg')).toBeTruthy() + expect(trigger.querySelector('.codicon-home')).toBeNull() + + fireEvent.pointerDown(trigger, { button: 0, pointerType: 'mouse' }) + fireEvent.click(screen.getByRole('menuitemradio', { name: 'Homelab' })) + expect(selectConnection).toHaveBeenCalledWith('homelab') + + fireEvent.pointerDown(trigger, { button: 0, pointerType: 'mouse' }) + fireEvent.click(screen.getByRole('menuitem', { name: 'Manage gateways…' })) + expect(onConnect).toHaveBeenCalledTimes(1) + expect(selectConnection).toHaveBeenCalledTimes(1) + }) + + it('fits the shared statusbar slot without changing its gateway identity', () => { + $connectionsRegistry.set(registry([connection('local', 'This device', 'local'), connection('homelab', 'Homelab')])) + render() + + const group = screen.getByRole('group', { name: 'Registered gateways' }) + const trigger = screen.getByRole('button', { name: 'Registered gateways: This device' }) + + expect(group.className).toContain('h-full') + expect(group.className).toContain('min-w-20') + expect(group.className).toContain('max-w-40') + expect(group.className).toContain('shrink') + expect(group.className).toContain('overflow-hidden') + expect(trigger.className).toContain('text-[0.6875rem]') + expect(trigger.className).toContain('min-w-0') + expect(trigger.className).toContain('overflow-hidden') + expect(trigger.textContent).toContain('This device') + }) + + it('keeps source controls stable while a remote is opening', () => { + $connectionsRegistry.set(registry([connection('local', 'This device', 'local'), connection('homelab', 'Homelab')])) + $pendingConnectionId.set('homelab') + render() + + expect(screen.getByRole('group', { name: 'Registered gateways' }).getAttribute('aria-busy')).toBe('true') + expect( + screen.getByRole('button', { name: 'Registered gateways: This device' }).querySelector('.animate-spin') + ).toBeTruthy() + }) + + it.each([2, 20])('uses the same stable source selector for %i registered backends', count => { + $connectionsRegistry.set( + registry([ + connection('local', 'This device', 'local'), + ...Array.from({ length: count - 1 }, (_, index) => connection(`remote-${index}`, `Remote ${index}`)) + ]) + ) + render() + + expect(screen.getByRole('button', { name: 'Registered gateways: This device' })).toBeTruthy() + }) + + it('keeps small gateway lists simple and naturally sorted', () => { + $connectionsRegistry.set( + registry([ + connection('zulu', 'Zulu'), + connection('local', 'This device', 'local'), + connection('studio-10', 'Studio 10'), + connection('alpha', 'alpha'), + connection('studio-2', 'Studio 2') + ]) + ) + render() + + const trigger = screen.getByRole('button', { name: 'Registered gateways: This device' }) + fireEvent.pointerDown(trigger, { button: 0, pointerType: 'mouse' }) + + expect(screen.queryByPlaceholderText('Search gateways…')).toBeNull() + expect(screen.getAllByRole('menuitemradio').map(item => item.textContent)).toEqual([ + 'This device', + 'alpha', + 'Studio 2', + 'Studio 10', + 'Zulu' + ]) + }) + + it('adds search at eight gateways and filters stable results without moving the connect action', async () => { + $connectionsRegistry.set( + registry([ + connection('zulu', 'Zulu'), + connection('local', 'This device', 'local'), + connection('studio-10', 'Studio 10'), + connection('alpha', 'Alpha'), + connection('studio-2', 'Studio 2'), + connection('work', 'Work VPS'), + connection('homelab', 'Homelab'), + connection('cloud', 'Cloud lab') + ]) + ) + render() + + const trigger = screen.getByRole('button', { name: 'Registered gateways: This device' }) + fireEvent.pointerDown(trigger, { + button: 0, + pointerType: 'mouse' + }) + + const search = screen.getByPlaceholderText('Search gateways…') + expect(screen.getByRole('menuitem', { name: 'Manage gateways…' })).toBeTruthy() + expect(screen.getAllByRole('menuitemradio').map(item => item.textContent)).toEqual([ + 'This device', + 'Alpha', + 'Cloud lab', + 'Homelab', + 'Studio 2', + 'Studio 10', + 'Work VPS', + 'Zulu' + ]) + + fireEvent.change(search, { target: { value: 'studio 10' } }) + expect(screen.getAllByRole('menuitemradio').map(item => item.textContent)).toEqual(['Studio 10']) + + const result = screen.getByRole('menuitemradio', { name: 'Studio 10' }) + fireEvent.keyDown(search, { key: 'ArrowDown' }) + expect(globalThis.document.activeElement).toBe(result) + + $findInPage.set({ active: true, query: '', matchOrdinal: 0, matchCount: 0 }) + result.focus() + fireEvent.keyDown(result, { key: 'f', metaKey: true }) + expect(globalThis.document.activeElement).toBe(search) + expect($findInPage.get().active).toBe(false) + + fireEvent.keyDown(search, { key: 'Escape' }) + expect(screen.queryByPlaceholderText('Search gateways…')).toBeNull() + await waitFor(() => expect(globalThis.document.activeElement).toBe(trigger)) + + fireEvent.pointerDown(trigger, { button: 0, pointerType: 'mouse' }) + expect((screen.getByPlaceholderText('Search gateways…') as HTMLInputElement).value).toBe('') + + fireEvent.click(screen.getByRole('menuitemradio', { name: 'Studio 10' })) + expect(selectConnection).toHaveBeenCalledWith('studio-10') + + fireEvent.pointerDown(trigger, { button: 0, pointerType: 'mouse' }) + expect((screen.getByPlaceholderText('Search gateways…') as HTMLInputElement).value).toBe('') + }) + + it('explains an empty large-list search', () => { + $connectionsRegistry.set( + registry([ + connection('local', 'This device', 'local'), + ...Array.from({ length: 7 }, (_, index) => connection(`remote-${index}`, `Remote ${index}`)) + ]) + ) + render() + + fireEvent.pointerDown(screen.getByRole('button', { name: 'Registered gateways: This device' }), { + button: 0, + pointerType: 'mouse' + }) + fireEvent.change(screen.getByPlaceholderText('Search gateways…'), { target: { value: 'missing' } }) + + expect(screen.getByText('No gateways match your search.')).toBeTruthy() + expect(screen.getByRole('menuitem', { name: 'Manage gateways…' })).toBeTruthy() + expect(globalThis.document.querySelector('[data-slot="dropdown-menu-radio-group"]')?.className).toContain('h-48') + }) + + it('announces a pending switch in the compact source menu', () => { + $connectionsRegistry.set( + registry([ + connection('local', 'This device', 'local'), + ...Array.from({ length: 6 }, (_, index) => connection(`remote-${index}`, `Remote ${index}`)) + ]) + ) + $pendingConnectionId.set('remote-3') + render() + + expect(screen.getByRole('group', { name: 'Registered gateways' }).getAttribute('aria-busy')).toBe('true') + }) +}) diff --git a/apps/desktop/src/app/chat/sidebar/connection-switcher.tsx b/apps/desktop/src/app/chat/sidebar/connection-switcher.tsx new file mode 100644 index 0000000000..ce0e56b801 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/connection-switcher.tsx @@ -0,0 +1,325 @@ +import { useStore } from '@nanostores/react' +import { useEffect, useMemo, useRef, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + dropdownMenuRow, + DropdownMenuSearch, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import type { DesktopRegistryConnection } from '@/global' +import { useI18n } from '@/i18n' +import { + CONNECTION_SEARCH_THRESHOLD, + connectionMatchesQuery, + connectionTooltip, + sortConnectionsForDisplay +} from '@/lib/connection-display' +import { triggerHaptic } from '@/lib/haptics' +import { Cloud, Loader2, Monitor, Network, Terminal } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { $desktopBoot } from '@/store/boot' +import { + $activeConnectionId, + $connectionsRegistry, + $pendingConnectionId, + initializeConnectionsRegistry, + refreshConnectionsRegistry, + selectConnection +} from '@/store/connections' +import { closeFindBar } from '@/store/find-in-page' +import { notifyError } from '@/store/notifications' +import { isAuxiliaryWindow, isPeerInstanceWindow } from '@/store/windows' + +export function ConnectionSwitcher({ compact = false, onConnect }: { compact?: boolean; onConnect: () => void }) { + const { t } = useI18n() + const registry = useStore($connectionsRegistry) + const activeConnectionId = useStore($activeConnectionId) + const boot = useStore($desktopBoot) + const pendingConnectionId = useStore($pendingConnectionId) + const [searchQuery, setSearchQuery] = useState('') + const [menuOpen, setMenuOpen] = useState(false) + const connectionListRef = useRef(null) + const searchInputRef = useRef(null) + + useEffect(() => { + void refreshConnectionsRegistry().catch(() => undefined) + + // Registry events are local IPC notifications, not remote polling. They + // keep a second Settings window or a removal/edit reflected here. + const off = window.hermesDesktop?.connections?.onChanged?.(() => { + void refreshConnectionsRegistry().catch(() => undefined) + }) + + return off + }, []) + + useEffect(() => { + // The primary boot owns its initial config/session fetches. Restoring a + // different source before those settle lets a late primary response repaint + // the sidebar under the new source label. Switch only after boot completes, + // then the normal source reset/refetch remains the final writer. Peer and + // auxiliary windows already boot into their intended runtime; replaying the + // primary window's app-launch preference would move them away from it. + if (!boot.running && !isAuxiliaryWindow() && !isPeerInstanceWindow()) { + void initializeConnectionsRegistry().catch(() => undefined) + } + }, [boot.running]) + + const connections = useMemo(() => sortConnectionsForDisplay(registry?.connections ?? []), [registry?.connections]) + + const activeConnection = connections.find(connection => connection.id === activeConnectionId) + const searchable = connections.length >= CONNECTION_SEARCH_THRESHOLD + + const kindLabels: Record = { + cloud: t.settings.connections.kindCloud, + local: t.settings.connections.kindLocal, + remote: t.settings.connections.kindRemote, + ssh: t.settings.connections.kindSsh + } + + const displayedConnections = searchable + ? connections.filter(connection => connectionMatchesQuery(connection, searchQuery, [kindLabels[connection.kind]])) + : connections + + useEffect(() => { + if (!menuOpen || !searchable || searchQuery) { + return + } + + connectionListRef.current?.querySelector('[aria-checked="true"]')?.scrollIntoView({ block: 'nearest' }) + }, [activeConnectionId, menuOpen, searchQuery, searchable]) + + useEffect(() => { + if (!menuOpen) { + return + } + + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key !== 'Escape') { + return + } + + event.preventDefault() + event.stopPropagation() + setMenuOpen(false) + setSearchQuery('') + } + + window.addEventListener('keydown', closeOnEscape, { capture: true }) + + return () => window.removeEventListener('keydown', closeOnEscape, { capture: true }) + }, [menuOpen]) + + if (connections.length <= 1) { + return null + } + + const choose = (connectionId: string) => { + triggerHaptic('selection') + const connection = connections.find(candidate => candidate.id === connectionId) + + void selectConnection(connectionId).catch(error => + notifyError(error, t.profiles.switchConnectionFailed(connection?.label ?? connectionId)) + ) + } + + return ( +
+ { + setMenuOpen(open) + + if (!open) { + setSearchQuery('') + } + }} + open={menuOpen} + > + + + + { + if (searchable && (event.metaKey || event.ctrlKey) && event.key.toLocaleLowerCase() === 'f') { + event.preventDefault() + event.stopPropagation() + // The app-level keybind sees the chord at window capture before + // this portal and may open Find in page. This menu owns the chord + // while it is open, so close that surface before focusing here. + closeFindBar() + searchInputRef.current?.focus() + searchInputRef.current?.select() + } + }} + side="top" + > + {searchable && ( + <> + { + if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') { + return + } + + const results = connectionListRef.current?.querySelectorAll( + '[role="menuitemradio"]:not([data-disabled])' + ) + + const target = + event.key === 'ArrowDown' ? results?.item(0) : results?.item((results?.length ?? 1) - 1) + + if (target) { + event.preventDefault() + event.stopPropagation() + target.focus() + } + }} + onValueChange={setSearchQuery} + placeholder={t.settings.connections.searchPlaceholder} + ref={searchInputRef} + value={searchQuery} + /> + + + )} + + {displayedConnections.length === 0 ? ( +
+ {t.settings.connections.noSearchResults} +
+ ) : ( + displayedConnections.map(connection => ( + + + + )) + )} +
+ + + + +
+
+
+ ) +} + +interface ConnectionMenuProps { + activeConnection?: DesktopRegistryConnection + compact: boolean + pending: boolean + title: string +} + +function ConnectionSwitcherTrigger({ + activeConnection, + compact, + pending, + title, + ...triggerProps +}: ConnectionMenuProps & React.ComponentProps<'button'>) { + return ( + + ) +} + +function ManageGatewaysLabel({ label }: { label: string }) { + return ( + + + ) +} + +function ConnectionGlyph({ connection }: { connection: DesktopRegistryConnection }) { + const Icon = + connection.kind === 'local' + ? Monitor + : connection.kind === 'cloud' + ? Cloud + : connection.kind === 'ssh' + ? Terminal + : Network + + return ( + + ) +} + +function ConnectionLabel({ connection }: { connection: DesktopRegistryConnection }) { + return ( + + + {connection.label} + + ) +} 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 8be72aba34..9d4d54b9af 100644 --- a/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx +++ b/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx @@ -13,6 +13,7 @@ import { deleteCronJob, getCronJobRuns, pauseCronJob, resumeCronJob, type Sessio import { useI18n } from '@/i18n' import { fmtDayTime, relativeTime } from '@/lib/time' import { cn } from '@/lib/utils' +import { confirm } from '@/store/confirm' import { updateCronJobs } from '@/store/cron' import { $changeEventsAvailable, $cronChangeTick } from '@/store/live-sync' import { notify, notifyError } from '@/store/notifications' @@ -259,7 +260,14 @@ function CronJobSidebarRow({ } const remove = async () => { - if (!window.confirm(`${c.deleteDescPrefix}${label}${c.deleteDescSuffix}`)) { + const ok = await confirm({ + confirmLabel: t.common.delete, + description: `${c.deleteDescPrefix}${label}${c.deleteDescSuffix}`, + destructive: true, + title: c.deleteTitle + }) + + if (!ok) { return } diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 82ac170dcb..868b116708 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -30,6 +30,7 @@ import { resolveProfileColor } from '@/lib/profile-color' import { sessionMatchesSearch } from '@/lib/session-search' import { normalizeSessionSource, sessionSourceLabel } from '@/lib/session-source' import { cn } from '@/lib/utils' +import { $activeConnectionId } from '@/store/connections' import { $cronJobs } from '@/store/cron' import { $bindings } from '@/store/keybinds' import { @@ -256,6 +257,16 @@ const HEADER_NAV_BTN = // 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). + +// The backend's FTS layer wraps matched terms in literal '>>>' / '<<<' +// highlight markers (sqlite snippet() delimiters — see hermes_state_search.py). +// The sidebar renders the snippet as plain text, so the markers must be +// stripped or a search for "foo" paints rows titled ">>>foo<<<". +// Exported for tests. +export function stripFtsMarkers(snippet: string): string { + return snippet.replaceAll('>>>', '').replaceAll('<<<', '') +} + function searchResultToSession(result: SessionSearchResult): SessionInfo { const ts = result.session_started ?? Date.now() / 1000 @@ -271,7 +282,7 @@ function searchResultToSession(result: SessionSearchResult): SessionInfo { message_count: 0, model: result.model ?? null, output_tokens: 0, - preview: result.snippet?.trim() || null, + preview: stripFtsMarkers(result.snippet ?? '').trim() || null, source: result.source ?? null, started_at: ts, title: null, @@ -377,6 +388,7 @@ export function ChatSidebar({ const profiles = useStore($profiles) const profileColors = useStore($profileColors) const profileScope = useStore($profileScope) + const activeConnectionId = useStore($activeConnectionId) // Toggle the persisted read-state watermark from a row menu. The row's own // `unread` prop mirrors what the dot paints; flip it and let the backend @@ -465,16 +477,19 @@ export function ChatSidebar({ // Profile scope = the "workspace switcher" context. Concrete scope shows only // that profile's sessions (clean rows, no per-row tags); ALL fans every - // profile in, grouped by profile below. Single-profile users land here with - // scope === their only profile, so nothing is filtered out. + // profile in. Grouped rendering stays gated on `showAllProfiles` (multi-profile + // + ALL) so a single-profile user is never stranded in a grouped view with no + // rail — but the *data* still has to fan in when the persisted scope is ALL + // (Grouping → Profile). Filtering that pool against the `__all__` sentinel + // matches nothing and empties recents + pins. // Archived rows are excluded from the sessions query, so Archived is a view of // its own set rather than a filter over this one — a flat list of archived // rows, no project tree, no date or status dividers. const scopedSessions = useMemo(() => { const pool = showArchived ? archivedSessions : sessions - return showAllProfiles ? pool : pool.filter(s => normalizeProfileKey(s.profile) === profileScope) - }, [sessions, archivedSessions, showArchived, showAllProfiles, profileScope]) + return filterSessionsByProfileScope(pool, profileScope) + }, [sessions, archivedSessions, showArchived, profileScope]) // One predicate for the status/project filters, so the flat list and the // project lanes narrow by the same rule. A project lane holds rows the loaded @@ -742,7 +757,7 @@ export function ChatSidebar({ const warm = window.setTimeout(() => void refreshProjectTree(), PROJECT_TREE_WARM_MS) return () => window.clearTimeout(warm) - }, [worktreeGroupingActive, showAllProfiles, profileScope, gatewayReady]) + }, [activeConnectionId, worktreeGroupingActive, showAllProfiles, profileScope, gatewayReady]) // Sessions the branch join can't answer for get one look at their own // transcript — a `gh pr create` in there names the PR outright. Backfills @@ -1679,7 +1694,10 @@ export function ChatSidebar({ grouping={showArchived || rankedGlobally ? 'none' : grouping === 'status' ? 'status' : 'date'} groups={displayAgentGroups} headerAction={ - <> + // One cluster, not a fragment: the header is justify-between, + // so two children (mark-all + the rest) park the check-all in + // the middle as a blank 24px hole until hover. +
{unreadCount > 0 && (
) : ( -
+ <> {!showAllProfiles ? (
-
+ )} - +
} label={sessionsLabel} labelMeta={ diff --git a/apps/desktop/src/app/chat/sidebar/profile-rail-connect.test.tsx b/apps/desktop/src/app/chat/sidebar/profile-rail-connect.test.tsx index 10e93f23e5..5a0d2b0a93 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-rail-connect.test.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-rail-connect.test.tsx @@ -4,8 +4,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { ProfileRail } from './profile-switcher' -afterEach(cleanup) - // The rail's discoverability pills are navigation, not identity — assert the // multi-gateway entry point deep-links to Settings → Connections instead of // relying on someone finding the pane three levels into Settings (the exact @@ -23,7 +21,7 @@ vi.mock('@/i18n', () => ({ common: { cancel: 'Cancel' }, profiles: { allProfiles: 'All profiles', - connectGateway: 'Connect another Hermes gateway…', + connectGateway: 'Manage gateways…', failedLoadSoul: 'Failed to load SOUL.md', failedSaveSoul: 'Failed to save SOUL.md', importProfile: 'Import profile…', @@ -59,6 +57,8 @@ vi.mock('@/store/profile', () => ({ sortByProfileOrder: (profiles: unknown[]) => profiles })) +vi.mock('@/store/connections', () => ({ $hasMultipleConnections: atom(false) })) + vi.mock('@/store/profile-share', () => ({ runExportProfileFlow: vi.fn(), runImportProfileFlow: vi.fn() @@ -78,11 +78,22 @@ vi.mock('../../profiles/create-profile-dialog', () => ({ CreateProfileDialog: () vi.mock('../../profiles/delete-profile-dialog', () => ({ DeleteProfileDialog: () => null })) vi.mock('../../profiles/rename-profile-dialog', () => ({ RenameProfileDialog: () => null })) +const { $hasMultipleConnections } = await import('@/store/connections') +const hasMultipleConnections = $hasMultipleConnections as ReturnType> +const { $profiles } = await import('@/store/profile') +const profiles = $profiles as ReturnType>> + +afterEach(() => { + cleanup() + hasMultipleConnections.set(false) + profiles.set([{ is_default: true, name: 'default' }]) +}) + describe('ProfileRail multi-gateway entry point', () => { it('deep-links to the unified Settings → Gateways page from the rail', () => { render() - const pill = screen.getByRole('button', { name: 'Connect another Hermes gateway…' }) + const pill = screen.getByRole('button', { name: 'Manage gateways…' }) fireEvent.click(pill) expect(navigate).toHaveBeenCalledWith('/settings?tab=gateway') @@ -93,7 +104,48 @@ describe('ProfileRail multi-gateway entry point', () => { // The whole point is first-run discoverability: the pill must not be // gated behind multiProfile the way the default↔all toggle is. - expect(screen.getByRole('button', { name: 'Connect another Hermes gateway…' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Manage gateways…' })).toBeTruthy() expect(screen.getByRole('button', { name: 'Manage profiles…' })).toBeTruthy() }) + + it('keeps the active profile explicit when gateway identity moves to the statusbar', () => { + hasMultipleConnections.set(true) + render() + + expect(screen.getByRole('button', { name: 'default' })).toBeTruthy() + expect(screen.queryByRole('button', { name: 'Manage gateways…' })).toBeNull() + expect(screen.getByRole('button', { name: 'Manage profiles…' })).toBeTruthy() + }) + + it('keeps thirteen profiles direct and condenses the fourteenth', () => { + profiles.set([ + { is_default: true, name: 'default' }, + ...Array.from({ length: 12 }, (_, index) => ({ is_default: false, name: `Profile ${index + 1}` })) + ]) + const { unmount } = render() + + expect(screen.queryByRole('button', { name: 'Profiles' })).toBeNull() + expect(screen.getByRole('button', { name: 'Profile 12' })).toBeTruthy() + unmount() + + profiles.set([ + { is_default: true, name: 'default' }, + ...Array.from({ length: 13 }, (_, index) => ({ is_default: false, name: `Profile ${index + 1}` })) + ]) + render() + + expect(screen.getByRole('button', { name: 'Profiles' })).toBeTruthy() + }) + + it('stays shrinkable with many profiles and multiple gateways', () => { + hasMultipleConnections.set(true) + profiles.set([ + { is_default: true, name: 'default' }, + ...Array.from({ length: 13 }, (_, index) => ({ is_default: false, name: `Profile ${index + 1}` })) + ]) + render() + + expect(screen.getByRole('group', { name: 'Profiles' }).className).toContain('min-w-0') + expect(screen.getByRole('button', { name: 'Profiles' })).toBeTruthy() + }) }) diff --git a/apps/desktop/src/app/chat/sidebar/profile-scope.test.ts b/apps/desktop/src/app/chat/sidebar/profile-scope.test.ts index 14e73edb61..68585af986 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-scope.test.ts +++ b/apps/desktop/src/app/chat/sidebar/profile-scope.test.ts @@ -27,4 +27,12 @@ describe('filterSessionsByProfileScope', () => { expect(filterSessionsByProfileScope(rows, ALL_PROFILES)).toBe(rows) }) + + it('does not empty ALL scope when every row is one profile', () => { + // Grouping → Profile persists ALL even with one profile. Filtering + // against the `__all__` sentinel would empty recents and pins. + const rows = [row('a', 'default'), row('b', 'default'), row('c', 'default')] + + expect(filterSessionsByProfileScope(rows, ALL_PROFILES)).toBe(rows) + }) }) diff --git a/apps/desktop/src/app/chat/sidebar/profile-scope.ts b/apps/desktop/src/app/chat/sidebar/profile-scope.ts index 45686d8252..d585b14c9b 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-scope.ts +++ b/apps/desktop/src/app/chat/sidebar/profile-scope.ts @@ -1,7 +1,13 @@ import { ALL_PROFILES, normalizeProfileKey } from '@/store/profile' import type { SessionInfo } from '@/types/hermes' -/** Return the sessions visible in one sidebar profile scope, or the original unified list for All profiles. */ +/** + * Sessions visible in one sidebar profile scope. + * + * ALL (`__all__`) returns the caller's list unchanged — including the + * single-profile case, where Grouping → Profile persists that sentinel + * while grouped rendering stays off. Never filter against `__all__`. + */ export function filterSessionsByProfileScope(sessions: SessionInfo[], profileScope: string): SessionInfo[] { if (profileScope === ALL_PROFILES) { return sessions diff --git a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx index 4a98d23cbb..5b86c788f5 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx @@ -28,9 +28,17 @@ import { Codicon } from '@/components/ui/codicon' import { ColorSwatches } from '@/components/ui/color-swatches' import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu' import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' import { ProfileGlyph } from '@/components/ui/profile-glyph' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { getProfileSoul, updateProfileSoul } from '@/hermes' import { useI18n } from '@/i18n' @@ -43,6 +51,7 @@ import { reorderStepHaptic } from '@/lib/reorder' import { cn } from '@/lib/utils' +import { $hasMultipleConnections } from '@/store/connections' import { notify, notifyError } from '@/store/notifications' import { $activeGatewayProfile, @@ -76,7 +85,7 @@ const RAIL_GAP = 4 // px — matches gap-1 between squares. // Past this many profiles the strip of colored squares stops scaling (tiny // drag targets, endless horizontal scroll), so the rail collapses to a compact -// select. Drag-reorder and long-press-recolor live only on the squares path. +// menu. Drag-reorder and long-press-recolor live only on the squares path. const PROFILE_DROPDOWN_THRESHOLD = 13 // Neighbors reflow on RAIL_TRANSITION; the dragged square glides between @@ -104,10 +113,9 @@ const stepThroughCells: Modifier = ({ containerNodeRect, draggingNodeRect, trans // Arc-Spaces-style profile rail at the sidebar foot: a default↔all toggle pinned // left, the colored named profiles scrolling between, and Manage pinned right. -// The active profile pops in its own color — the "where am I" cue. Single- -// profile users see the "+" (create their first profile) and the Manage -// overflow (edit the default profile's SOUL.md); the colored named squares -// and the default↔all toggle only appear once a second profile exists. +// The active profile pops in its own color — the "where am I" cue. Gateway +// identity lives in the statusbar, so this strip remains entirely available to +// profiles regardless of how many backends are registered. export function ProfileRail() { const { t } = useI18n() const p = t.profiles @@ -116,6 +124,7 @@ export function ProfileRail() { const gatewayProfile = useStore($activeGatewayProfile) const order = useStore($profileOrder) const colors = useStore($profileColors) + const multipleConnections = useStore($hasMultipleConnections) const navigate = useNavigate() const [createOpen, setCreateOpen] = useState(false) @@ -230,7 +239,7 @@ export function ProfileRail() { }, [createRequest]) return ( -
+
{/* One button toggles default ↔ all: home face when scoped to a profile, layers face when showing everything. Pinned left like Manage is right. Hidden until a second profile exists. */} @@ -266,11 +275,11 @@ export function ProfileRail() { setCreateOpen(true)} + onImport={() => void runImportProfileFlow()} onSelect={selectProfile} profiles={named} /> - setCreateOpen(true)} /> -
) : (
navigate(PROFILES_ROUTE)} /> - {/* Multi-gateway discoverability: a plug pinned beside Manage deep-links - to Settings → Gateways (the connections registry lives on the unified - Gateways page now). The registry (local runtime + remote gateways + - Hermes Cloud + SSH) is otherwise buried three levels into Settings, - and the rail is exactly where a user looks when they wonder "how do I - get my other machine's agents in here". */} - navigate(`${SETTINGS_ROUTE}?tab=gateway`)} - /> + {/* Multi-gateway discoverability: before a second source exists, a plug + pinned beside Manage deep-links to the unified Gateways page. Once + there are several sources, the same action lives in their selector. */} + {!multipleConnections && ( + navigate(`${SETTINGS_ROUTE}?tab=gateway`)} + /> + )} {/* Land in the new profile on a fresh chat (selectProfile triggers the new-session reset), not stuck on the session you were just in. */} @@ -475,17 +483,21 @@ function ImportProfileButton({ label }: { label: string }) { ) } -// The condensed rail: every named profile in one compact select. The trigger +// The condensed rail: every named profile in one compact menu. The trigger // shows the active profile (tinted initial + name); on default/all scope it // falls back to the placeholder since the left toggle pill carries that state. function ProfileDropdown({ activeKey, colors, + onCreate, + onImport, onSelect, profiles }: { activeKey: null | string colors: Record + onCreate: () => void + onImport: () => void onSelect: (name: string) => void profiles: ProfileInfo[] }) { @@ -493,23 +505,59 @@ function ProfileDropdown({ const p = t.profiles const value = activeKey ? (profiles.find(profile => normalizeProfileKey(profile.name) === activeKey)?.name ?? '') : '' + const activeProfile = profiles.find(profile => profile.name === value) return ( - + + + + + + + + + + + name && onSelect(name)} value={value}> + {profiles.map(profile => ( + + ))} + + + ) } @@ -519,12 +567,17 @@ function ProfileDropdownItem({ color, label, name }: { color: null | string; lab const { cancelPrewarm, startPrewarm } = useProfilePrewarm(name) return ( - + - + ) } diff --git a/apps/desktop/src/app/chat/sidebar/projects/entered-content.tsx b/apps/desktop/src/app/chat/sidebar/projects/entered-content.tsx index 6b09788157..7d2fc7117c 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/entered-content.tsx +++ b/apps/desktop/src/app/chat/sidebar/projects/entered-content.tsx @@ -2,16 +2,8 @@ import { useStore } from '@nanostores/react' import type * as React from 'react' import { useMemo, useState } from 'react' -import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' +import { ConfirmDialog } from '@/components/ui/confirm-dialog' import type { HermesGitWorktree } from '@/global' import type { SessionInfo } from '@/hermes' import { useI18n } from '@/i18n' @@ -190,43 +182,26 @@ function RepoFlatSection({ destructiveLabel: string, onDestructive: (group: SidebarSessionGroup) => void ) => ( - !isOpen && setTarget(null)} open={Boolean(target)}> - - - {`${s.projects.removeWorktree} "${target?.label ?? ''}"?`} - {description} - - - - - - - - + setTarget(null)} + onConfirm={() => { + if (target) { + onDestructive(target) + } + }} + open={Boolean(target)} + secondaryAction={{ + label: s.projects.removeFromSidebar, + onClick: () => target && dismissWorktree(target.id) + }} + title={`${s.projects.removeWorktree} "${target?.label ?? ''}"?`} + /> ) const removeDialog = ( diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts index bd32d4fd4a..c81f1d72b1 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import type { HermesGitWorktree } from '@/global' +import { makeCwdSession } from '@/test/session-info' import type { ProjectInfo, SessionInfo } from '@/types/hermes' import { @@ -22,29 +23,6 @@ import { // covered by tests/tui_gateway/test_project_tree.py). This file only covers the // thin render helpers the desktop still owns + the VISUAL worktree enhancer. -let nextId = 0 - -function makeSession(cwd: null | string, overrides: Partial = {}): SessionInfo { - return { - archived: false, - cwd, - ended_at: null, - id: `s${nextId++}`, - input_tokens: 0, - is_active: false, - last_active: 1_000, - message_count: 1, - model: 'claude', - output_tokens: 0, - preview: null, - source: 'cli', - started_at: 1_000, - title: null, - tool_call_count: 0, - ...overrides - } -} - const lane = (over: Partial & Pick): SidebarSessionGroup => ({ path: null, sessions: [], @@ -76,7 +54,7 @@ describe('kanbanWorktreeDir', () => { describe('sortWorktreeGroups', () => { it('pins trunk to the top, sinks kanban to the bottom, and orders the rest by recency', () => { - const at = (t: number) => [makeSession('/x', { last_active: t })] + const at = (t: number) => [makeCwdSession('/x', { last_active: t })] const groups = [ lane({ id: 'k', label: 'kanban', isKanban: true, sessions: at(999) }), @@ -92,7 +70,7 @@ describe('sortWorktreeGroups', () => { it('pins the live home checkout above trunk, even when it has no sessions yet', () => { const groups = [ - lane({ id: 'main', label: 'main', isMain: true, sessions: [makeSession('/x', { last_active: 999 })] }), + lane({ id: 'main', label: 'main', isMain: true, sessions: [makeCwdSession('/x', { last_active: 999 })] }), lane({ id: 'home', label: 'bb/projects-paradigm', isMain: true, isHome: true }) ] @@ -150,7 +128,7 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => { label: 'main', isMain: true, path: '/repo', - sessions: [makeSession('/repo')] + sessions: [makeCwdSession('/repo')] }) ] } @@ -176,7 +154,13 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => { it('does not add a second "main" for a linked worktree checked out on main', () => { const groups = [ - lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] }) + lane({ + id: '/repo::branch::main', + label: 'main', + isMain: true, + path: '/repo', + sessions: [makeCwdSession('/repo')] + }) ] const discovered: HermesGitWorktree[] = [ @@ -216,14 +200,14 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => { label: 'main', isMain: true, path: '/repo', - sessions: [makeSession('/repo')] + sessions: [makeCwdSession('/repo')] }), lane({ id: '/repo-ci', label: 'hermes-agent-ci', isMain: false, path: '/repo-ci', - sessions: [makeSession('/repo-ci')] + sessions: [makeCwdSession('/repo-ci')] }) ] } @@ -256,7 +240,7 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => { label: 'bb/attempts', isMain: false, path: '/repo/.worktrees/attempts', - sessions: [makeSession('/repo/.worktrees/attempts')] + sessions: [makeCwdSession('/repo/.worktrees/attempts')] }) ] } @@ -291,7 +275,7 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => { label: 'bb/feature', isMain: false, path: '/repo-feature', - sessions: [makeSession('/repo-feature'), makeSession('/repo-feature')] + sessions: [makeCwdSession('/repo-feature'), makeCwdSession('/repo-feature')] }) ] } @@ -313,7 +297,13 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => { id: '/repo', path: '/repo', groups: [ - lane({ id: '/repo-ci', label: 'repo-ci', isMain: false, path: '/repo-ci', sessions: [makeSession('/repo-ci')] }) + lane({ + id: '/repo-ci', + label: 'repo-ci', + isMain: false, + path: '/repo-ci', + sessions: [makeCwdSession('/repo-ci')] + }) ] } @@ -334,7 +324,7 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => { label: 'main', isMain: true, path: '/repo', - sessions: [makeSession('/repo')] + sessions: [makeCwdSession('/repo')] }) ] } @@ -366,7 +356,7 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => { label: 'main', isMain: true, path: '/repo', - sessions: [makeSession('/repo')] + sessions: [makeCwdSession('/repo')] }) ] } @@ -391,14 +381,14 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => { label: 'main', isMain: true, path: '/repo', - sessions: [makeSession('/repo', { id: 'a' })] + sessions: [makeCwdSession('/repo', { id: 'a' })] }), lane({ id: '/repo::branch::old', label: 'old-feature', isMain: true, path: '/repo', - sessions: [makeSession('/repo', { id: 'b' })] + sessions: [makeCwdSession('/repo', { id: 'b' })] }) ] } @@ -425,7 +415,7 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => { label: 'main', isMain: true, path: '/repo', - sessions: [makeSession('/repo')] + sessions: [makeCwdSession('/repo')] }) ] } @@ -481,11 +471,11 @@ const homeNode = (sessions: SessionInfo[]): SidebarProjectTree => describe('liveSessionProjectId', () => { it('maps a brand-new (unpersisted) session to its auto project (the repo root)', () => { - expect(liveSessionProjectId(makeSession('/www/app'), [])).toBe('/www/app') + expect(liveSessionProjectId(makeCwdSession('/www/app'), [])).toBe('/www/app') }) it('routes a session under an explicit project folder to that project', () => { - const id = liveSessionProjectId(makeSession('/www/app/src', { git_repo_root: '/www/app', git_branch: 'feat' }), [ + const id = liveSessionProjectId(makeCwdSession('/www/app/src', { git_repo_root: '/www/app', git_branch: 'feat' }), [ makeProject('p_app', ['/www/app']) ]) @@ -495,31 +485,31 @@ describe('liveSessionProjectId', () => { it('anchors a cwd-less session on its git_repo_root (backend groups it there too)', () => { // Older/imported rows carry only a repo root; the sidebar files them under // the repo's project, so membership (and color) must resolve from the root. - expect(liveSessionProjectId(makeSession(null, { git_repo_root: '/www/app' }), [])).toBe('/www/app') + expect(liveSessionProjectId(makeCwdSession(null, { git_repo_root: '/www/app' }), [])).toBe('/www/app') expect( - liveSessionProjectId(makeSession(null, { git_repo_root: '/www/app' }), [makeProject('p_app', ['/www/app'])]) + liveSessionProjectId(makeCwdSession(null, { git_repo_root: '/www/app' }), [makeProject('p_app', ['/www/app'])]) ).toBe('p_app') }) it('skips cwd-less, kanban-task, and out-of-tree (sibling) worktree sessions', () => { - expect(liveSessionProjectId(makeSession(null), [])).toBeNull() + expect(liveSessionProjectId(makeCwdSession(null), [])).toBeNull() // Kanban task worktree → folds into the kanban bucket, not a project preview. - expect(liveSessionProjectId(makeSession('/repo/.worktrees/t_aaaaaaaa'), [])).toBeNull() + expect(liveSessionProjectId(makeCwdSession('/repo/.worktrees/t_aaaaaaaa'), [])).toBeNull() // Sibling worktree OUTSIDE the repo root → project can't be derived from the row. - expect(liveSessionProjectId(makeSession('/elsewhere/wt', { git_repo_root: '/repo' }), [])).toBeNull() + expect(liveSessionProjectId(makeCwdSession('/elsewhere/wt', { git_repo_root: '/repo' }), [])).toBeNull() }) it('places an in-tree worktree session under its repo project (the root is in the path)', () => { // "Convert a branch" / "new worktree" land at `/.worktrees/`, // so they belong to the same auto project as the repo root and must show in // the overview at once, not wait for the next backend refresh. - expect(liveSessionProjectId(makeSession('/www/app/.worktrees/test1', { git_repo_root: '/www/app' }), [])).toBe( + expect(liveSessionProjectId(makeCwdSession('/www/app/.worktrees/test1', { git_repo_root: '/www/app' }), [])).toBe( '/www/app' ) }) it('routes an in-tree worktree session to the owning explicit project', () => { - const id = liveSessionProjectId(makeSession('/www/app/.worktrees/test1', { git_repo_root: '/www/app' }), [ + const id = liveSessionProjectId(makeCwdSession('/www/app/.worktrees/test1', { git_repo_root: '/www/app' }), [ makeProject('p_app', ['/www/app']) ]) @@ -532,13 +522,13 @@ describe('liveSessionProjectId', () => { // only the auto-project (repo root) fallback needs cwd-under-root // confidence. Match via the repo root... expect( - liveSessionProjectId(makeSession('/www/elsewhere', { git_repo_root: '/home/u/proj' }), [ + liveSessionProjectId(makeCwdSession('/www/elsewhere', { git_repo_root: '/home/u/proj' }), [ makeProject('p_proj', ['/home/u/proj']) ]) ).toBe('p_proj') // ...and via the cwd. expect( - liveSessionProjectId(makeSession('/www/elsewhere/sub', { git_repo_root: '/home/u/proj' }), [ + liveSessionProjectId(makeCwdSession('/www/elsewhere/sub', { git_repo_root: '/home/u/proj' }), [ makeProject('p_www', ['/www/elsewhere']) ]) ).toBe('p_www') @@ -547,13 +537,13 @@ describe('liveSessionProjectId', () => { it('matches a mixed-case/separator Windows cwd to its explicit project in the live overlay', () => { // The bug: a fresh Windows session drops into the overlay before the next // backend refresh; case-sensitive matching missed its project until then. - const id = liveSessionProjectId(makeSession('c:/work/notes/SUB'), [makeProject('p_notes', ['C:\\Work\\Notes'])]) + const id = liveSessionProjectId(makeCwdSession('c:/work/notes/SUB'), [makeProject('p_notes', ['C:\\Work\\Notes'])]) expect(id).toBe('p_notes') }) it('matches a root-relative WSL cwd (single backslash) case-insensitively', () => { - const id = liveSessionProjectId(makeSession('//wsl.localhost/Ubuntu/home/alice/PROJ'), [ + const id = liveSessionProjectId(makeCwdSession('//wsl.localhost/Ubuntu/home/alice/PROJ'), [ makeProject('p_proj', ['\\wsl.localhost\\Ubuntu\\home\\alice\\proj']) ]) @@ -562,7 +552,7 @@ describe('liveSessionProjectId', () => { it('keeps POSIX cwd matching case-sensitive (no false project match)', () => { // Distinct case on POSIX is a distinct path → falls back to its own auto id. - expect(liveSessionProjectId(makeSession('/work/notes'), [makeProject('p_notes', ['/Work/Notes'])])).toBe( + expect(liveSessionProjectId(makeCwdSession('/work/notes'), [makeProject('p_notes', ['/Work/Notes'])])).toBe( '/work/notes' ) }) @@ -575,19 +565,19 @@ describe('sessionProjectColor', () => { }) it('inherits the color of the explicit project the session belongs to', () => { - const session = makeSession('/www/app/src', { git_repo_root: '/www/app' }) + const session = makeCwdSession('/www/app/src', { git_repo_root: '/www/app' }) expect(sessionProjectColor(session, [colored('p_app', ['/www/app'], '#4a9eff')])).toBe('#4a9eff') }) it('returns null when the owning project has no color set', () => { - const session = makeSession('/www/app/src', { git_repo_root: '/www/app' }) + const session = makeCwdSession('/www/app/src', { git_repo_root: '/www/app' }) expect(sessionProjectColor(session, [makeProject('p_app', ['/www/app'])])).toBeNull() }) it('colors a cwd-less session by its git_repo_root project (the grouped-but-grey fix)', () => { - const session = makeSession(null, { git_repo_root: '/www/app' }) + const session = makeCwdSession(null, { git_repo_root: '/www/app' }) expect(sessionProjectColor(session, [colored('p_app', ['/www/app'], '#4a9eff')])).toBe('#4a9eff') }) @@ -595,7 +585,7 @@ describe('sessionProjectColor', () => { it('colors a cwd-outside-root session when an explicit project folder matches', () => { // The backend tree groups such a row under the project; the client color // derivation must agree instead of leaving the row (and its tab) grey. - const session = makeSession('/www/elsewhere', { git_repo_root: '/home/u/proj' }) + const session = makeCwdSession('/www/elsewhere', { git_repo_root: '/home/u/proj' }) expect(sessionProjectColor(session, [colored('p_proj', ['/home/u/proj'], '#4a9eff')])).toBe('#4a9eff') }) @@ -603,15 +593,15 @@ describe('sessionProjectColor', () => { it('returns null for a session that only maps to an auto repo root (no explicit project)', () => { // liveSessionProjectId falls back to the repo root id, which is not a // project row and therefore carries no color. - expect(sessionProjectColor(makeSession('/www/app'), [])).toBeNull() + expect(sessionProjectColor(makeCwdSession('/www/app'), [])).toBeNull() }) it('returns null for an unplaceable (cwd-less) session', () => { - expect(sessionProjectColor(makeSession(null), [colored('p_app', ['/www/app'], '#4a9eff')])).toBeNull() + expect(sessionProjectColor(makeCwdSession(null), [colored('p_app', ['/www/app'], '#4a9eff')])).toBeNull() }) it('uses the longest-prefix project when nested projects both match', () => { - const session = makeSession('/www/app/packages/api/src', { git_repo_root: '/www/app' }) + const session = makeCwdSession('/www/app/packages/api/src', { git_repo_root: '/www/app' }) const projects = [ colored('p_root', ['/www/app'], '#111111'), @@ -630,7 +620,7 @@ describe('overlayLiveLanes', () => { repos: [{ id: '/www/app', label: 'app', path: '/www/app', sessionCount: 0, groups: [] }] }) - const live = [makeSession('/www/app', { id: 'fresh', git_branch: 'main' })] + const live = [makeCwdSession('/www/app', { id: 'fresh', git_branch: 'main' })] const overlaid = overlayLiveLanes(project, live) const lane = overlaid.repos[0].groups.find(g => g.label === 'main') @@ -649,7 +639,7 @@ describe('overlayLiveLanes', () => { repos: [{ id: '/www/app', label: 'app', path: '/www/app', sessionCount: 0, groups: [] }] }) - const live = [makeSession('/www/app/.worktrees/baby', { id: 'fresh' })] + const live = [makeCwdSession('/www/app/.worktrees/baby', { id: 'fresh' })] const overlaid = overlayLiveLanes(project, live) const lane = overlaid.repos[0].groups.find(g => g.id === '/www/app/.worktrees/baby') @@ -665,7 +655,7 @@ describe('overlayLiveLanes', () => { repos: [{ id: '/www/app', label: 'app', path: '/www/app', sessionCount: 0, groups: [] }] }) - const live = [makeSession('/www/app/.worktrees/t_abc12345', { id: 'k' })] + const live = [makeCwdSession('/www/app/.worktrees/t_abc12345', { id: 'k' })] const overlaid = overlayLiveLanes(project, live) const lane = overlaid.repos[0].groups.find(g => g.isKanban) @@ -675,7 +665,7 @@ describe('overlayLiveLanes', () => { }) it('does not duplicate a session already present in a backend lane', () => { - const existing = makeSession('/www/app', { id: 'dup', git_branch: 'main' }) + const existing = makeCwdSession('/www/app', { id: 'dup', git_branch: 'main' }) const project = projectNode({ id: '/www/app', @@ -704,8 +694,8 @@ describe('overlayLiveLanes', () => { // and CREATE a second main lane with the same sessions — dual lanes in the // project drill-in (e.g. main + codex-research-guardian). const root = '/home/hermes/hermes-workspace/codex-research-guardian' - const a = makeSession(root, { id: 's1' }) // empty git_branch / git_repo_root - const b = makeSession(root, { id: 's2' }) + const a = makeCwdSession(root, { id: 's1' }) // empty git_branch / git_repo_root + const b = makeCwdSession(root, { id: 's2' }) const project = projectNode({ id: root, @@ -742,7 +732,7 @@ describe('overlayLiveLanes', () => { it('joins a fresh live session into an existing non-git workspace lane (no branch id)', () => { const root = '/work/notes' - const existing = makeSession(root, { id: 'old' }) + const existing = makeCwdSession(root, { id: 'old' }) const project = projectNode({ id: root, @@ -759,7 +749,7 @@ describe('overlayLiveLanes', () => { ] }) - const fresh = makeSession(root, { id: 'fresh' }) + const fresh = makeCwdSession(root, { id: 'fresh' }) const overlaid = overlayLiveLanes(project, [existing, fresh]) const groups = overlaid.repos[0].groups @@ -769,14 +759,14 @@ describe('overlayLiveLanes', () => { }) it('preserves backend recency order when live sessions overlay a lane', () => { - const recentlyActive = makeSession('/www/app', { + const recentlyActive = makeCwdSession('/www/app', { id: 'recently-active', git_branch: 'main', started_at: 1, last_active: 3 }) - const newlyCreated = makeSession('/www/app', { + const newlyCreated = makeCwdSession('/www/app', { id: 'newly-created', git_branch: 'main', started_at: 2, @@ -816,7 +806,7 @@ describe('overlayLiveLanes', () => { // Backend keyed the worktree lane off a branch-style id (no live git probe), // but the lane PATH is the worktree dir. A new session under that worktree // must join the existing lane, not spawn a twin. - const existing = makeSession('/www/app/.worktrees/baby', { id: 'old' }) + const existing = makeCwdSession('/www/app/.worktrees/baby', { id: 'old' }) const project = projectNode({ id: '/www/app', @@ -838,7 +828,7 @@ describe('overlayLiveLanes', () => { ] }) - const fresh = makeSession('/www/app/.worktrees/baby', { id: 'fresh' }) + const fresh = makeCwdSession('/www/app/.worktrees/baby', { id: 'fresh' }) const overlaid = overlayLiveLanes(project, [existing, fresh]) const lanes = overlaid.repos[0].groups.filter(g => g.path === '/www/app/.worktrees/baby') @@ -850,7 +840,7 @@ describe('overlayLiveLanes', () => { it('places a session into an out-of-tree (sibling) worktree lane by its path', () => { // `hermes-agent-ci` is a linked worktree living BESIDE the repo, not under // it — repo-root nesting fails, but the existing lane carries its real path. - const existing = makeSession('/www/app-ci', { id: 'old' }) + const existing = makeCwdSession('/www/app-ci', { id: 'old' }) const project = projectNode({ id: '/www/app', @@ -868,7 +858,7 @@ describe('overlayLiveLanes', () => { ] }) - const fresh = makeSession('/www/app-ci', { id: 'fresh' }) + const fresh = makeCwdSession('/www/app-ci', { id: 'fresh' }) const overlaid = overlayLiveLanes(project, [existing, fresh]) const ci = overlaid.repos[0].groups.find(g => g.path === '/www/app-ci') @@ -890,7 +880,7 @@ describe('overlayLiveLanes', () => { repos: [{ id: '/www/app', label: 'app', path: '/www/app', sessionCount: 0, groups }] }) - const fresh = makeSession('/www/app-retry', { id: 'fresh' }) + const fresh = makeCwdSession('/www/app-retry', { id: 'fresh' }) const overlaid = overlayLiveLanes(project, [fresh]) const lane = overlaid.repos[0].groups.find(g => g.path === '/www/app-retry') @@ -899,8 +889,8 @@ describe('overlayLiveLanes', () => { }) it('evicts a deleted/archived snapshot row (and drops the lane once empty)', () => { - const a = makeSession('/www/app', { id: 'keep', git_branch: 'main' }) - const b = makeSession('/www/app/.worktrees/baby', { id: 'gone' }) + const a = makeCwdSession('/www/app', { id: 'keep', git_branch: 'main' }) + const b = makeCwdSession('/www/app/.worktrees/baby', { id: 'gone' }) const project = projectNode({ id: '/www/app', @@ -927,11 +917,11 @@ describe('overlayLiveLanes', () => { }) it('adds a brand-new detached chat to Home, and evicts a deleted one', () => { - const existing = makeSession(null, { id: 'old', started_at: 1 }) - const doomed = makeSession(null, { id: 'gone', started_at: 2 }) + const existing = makeCwdSession(null, { id: 'old', started_at: 1 }) + const doomed = makeCwdSession(null, { id: 'gone', started_at: 2 }) const home = homeNode([existing, doomed]) - const overlaid = overlayLiveLanes(home, [makeSession(null, { id: 'fresh', started_at: 9 })], new Set(['gone'])) + const overlaid = overlayLiveLanes(home, [makeCwdSession(null, { id: 'fresh', started_at: 9 })], new Set(['gone'])) expect(overlaid.repos[0].groups[0].sessions.map(s => s.id)).toEqual(['fresh', 'old']) expect(overlaid.sessionCount).toBe(2) @@ -943,14 +933,14 @@ describe('overlayLiveLanes', () => { // and back out on the next snapshot. const home = homeNode([]) - expect(overlayLiveLanes(home, [makeSession('/www/app', { id: 'fresh' })])).toBe(home) + expect(overlayLiveLanes(home, [makeCwdSession('/www/app', { id: 'fresh' })])).toBe(home) }) it('evicts a session from the main lane when the live overlay places it into a worktree lane', () => { // Session was in main when the backend tree was captured, but the live // $sessions cache now has it under a worktree cwd. The overlay must place // it ONLY in the worktree lane — not both. - const session = makeSession('/www/app/.worktrees/feature', { id: 'moved', git_branch: 'feature' }) + const session = makeCwdSession('/www/app/.worktrees/feature', { id: 'moved', git_branch: 'feature' }) const project = projectNode({ id: '/www/app', @@ -989,10 +979,10 @@ describe('overlayLivePreviews', () => { it('merges live sessions into a project preview, live first, capped to the limit', () => { const project = projectNode({ id: '/www/app', - previewSessions: [makeSession('/www/app', { id: 'old', started_at: 1, last_active: 1 })] + previewSessions: [makeCwdSession('/www/app', { id: 'old', started_at: 1, last_active: 1 })] }) - const live = [makeSession('/www/app', { id: 'fresh', started_at: 99, last_active: 99 })] + const live = [makeCwdSession('/www/app', { id: 'fresh', started_at: 99, last_active: 99 })] const previews = overlayLivePreviews([project], live, [], 3) @@ -1003,8 +993,8 @@ describe('overlayLivePreviews', () => { const project = projectNode({ id: '/www/app', previewSessions: [ - makeSession('/www/app', { id: 'gone', started_at: 5, last_active: 5 }), - makeSession('/www/app', { id: 'old', started_at: 1, last_active: 1 }) + makeCwdSession('/www/app', { id: 'gone', started_at: 5, last_active: 5 }), + makeCwdSession('/www/app', { id: 'old', started_at: 1, last_active: 1 }) ] }) @@ -1017,9 +1007,9 @@ describe('overlayLivePreviews', () => { const project = projectNode({ id: '/www/app', previewSessions: [ - makeSession('/www/app', { id: 'newest', last_active: 9, started_at: 9 }), - makeSession('/www/app', { id: 'cheap', last_active: 8, started_at: 8 }), - makeSession('/www/app', { id: 'priciest', last_active: 1, started_at: 1 }) + makeCwdSession('/www/app', { id: 'newest', last_active: 9, started_at: 9 }), + makeCwdSession('/www/app', { id: 'cheap', last_active: 8, started_at: 8 }), + makeCwdSession('/www/app', { id: 'priciest', last_active: 1, started_at: 1 }) ] }) @@ -1029,7 +1019,7 @@ describe('overlayLivePreviews', () => { }) it('previews a detached session under Home, which no cwd could place', () => { - const previews = overlayLivePreviews([homeNode([])], [makeSession(null, { id: 'fresh' })], [], 3) + const previews = overlayLivePreviews([homeNode([])], [makeCwdSession(null, { id: 'fresh' })], [], 3) expect(previews[NO_PROJECT_ID].map(s => s.id)).toEqual(['fresh']) }) @@ -1037,8 +1027,8 @@ describe('overlayLivePreviews', () => { describe('excludeProjectSessions', () => { it('drops matching rows from every lane and recounts the subtree', () => { - const keep = makeSession('/www/app', { id: 'keep' }) - const pinnedRow = makeSession('/www/app', { id: 'pinned' }) + const keep = makeCwdSession('/www/app', { id: 'keep' }) + const pinnedRow = makeCwdSession('/www/app', { id: 'pinned' }) const project = projectNode({ id: '/www/app', @@ -1062,7 +1052,7 @@ describe('excludeProjectSessions', () => { }) it('keeps a lane the filter emptied — a worktree is structure, not a row', () => { - const pinnedRow = makeSession('/www/app/wt', { id: 'pinned' }) + const pinnedRow = makeCwdSession('/www/app/wt', { id: 'pinned' }) const project = projectNode({ id: '/www/app', @@ -1090,7 +1080,7 @@ describe('excludeProjectSessions', () => { it('returns the same node when nothing matches (memo-stable)', () => { const project = projectNode({ id: '/www/app', - previewSessions: [makeSession('/www/app', { id: 'keep' })], + previewSessions: [makeCwdSession('/www/app', { id: 'keep' })], repos: [ { id: '/www/app', @@ -1098,7 +1088,7 @@ describe('excludeProjectSessions', () => { path: '/www/app', sessionCount: 1, groups: [ - lane({ id: 'main', isMain: true, label: 'main', sessions: [makeSession('/www/app', { id: 'keep' })] }) + lane({ id: 'main', isMain: true, label: 'main', sessions: [makeCwdSession('/www/app', { id: 'keep' })] }) ] } ], @@ -1111,7 +1101,7 @@ describe('excludeProjectSessions', () => { it('survives the live overlay: a lane left empty by the filter is not pruned', () => { // The two run in sequence on an entered project (filter, then overlay), and // the overlay drops lanes it empties — it must not take the filter's with it. - const pinnedRow = makeSession('/www/app/wt', { id: 'pinned' }) + const pinnedRow = makeCwdSession('/www/app/wt', { id: 'pinned' }) const project = projectNode({ id: '/www/app', diff --git a/apps/desktop/src/app/chat/sidebar/session-actions-menu.test.tsx b/apps/desktop/src/app/chat/sidebar/session-actions-menu.test.tsx index 60de5f5ca5..21e276bb04 100644 --- a/apps/desktop/src/app/chat/sidebar/session-actions-menu.test.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-actions-menu.test.tsx @@ -247,12 +247,19 @@ describe('SessionActionsMenu', () => { expect(await screen.queryByRole('dialog')).toBeNull() expect(onDelete).not.toHaveBeenCalled() - // Re-open and confirm with Enter: the delete call fires. + // Re-open and confirm with Enter at wherever focus actually is. Firing on + // the dialog node would pass even when the menu leaves focus on the row + // trigger — where Enter re-activates the row instead of confirming. fireEvent.pointerDown(trigger, { button: 0, pointerType: 'mouse' }) fireEvent.pointerUp(trigger, { button: 0, pointerType: 'mouse' }) fireEvent.click(trigger) fireEvent.click(await screen.findByRole('menuitem', { name: /delete/i })) - fireEvent.keyDown(await screen.findByRole('dialog'), { key: 'Enter' }) + + const reopened = await screen.findByRole('dialog') + // eslint-disable-next-line no-restricted-globals -- asserting real focus requires the live document + await waitFor(() => expect(reopened.contains(document.activeElement)).toBe(true)) + // eslint-disable-next-line no-restricted-globals -- asserting real focus requires the live document + fireEvent.keyDown(document.activeElement!, { key: 'Enter' }) expect(await screen.findByText('Session deleted')).toBeTruthy() expect(onDelete).toHaveBeenCalledTimes(1) diff --git a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx index 2550800e72..70962073db 100644 --- a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx @@ -22,14 +22,7 @@ import { Codicon } from '@/components/ui/codicon' import { ColorSwatches } from '@/components/ui/color-swatches' import { ConfirmDialog } from '@/components/ui/confirm-dialog' import { CopyButton } from '@/components/ui/copy-button' -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - preventCloseButtonAutoFocus -} from '@/components/ui/dialog' +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { renameSession } from '@/hermes' import { useI18n } from '@/i18n' @@ -580,7 +573,6 @@ function DeleteSessionDialog({ open, onOpenChange, onConfirm, sessionTitle }: De doneLabel={r.deleted} onClose={() => onOpenChange(false)} onConfirm={onConfirm} - onOpenAutoFocus={preventCloseButtonAutoFocus} open={open} title={r.deleteTitle} /> diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx index 71e3cee766..553061f882 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx @@ -30,6 +30,7 @@ import { $projects } from '@/store/projects' import { $pullRequestsByBranch, sessionPrKey } from '@/store/pull-requests' import { $sessionDotStateById, hasLiveTurn, showsRunningArc } from '@/store/session-dot-state' import { $sessionListDensity } from '@/store/session-list-density' +import { $openStoredSessionIds } from '@/store/session-states' import { sessionCostUsd } from '@/store/sidebar-archive' import { $todoProgressBySession } from '@/store/todos' @@ -173,6 +174,10 @@ function SidebarSessionRowImpl({ // those branches should repaint. const prKey = sessionPrKey(session) const pr = useStoreSelector($pullRequestsByBranch, prs => (rowMeta.includes('pr') && prKey ? prs[prKey] : undefined)) + // Open in a pane, but not the focused one. A selector rather than a prop: + // it reaches all four row render paths at once, the set only changes when a + // tile opens or closes, and the boolean bails every unaffected row out. + const openUnfocused = useStoreSelector($openStoredSessionIds, open => !isSelected && open.has(session.id)) const totalTokens = session.input_tokens + session.output_tokens const cost = sessionCostUsd(session) @@ -352,6 +357,10 @@ function SidebarSessionRowImpl({ !card && density !== 'compact' && 'min-h-[2.75rem]', !card && density === 'detailed' && 'min-h-[3.875rem]', isSelected && 'bg-(--ui-row-active-background)', + // Open in another pane: the SAME band, just weaker. Its own mixed + // token rather than row opacity — dimming the whole row would take + // the title and the status dot down with it. + openUnfocused && 'bg-(--ui-row-open-background)', liveTurn && 'text-foreground', // Opaque surface while lifted so the dragged row erases what's under // it (translucency let the rows below bleed through). data-glass-opaque diff --git a/apps/desktop/src/app/chat/sidebar/strip-fts-markers.test.ts b/apps/desktop/src/app/chat/sidebar/strip-fts-markers.test.ts new file mode 100644 index 0000000000..1b33c7bc45 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/strip-fts-markers.test.ts @@ -0,0 +1,27 @@ +// Regression: the backend's session-search FTS layer wraps matched terms in +// literal '>>>' / '<<<' snippet() delimiters (hermes_state_search.py). The +// sidebar paints the snippet as plain text, so an unstripped marker renders +// rows titled ">>>foo<<<" (Aug 2026 desktop audit). +import { describe, expect, it } from 'vitest' + +import { stripFtsMarkers } from './index' + +describe('stripFtsMarkers', () => { + it('strips highlight markers around the matched term', () => { + expect(stripFtsMarkers('...replied with >>>MARCO<<< and nothing else...')).toBe( + '...replied with MARCO and nothing else...' + ) + }) + + it('strips multiple marked terms', () => { + expect(stripFtsMarkers('>>>alpha<<< then >>>beta<<<')).toBe('alpha then beta') + }) + + it('leaves marker-free snippets untouched', () => { + expect(stripFtsMarkers('plain snippet text')).toBe('plain snippet text') + }) + + it('handles empty string', () => { + expect(stripFtsMarkers('')).toBe('') + }) +}) diff --git a/apps/desktop/src/app/command-center/maintenance.tsx b/apps/desktop/src/app/command-center/maintenance.tsx index e8ee2f0e4e..d603bfc7fd 100644 --- a/apps/desktop/src/app/command-center/maintenance.tsx +++ b/apps/desktop/src/app/command-center/maintenance.tsx @@ -23,6 +23,7 @@ import { useI18n } from '@/i18n' import { AlertCircle } from '@/lib/icons' import { cn } from '@/lib/utils' import { upsertDesktopActionTask } from '@/store/activity' +import { confirm } from '@/store/confirm' import { notify, notifyError } from '@/store/notifications' import type { ActionStatusResponse } from '@/types/hermes' @@ -169,7 +170,7 @@ export function MaintenancePanel() { const doResetMemory = useCallback( async (target: 'all' | 'memory' | 'user', label: string) => { - if (!window.confirm(mm.resetConfirm(label))) { + if (!(await confirm({ destructive: true, title: mm.resetConfirm(label) }))) { return } diff --git a/apps/desktop/src/app/command-palette/highlight-watcher.test.tsx b/apps/desktop/src/app/command-palette/highlight-watcher.test.tsx index d2929fcc1e..2e92a24678 100644 --- a/apps/desktop/src/app/command-palette/highlight-watcher.test.tsx +++ b/apps/desktop/src/app/command-palette/highlight-watcher.test.tsx @@ -9,18 +9,12 @@ import { render } from '@testing-library/react' import { Command } from 'cmdk' import { describe, expect, it, vi } from 'vitest' +import { stubMenuDomApis, stubResizeObserver } from '@/test/jsdom' + import { HighlightWatcher } from './highlight-watcher' -// cmdk observes group headings with a ResizeObserver, which jsdom lacks. -class TestResizeObserver { - observe() {} - unobserve() {} - disconnect() {} -} - -vi.stubGlobal('ResizeObserver', TestResizeObserver) - -Element.prototype.scrollIntoView = function scrollIntoView() {} +stubResizeObserver() +stubMenuDomApis() const palette = (onValue: (value: string) => void, onRootValueChange?: (value: string) => void) => ( diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index 6a457aa6c1..da4d4f3742 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -18,6 +18,7 @@ import { Command, CommandGroup, CommandInput, CommandItem, CommandList } from '@ import { HighlightMatches } from '@/components/ui/highlight-matches' import { KbdCombo } from '@/components/ui/kbd' import { getHermesConfigRecord, listAllProfileSessions } from '@/hermes' +import { useMediaQuery } from '@/hooks/use-media-query' import { useI18n } from '@/i18n' import { sessionTitle } from '@/lib/chat-runtime' import { @@ -248,8 +249,8 @@ const rankGroups = (groups: PaletteGroup[], search: string): PaletteGroup[] => { .map(entry => entry.group) } -// cmdk selection values must be unique; labels alone can repeat (the same -// theme lists under both Light and Dark). The id suffix disambiguates. +// cmdk selection values must be unique; labels alone can repeat (a settings +// field and a session can share a title). The id suffix disambiguates. const paletteValue = (item: PaletteItem): string => `${item.label}\u0001${item.id}` const EMPTY_GROUPS: PaletteGroup[] = [] @@ -559,6 +560,16 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) { const { availableThemes, clearThemePreview, mode, previewTheme, resolvedMode, setMode, setTheme, themeName } = useTheme() + // Mode rows preview like theme rows do: paint the committed skin at the + // highlighted brightness. `system` has to be resolved here — previewTheme + // paints a concrete light/dark. + const systemDark = useMediaQuery('(prefers-color-scheme: dark)') + + const resolveThemeMode = useCallback( + (target: ThemeMode): 'light' | 'dark' => (target === 'system' ? (systemDark ? 'dark' : 'light') : target), + [systemDark] + ) + const [search, setSearch] = useState('') const [page, setPage] = useState(null) const inputRef = useRef(null) @@ -1148,6 +1159,7 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) { keepOpen: true, keywords: ['appearance', 'color mode', 'brightness', entry.mode, t.settings.modeOptions[entry.mode].label], label: t.settings.modeOptions[entry.mode].label, + onHighlight: () => previewTheme(themeName, resolveThemeMode(entry.mode)), run: () => setMode(entry.mode) })) }) @@ -1232,6 +1244,7 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) { mode, previewTheme, resolvedMode, + resolveThemeMode, search, sessions, setMode, @@ -1325,27 +1338,51 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) { } ] }, - // Built-ins and imported families list under the mode(s) they support; - // picking sets skin + mode at once. A multi-variant import (GitHub, - // Solarized) appears in both groups and switches variants with the mode. - ...(['light', 'dark'] as const).map(groupMode => ({ - heading: groupMode === 'light' ? t.settings.modeOptions.light.label : t.settings.modeOptions.dark.label, - items: availableThemes - .filter(theme => themeSupportsMode(theme.name, groupMode)) - .map(theme => ({ - active: themeName === theme.name && resolvedMode === groupMode, - icon: groupMode === 'light' ? Sun : Moon, - id: `theme-${theme.name}-${groupMode}`, + // Brightness lives with the palettes: one mode toggle for the whole + // list instead of splitting every theme across a Light and a Dark group. + { + heading: t.settings.appearance.colorMode, + items: THEME_MODES.map(entry => ({ + active: mode === entry.mode, + icon: entry.icon, + id: `theme-mode-${entry.mode}`, + keepOpen: true, + keywords: ['appearance', 'brightness', 'color mode', t.settings.modeOptions[entry.mode].label], + label: t.settings.modeOptions[entry.mode].label, + onHighlight: () => previewTheme(themeName, resolveThemeMode(entry.mode)), + run: () => setMode(entry.mode) + })) + }, + // Every palette once, applied on top of the selected mode. An import + // that only ships one variant (Dracula) flips the mode to the side it + // can actually render. + { + heading: t.settings.appearance.themeTitle, + items: availableThemes.map(theme => { + const previewMode = themeSupportsMode(theme.name, resolvedMode) + ? resolvedMode + : resolvedMode === 'dark' + ? 'light' + : 'dark' + + return { + active: themeName === theme.name, + icon: Palette, + id: `theme-${theme.name}`, keepOpen: true, - keywords: ['theme', 'appearance', 'palette', groupMode, theme.label, theme.description ?? ''], + keywords: ['theme', 'appearance', 'palette', theme.label, theme.description ?? ''], label: theme.label, - onHighlight: () => previewTheme(theme.name, groupMode), + onHighlight: () => previewTheme(theme.name, previewMode), run: () => { setTheme(theme.name) - setMode(groupMode) + + if (previewMode !== resolvedMode) { + setMode(previewMode) + } } - })) - })) + } + }) + } ] }, 'color-mode': { @@ -1361,6 +1398,7 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) { keepOpen: true, keywords: ['appearance', 'brightness', t.settings.modeOptions[entry.mode].label], label: t.settings.modeOptions[entry.mode].label, + onHighlight: () => previewTheme(themeName, resolveThemeMode(entry.mode)), run: () => setMode(entry.mode) })) } @@ -1387,7 +1425,18 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) { groups: settingsPageGroups } }), - [availableThemes, mode, previewTheme, resolvedMode, setMode, setTheme, settingsPageGroups, t, themeName] + [ + availableThemes, + mode, + previewTheme, + resolvedMode, + resolveThemeMode, + setMode, + setTheme, + settingsPageGroups, + t, + themeName + ] ) const activePage = page ? subPages[page] : null diff --git a/apps/desktop/src/app/command-palette/marketplace-theme-page.tsx b/apps/desktop/src/app/command-palette/marketplace-theme-page.tsx index 6766b2dae3..1adab907f3 100644 --- a/apps/desktop/src/app/command-palette/marketplace-theme-page.tsx +++ b/apps/desktop/src/app/command-palette/marketplace-theme-page.tsx @@ -10,9 +10,11 @@ import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' -import { useEffect, useState } from 'react' +import { useState } from 'react' +import { StatusRow } from '@/app/command-palette/status-row' import { HUD_ITEM, HUD_TEXT } from '@/app/floating-hud' +import { useDebounced } from '@/app/hooks/use-debounced' import type { DesktopMarketplaceSearchItem } from '@/global' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' @@ -23,18 +25,6 @@ import { $marketplaceInstalls } from '@/themes/user-themes' const compactNumber = new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 }) -function useDebounced(value: T, delayMs: number): T { - const [debounced, setDebounced] = useState(value) - - useEffect(() => { - const handle = setTimeout(() => setDebounced(value), delayMs) - - return () => clearTimeout(handle) - }, [value, delayMs]) - - return debounced -} - interface MarketplaceThemePageProps { search: string /** Activate a freshly installed theme by slug. */ @@ -90,17 +80,17 @@ export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePa } if (query.isLoading) { - return } text={copy.loading} /> + return } text={copy.loading} /> } if (query.isError) { - return + return } const results = query.data ?? [] if (results.length === 0) { - return + return } return ( @@ -156,17 +146,3 @@ export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePa
) } - -function Status({ icon, text, tone }: { icon?: React.ReactNode; text: string; tone?: 'error' }) { - return ( -
- {icon} - {text} -
- ) -} diff --git a/apps/desktop/src/app/command-palette/pet-palette-page.tsx b/apps/desktop/src/app/command-palette/pet-palette-page.tsx index 241bb43312..b23157f5d4 100644 --- a/apps/desktop/src/app/command-palette/pet-palette-page.tsx +++ b/apps/desktop/src/app/command-palette/pet-palette-page.tsx @@ -10,6 +10,7 @@ import { useStore } from '@nanostores/react' import { useEffect, useMemo } from 'react' +import { StatusRow } from '@/app/command-palette/status-row' import { HUD_ITEM, HUD_TEXT } from '@/app/floating-hud' import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' import { PetThumb } from '@/components/pet/pet-thumb' @@ -59,15 +60,15 @@ export function PetPalettePage({ search, onGenerate }: PetPalettePageProps) { } if (status === 'loading' && !gallery) { - return } text={copy.loading} /> + return } text={copy.loading} /> } if (status === 'stale') { - return + return } if (!gallery?.pets.length && error) { - return + return } const mutating = Boolean(busy) @@ -95,7 +96,7 @@ export function PetPalettePage({ search, onGenerate }: PetPalettePageProps) { {error &&

{error}

} {shown.length === 0 ? ( - + ) : ( shown.map(pet => { const isActive = enabled && pet.slug === active @@ -198,17 +199,3 @@ export function PetInlineToggle() { ) } - -function Status({ icon, text, tone }: { icon?: React.ReactNode; text: string; tone?: 'error' }) { - return ( -
- {icon} - {text} -
- ) -} diff --git a/apps/desktop/src/app/command-palette/status-row.tsx b/apps/desktop/src/app/command-palette/status-row.tsx new file mode 100644 index 0000000000..2fad2de52f --- /dev/null +++ b/apps/desktop/src/app/command-palette/status-row.tsx @@ -0,0 +1,18 @@ +import type React from 'react' + +import { cn } from '@/lib/utils' + +/** Centered loading / empty / error row for a Cmd-K page's result list. */ +export function StatusRow({ icon, text, tone }: { icon?: React.ReactNode; text: string; tone?: 'error' }) { + return ( +
+ {icon} + {text} +
+ ) +} diff --git a/apps/desktop/src/app/context-menu/app-context-menu.test.tsx b/apps/desktop/src/app/context-menu/app-context-menu.test.tsx new file mode 100644 index 0000000000..f7e93a211c --- /dev/null +++ b/apps/desktop/src/app/context-menu/app-context-menu.test.tsx @@ -0,0 +1,587 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { registerTerminalContextMenu } from '@/app/right-sidebar/terminal/terminal-context-menu' +import { ContextMenu, ContextMenuTrigger, HERMES_CONTEXT_MENU_TRIGGER_ATTR } from '@/components/ui/context-menu' +import { formatCombo } from '@/lib/keybinds/combo' +import { $previewTabs, closeRightRail } from '@/store/preview' +import { $connection } from '@/store/session' + +import { AppContextMenu } from './app-context-menu' +import { + $contextMenu, + augmentSpellcheck, + type GuestMenuHandle, + type GuestMenuParams, + openGuestContextMenu +} from './store' +import { resolveDomTarget } from './target' + +const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] } + +function installBridge(partial: Partial = {}) { + desktopWindow.hermesDesktop = { + openExternal: vi.fn().mockResolvedValue(undefined), + writeClipboard: vi.fn().mockResolvedValue(undefined), + ...partial + } as unknown as Window['hermesDesktop'] +} + +function mountMenu() { + return render( + + + + ) +} + +function attach(html: string): HTMLElement { + const host = document.createElement('div') + + host.innerHTML = html + document.body.appendChild(host) + + return host +} + +afterEach(() => { + $contextMenu.set(null) + $connection.set(null) + closeRightRail() + cleanup() + vi.restoreAllMocks() + document.body.innerHTML = '' + delete desktopWindow.hermesDesktop +}) + +describe('resolveDomTarget', () => { + it('resolves an anchor and its href', () => { + const host = attach('link') + + expect(resolveDomTarget(host.querySelector('a')).linkUrl).toBe('https://example.com/x') + }) + + it('ignores hash-only placeholder anchors', () => { + const host = attach('stub') + + expect(resolveDomTarget(host.querySelector('a')).linkUrl).toBe('') + }) + + it('resolves an image, and the anchor wrapping it', () => { + const host = attach('') + const target = resolveDomTarget(host.querySelector('img')) + + expect(target.onImage).toBe(true) + expect(target.imageUrl).toContain('pic.png') + expect(target.linkUrl).toBe('https://example.com/page') + }) + + it('resolves editables, skipping disabled and readonly fields', () => { + const host = attach('
x
') + + expect(resolveDomTarget(host.querySelector('textarea')).editable).toBeTruthy() + expect(resolveDomTarget(host.querySelector('input')).editable).toBeNull() + }) +}) + +describe('AppContextMenu', () => { + it('opens the link menu on a chat link right-click', async () => { + installBridge() + mountMenu() + const host = attach('Docs') + + fireEvent.contextMenu(host.querySelector('a')!) + + expect(await screen.findByText('Open in in-app browser')).toBeTruthy() + expect(screen.getByText('Open in external browser')).toBeTruthy() + expect(screen.getByText('Copy URL')).toBeTruthy() + expect(screen.queryByText('Copy resolved URL')).toBeNull() + }) + + it('opens the in-app browser from the link menu', async () => { + installBridge() + mountMenu() + const host = attach('Docs') + + fireEvent.contextMenu(host.querySelector('a')!) + fireEvent.click(await screen.findByText('Open in in-app browser')) + + await waitFor(() => expect($previewTabs.get().at(-1)?.target.url).toBe('https://example.com/docs')) + }) + + it('offers the resolved copy only for loopback links on a remote gateway', async () => { + $connection.set({ mode: 'remote' } as never) + const reachPreviewUrl = vi.fn(async () => 'http://127.0.0.1:45173/') + const writeClipboard = vi.fn().mockResolvedValue(undefined) + + installBridge({ + reachPreviewUrl: reachPreviewUrl as unknown as Window['hermesDesktop']['reachPreviewUrl'], + writeClipboard: writeClipboard as unknown as Window['hermesDesktop']['writeClipboard'] + }) + mountMenu() + const host = attach('Dev') + + fireEvent.contextMenu(host.querySelector('a')!) + fireEvent.click(await screen.findByText('Copy resolved URL')) + + await waitFor(() => expect(writeClipboard).toHaveBeenCalledWith('http://127.0.0.1:45173/')) + }) + + it('opens the image menu with copy, address, and save', async () => { + installBridge() + mountMenu() + const host = attach('pic') + + fireEvent.contextMenu(host.querySelector('img')!) + + expect(await screen.findByText('Copy image')).toBeTruthy() + expect(screen.getByText('Copy image address')).toBeTruthy() + expect(screen.getByText('Save image as…')).toBeTruthy() + }) + + it('opens the edit menu in an editable and augments it with spellcheck', async () => { + installBridge() + mountMenu() + const host = attach('') + + fireEvent.contextMenu(host.querySelector('textarea')!) + + expect(await screen.findByText('Select all')).toBeTruthy() + expect(screen.getByText('Paste')).toBeTruthy() + expect(screen.queryByText('Add to dictionary')).toBeNull() + + // The main-process forward lands after the menu opened. + augmentSpellcheck({ misspelledWord: 'teh', suggestions: ['the', 'ten'] }) + + expect(await screen.findByText('Add to dictionary')).toBeTruthy() + expect(screen.getByText('the')).toBeTruthy() + }) + + it('shows edit verbs without icons and with faded accelerators', async () => { + installBridge() + mountMenu() + const host = attach('') + + fireEvent.contextMenu(host.querySelector('textarea')!) + + // formatCombo picks ⌘/Ctrl from the host running the test, exactly like + // the menu itself — so the assertion is platform-honest, not hardcoded. + const pasteItem = (await screen.findByText('Paste')).closest('[data-slot="dropdown-menu-item"]')! + + expect(pasteItem.querySelector('[data-slot="dropdown-menu-shortcut"]')?.textContent).toBe(formatCombo('mod+v')) + expect(pasteItem.querySelector('.codicon')).toBeNull() + + const selectAllItem = screen.getByText('Select all').closest('[data-slot="dropdown-menu-item"]')! + + expect(selectAllItem.querySelector('[data-slot="dropdown-menu-shortcut"]')?.textContent).toBe(formatCombo('mod+a')) + expect(selectAllItem.querySelector('.codicon')).toBeNull() + }) + + it('runs edit verbs after the menu closed, with focus back on the editable', async () => { + const contextMenuEdit = vi.fn().mockResolvedValue(undefined) + + installBridge({ contextMenuEdit: contextMenuEdit as unknown as Window['hermesDesktop']['contextMenuEdit'] }) + mountMenu() + const host = attach('') + const textarea = host.querySelector('textarea')! + + // Cut/copy act on the selection, so give the field one. + textarea.setSelectionRange(0, 4) + fireEvent.contextMenu(textarea) + fireEvent.click(await screen.findByText('Copy')) + + // The verb waits a frame so the radix focus trap unmounts first — + // dispatching while the trap holds focus sent the command to `body`. + expect(contextMenuEdit).not.toHaveBeenCalled() + + await waitFor(() => expect(contextMenuEdit).toHaveBeenCalledWith('copy')) + expect($contextMenu.get()).toBeNull() + expect(document.activeElement).toBe(textarea) + }) + + it('grays out cut and copy when the field has text but no selection', async () => { + installBridge() + mountMenu() + const host = attach('') + const textarea = host.querySelector('textarea')! + + textarea.setSelectionRange(0, 0) + fireEvent.contextMenu(textarea) + + const item = (label: string) => screen.getByText(label).closest('[data-slot="dropdown-menu-item"]') as HTMLElement + + await screen.findByText('Select all') + + expect(item('Cut').getAttribute('data-disabled')).not.toBeNull() + expect(item('Copy').getAttribute('data-disabled')).not.toBeNull() + // Content is there, so select all stays live. + expect(item('Select all').getAttribute('data-disabled')).toBeNull() + }) + + it('enables cut and copy when the field has a selection', async () => { + installBridge() + mountMenu() + const host = attach('') + const textarea = host.querySelector('textarea')! + + textarea.setSelectionRange(0, 4) + fireEvent.contextMenu(textarea) + + const item = (label: string) => screen.getByText(label).closest('[data-slot="dropdown-menu-item"]') as HTMLElement + + await screen.findByText('Select all') + + expect(item('Cut').getAttribute('data-disabled')).toBeNull() + expect(item('Copy').getAttribute('data-disabled')).toBeNull() + }) + + it('select all stays inside the field and never reaches main', async () => { + const contextMenuEdit = vi.fn().mockResolvedValue(undefined) + + installBridge({ contextMenuEdit: contextMenuEdit as unknown as Window['hermesDesktop']['contextMenuEdit'] }) + mountMenu() + const host = attach('') + const textarea = host.querySelector('textarea')! + + fireEvent.contextMenu(textarea) + fireEvent.click(await screen.findByText('Select all')) + + // Renderer-side selection scoped to the field: main's selectAll acts on + // the focused FRAME and selected the whole transcript when focus + // slipped (the edit composer re-parents focus on blur). + await waitFor(() => { + expect(textarea.selectionStart).toBe(0) + expect(textarea.selectionEnd).toBe('alpha beta gamma'.length) + }) + expect(contextMenuEdit).not.toHaveBeenCalled() + expect(document.activeElement).toBe(textarea) + }) + + it('splits select all into its own section under the edit verbs', async () => { + installBridge() + mountMenu() + const host = attach('') + + fireEvent.contextMenu(host.querySelector('textarea')!) + + const selectAllItem = (await screen.findByText('Select all')).closest('[data-slot="dropdown-menu-item"]')! + const pasteItem = screen.getByText('Paste').closest('[data-slot="dropdown-menu-item"]')! + + // Sections render as sibling `.contents` wrappers with the separator + // inside the later one — different wrappers = different sections. + expect(pasteItem.parentElement).not.toBe(selectAllItem.parentElement) + expect(selectAllItem.parentElement?.querySelector('[data-slot="dropdown-menu-separator"]')).not.toBeNull() + }) + + it('grays out cut, copy, and select all in an empty field', async () => { + installBridge() + mountMenu() + const host = attach('') + + fireEvent.contextMenu(host.querySelector('textarea')!) + + const item = (label: string) => screen.getByText(label).closest('[data-slot="dropdown-menu-item"]') as HTMLElement + + await screen.findByText('Select all') + + expect(item('Cut').getAttribute('data-disabled')).not.toBeNull() + expect(item('Copy').getAttribute('data-disabled')).not.toBeNull() + expect(item('Select all').getAttribute('data-disabled')).not.toBeNull() + }) + + it('grays out paste until the clipboard reports text', async () => { + const readClipboard = vi.fn().mockResolvedValue('clip content') + + installBridge({ readClipboard: readClipboard as unknown as Window['hermesDesktop']['readClipboard'] }) + mountMenu() + const host = attach('') + + fireEvent.contextMenu(host.querySelector('textarea')!) + + // The clipboard read is async — the item enables when it lands. + const pasteItem = (await screen.findByText('Paste')).closest('[data-slot="dropdown-menu-item"]')! + + await waitFor(() => expect(pasteItem.getAttribute('data-disabled')).toBeNull()) + }) + + it('keeps paste grayed out on an empty clipboard', async () => { + const readClipboard = vi.fn().mockResolvedValue('') + + installBridge({ readClipboard: readClipboard as unknown as Window['hermesDesktop']['readClipboard'] }) + mountMenu() + const host = attach('') + + fireEvent.contextMenu(host.querySelector('textarea')!) + + const pasteItem = (await screen.findByText('Paste')).closest('[data-slot="dropdown-menu-item"]')! + + await waitFor(() => expect(readClipboard).toHaveBeenCalled()) + expect(pasteItem.getAttribute('data-disabled')).not.toBeNull() + }) + + it('offers the window verbs on bare chrome', async () => { + installBridge() + mountMenu() + const host = attach('

plain chrome

') + + fireEvent.contextMenu(host.querySelector('p')!) + + expect(await screen.findByText('Settings')).toBeTruthy() + }) + + it('skips plain right-clicks inside a skip-marked surface, but not links in it', async () => { + installBridge() + mountMenu() + + const host = attach( + '

bubble text

In-bubble
' + ) + + fireEvent.contextMenu(host.querySelector('p')!) + expect($contextMenu.get()).toBeNull() + + fireEvent.contextMenu(host.querySelector('a')!) + expect(await screen.findByText('Copy URL')).toBeTruthy() + }) + + it('leaves surfaces with their own radix menu alone', () => { + installBridge() + mountMenu() + const host = attach('
session row
') + + fireEvent.contextMenu(host.querySelector('span')!) + + expect($contextMenu.get()).toBeNull() + }) + + it('shows the terminal menu through a registered handle', async () => { + installBridge() + mountMenu() + const host = attach('
') + const paste = vi.fn() + + const unregister = registerTerminalContextMenu(host.querySelector('[data-terminal]')!, { + getSelection: () => 'picked text', + paste, + selectAll: vi.fn() + }) + + fireEvent.contextMenu(host.querySelector('canvas')!) + + expect(await screen.findByText('Copy')).toBeTruthy() + expect(screen.getByText('Paste')).toBeTruthy() + expect(screen.getByText('Select all')).toBeTruthy() + unregister() + }) + + it('hides paste on the read-only agent terminal', async () => { + installBridge() + mountMenu() + const host = attach('
') + + const unregister = registerTerminalContextMenu(host.querySelector('[data-terminal]')!, { + getSelection: () => '', + paste: null, + selectAll: vi.fn() + }) + + fireEvent.contextMenu(host.querySelector('canvas')!) + + expect(await screen.findByText('Select all')).toBeTruthy() + expect(screen.queryByText('Paste')).toBeNull() + unregister() + }) +}) + +describe('AppContextMenu guest (in-app browser)', () => { + const guestHandle = (overrides: Partial = {}): GuestMenuHandle => ({ + addToDictionary: vi.fn(), + copyImage: vi.fn(), + editCommand: vi.fn(), + inspectElement: vi.fn(), + replaceMisspelling: vi.fn(), + ...overrides + }) + + const guestParams = (overrides: Partial = {}): GuestMenuParams => ({ + dictionarySuggestions: [], + editFlags: { canCopy: true, canCut: true, canPaste: true, canSelectAll: true }, + hasImageContents: false, + isEditable: false, + linkURL: '', + misspelledWord: '', + selectionText: '', + srcURL: '', + ...overrides + }) + + it('shows text tools and inspect on a bare page right-click', async () => { + installBridge() + mountMenu() + const guest = guestHandle() + + openGuestContextMenu(10, 10, guestParams(), guest) + + expect(await screen.findByText('Select all')).toBeTruthy() + expect(screen.getByText('Inspect element')).toBeTruthy() + // The page verbs live on the browser bar only now. + expect(screen.queryByText('Copy page URL')).toBeNull() + expect(screen.queryByText('Open in browser')).toBeNull() + expect(screen.queryByText('Show preview console')).toBeNull() + }) + + it('draws a line between select all and inspect element', async () => { + installBridge() + mountMenu() + + openGuestContextMenu(10, 10, guestParams(), guestHandle()) + + const selectAllItem = (await screen.findByText('Select all')).closest('[data-slot="dropdown-menu-item"]')! + const inspectItem = screen.getByText('Inspect element').closest('[data-slot="dropdown-menu-item"]')! + + expect(selectAllItem.parentElement).not.toBe(inspectItem.parentElement) + expect(inspectItem.parentElement?.querySelector('[data-slot="dropdown-menu-separator"]')).not.toBeNull() + }) + + it('runs inspect element against the handle', async () => { + installBridge() + mountMenu() + const guest = guestHandle() + + openGuestContextMenu(10, 10, guestParams(), guest) + fireEvent.click(await screen.findByText('Inspect element')) + + expect(guest.inspectElement).toHaveBeenCalled() + }) + + it('adds a link section above the tools for guest links', async () => { + installBridge() + mountMenu() + + openGuestContextMenu(10, 10, guestParams({ linkURL: 'https://example.com/deep' }), guestHandle()) + + expect(await screen.findByText('Open in in-app browser')).toBeTruthy() + expect(screen.getByText('Copy URL')).toBeTruthy() + // Inspect element rides every guest menu; the bar-only verbs do not. + expect(screen.getByText('Inspect element')).toBeTruthy() + expect(screen.queryByText('Copy page URL')).toBeNull() + }) + + it('keeps inspect element in guest editable menus', async () => { + installBridge() + mountMenu() + + openGuestContextMenu(10, 10, guestParams({ isEditable: true }), guestHandle()) + + expect(await screen.findByText('Paste')).toBeTruthy() + expect(screen.getByText('Inspect element')).toBeTruthy() + expect(screen.queryByText('Copy page URL')).toBeNull() + expect(screen.queryByText('Open in browser')).toBeNull() + expect(screen.queryByText('Show preview console')).toBeNull() + }) + + it('grays out guest edit verbs from Chromium editFlags', async () => { + installBridge() + mountMenu() + + // An empty input: Chromium reports nothing to cut/copy/select, paste ok. + openGuestContextMenu( + 10, + 10, + guestParams({ + editFlags: { canCopy: false, canCut: false, canPaste: true, canSelectAll: false }, + isEditable: true + }), + guestHandle() + ) + + const item = (label: string) => screen.getByText(label).closest('[data-slot="dropdown-menu-item"]') as HTMLElement + + await screen.findByText('Select all') + + expect(item('Cut').getAttribute('data-disabled')).not.toBeNull() + expect(item('Copy').getAttribute('data-disabled')).not.toBeNull() + expect(item('Select all').getAttribute('data-disabled')).not.toBeNull() + expect(item('Paste').getAttribute('data-disabled')).toBeNull() + }) + + it('splits guest select all into its own section', async () => { + installBridge() + mountMenu() + + openGuestContextMenu(10, 10, guestParams({ isEditable: true }), guestHandle()) + + const selectAllItem = (await screen.findByText('Select all')).closest('[data-slot="dropdown-menu-item"]')! + const pasteItem = screen.getByText('Paste').closest('[data-slot="dropdown-menu-item"]')! + + expect(pasteItem.parentElement).not.toBe(selectAllItem.parentElement) + expect(selectAllItem.parentElement?.querySelector('[data-slot="dropdown-menu-separator"]')).not.toBeNull() + }) + + it('dispatches guest edit verbs a frame after the menu closes', async () => { + installBridge() + mountMenu() + const guest = guestHandle() + + openGuestContextMenu(10, 10, guestParams(), guest) + fireEvent.click(await screen.findByText('Select all')) + + // Deferred past the radix unmount so the webview focus() is not stolen + // back — dispatching with host focus selected the address bar + chat. + expect(guest.editCommand).not.toHaveBeenCalled() + + await waitFor(() => expect(guest.editCommand).toHaveBeenCalledWith('selectAll')) + expect($contextMenu.get()).toBeNull() + }) + + it('adds an image section with copy and save for guest images', async () => { + installBridge() + mountMenu() + + openGuestContextMenu( + 10, + 10, + guestParams({ hasImageContents: true, srcURL: 'https://example.com/pic.png' }), + guestHandle() + ) + + expect(await screen.findByText('Copy image')).toBeTruthy() + expect(screen.getByText('Save image as…')).toBeTruthy() + }) + + it('shows spell suggestions immediately for guest editables', async () => { + installBridge() + mountMenu() + const guest = guestHandle() + + openGuestContextMenu( + 10, + 10, + guestParams({ dictionarySuggestions: ['the'], isEditable: true, misspelledWord: 'teh' }), + guest + ) + + fireEvent.click(await screen.findByText('the')) + + expect(guest.replaceMisspelling).toHaveBeenCalledWith('the') + expect(screen.queryByText('Add to dictionary')).toBeNull() + }) +}) + +describe('ContextMenuTrigger asChild', () => { + it('keeps the coordinator marker when the child overwrites data-slot', () => { + render( + + +
bar
+
+
+ ) + + const footer = screen.getByText('bar') + + expect(footer.getAttribute('data-slot')).toBe('statusbar') + expect(footer.hasAttribute(HERMES_CONTEXT_MENU_TRIGGER_ATTR)).toBe(true) + }) +}) diff --git a/apps/desktop/src/app/context-menu/app-context-menu.tsx b/apps/desktop/src/app/context-menu/app-context-menu.tsx new file mode 100644 index 0000000000..17ca30fa38 --- /dev/null +++ b/apps/desktop/src/app/context-menu/app-context-menu.tsx @@ -0,0 +1,695 @@ +import { useStore } from '@nanostores/react' +import type { ReactNode } from 'react' +import { useEffect } from 'react' +import { useNavigate } from 'react-router' + +import { terminalMenuHandleFor } from '@/app/right-sidebar/terminal/terminal-context-menu' +import { Codicon } from '@/components/ui/codicon' +import { HERMES_CONTEXT_MENU_TRIGGER_ATTR } from '@/components/ui/context-menu' +import { writeClipboardText } from '@/components/ui/copy-button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { type Translations, useI18n } from '@/i18n' +import { hostPathLabel, normalizeExternalUrl, openExternalLink } from '@/lib/external-link' +import { formatCombo } from '@/lib/keybinds/combo' +import { isRemoteGateway } from '@/lib/media' +import { reachablePreviewUrl } from '@/lib/preview-reach' +import { openCommandPalette } from '@/store/command-palette' +import { openPreview } from '@/store/preview' +import { toggleStatusbarVisible } from '@/store/statusbar-prefs' +import { requestActiveUpdate } from '@/store/updates' +import { canOpenNewWindow, openNewWindow } from '@/store/windows' + +import { navigateToWorkspacePage, NEW_CHAT_ROUTE, SETTINGS_ROUTE } from '../routes' + +import { + $contextMenu, + augmentSpellcheck, + closeContextMenu, + type OpenContextMenu, + openDomContextMenu, + openTerminalContextMenu +} from './store' +import { isWebUrl, resolveDomTarget } from './target' + +/** Marks a surface that owns PLAIN right-clicks itself (the user-message + * reaction bubble). Owned targets inside it — links, images, editables, + * selections — still get the app menu. */ +export const CONTEXT_MENU_SKIP_ATTR = 'data-context-menu-skip' + +const LOOPBACK_HOST_RE = /^(localhost|127\.0\.0\.1|0\.0\.0\.0|\[?::1\]?)$/i + +// Accelerators shown beside the edit verbs. Display only — Chromium's +// before-input-event handling already executes the chords; the menu just +// advertises them the way the native menu did. `formatCombo` renders +// mod as ⌘ on macOS and Ctrl elsewhere. +const EDIT_SHORTCUTS = { + copy: formatCombo('mod+c'), + cut: formatCombo('mod+x'), + paste: formatCombo('mod+v'), + selectAll: formatCombo('mod+a') +} as const + +function isLoopbackUrl(url: string): boolean { + try { + return LOOPBACK_HOST_RE.test(new URL(url).hostname) + } catch { + return false + } +} + +/** An item: codicon + label, or label + faded right-aligned shortcut. Edit + * verbs (cut/copy/paste/select all) drop the icon and show the accelerator + * instead, like the native menus they replaced. */ +function Item({ + disabled, + icon, + label, + onSelect, + shortcut +}: { + disabled?: boolean + icon?: string + label: string + onSelect: () => void + shortcut?: string +}) { + return ( + + {icon ? : null} + {label} + {shortcut ? {shortcut} : null} + + ) +} + +type ShellVerbs = { + navigate: ReturnType + t: Translations +} + +function terminalSections(open: Extract, t: Translations): ReactNode[][] { + const { terminal } = open + const selection = terminal.getSelection() + + return [ + [ + selection ? ( + void writeClipboardText(selection)} + /> + ) : null, + terminal.paste ? ( + + void window.hermesDesktop?.readClipboard().then(text => (text ? terminal.paste?.(text) : undefined)) + } + /> + ) : null, + terminal.selectAll()} + /> + ].filter(Boolean) + ] +} + +function domSections(open: Extract, t: Translations): ReactNode[][] { + const copy = t.contextMenu + const { spellcheck, target } = open + const sections: ReactNode[][] = [] + const linkUrl = target.linkUrl ? normalizeExternalUrl(target.linkUrl) : '' + const linkIsWeb = isWebUrl(linkUrl) + const imageIsWeb = isWebUrl(target.imageUrl) + const showResolvedCopy = linkIsWeb && isRemoteGateway() && isLoopbackUrl(linkUrl) + + // The edit verbs and spell-check actions act on the sender's FOCUSED + // element in main. Focus cannot be restored while the menu is open: the + // radix content is a focus trap, so a focus() here is immediately stolen + // back, the command then runs against `body`, and select-all grabs the + // WHOLE transcript instead of the field. Close the menu first, then focus + // and dispatch on the next frame — after the trap is unmounted. + const withEditableFocus = (action: () => void) => { + const editable = target.editable + + closeContextMenu() + requestAnimationFrame(() => { + editable?.focus() + action() + }) + } + + const editableCommand = (command: 'copy' | 'cut' | 'paste') => { + withEditableFocus(() => void window.hermesDesktop?.contextMenuEdit?.(command)) + } + + // Select all runs entirely in the renderer, scoped to the editable itself. + // Main's selectAll acts on whatever the FOCUSED FRAME considers "all" and + // has no notion of the field the menu was opened on — with any focus slip + // (the edit composer re-parents focus on blur) it selected the whole + // transcript. A renderer range cannot escape the field. + const selectAllInEditable = () => { + withEditableFocus(() => { + const editable = target.editable + + if (editable instanceof HTMLInputElement || editable instanceof HTMLTextAreaElement) { + editable.select() + + return + } + + if (editable) { + const range = document.createRange() + + range.selectNodeContents(editable) + + const selection = window.getSelection() + + selection?.removeAllRanges() + selection?.addRange(range) + } + }) + } + + const spellcheckAction = (action: { kind: 'add' | 'replace'; word: string }) => { + withEditableFocus(() => void window.hermesDesktop?.contextMenuSpellcheck?.(action)) + } + + if (linkUrl) { + sections.push( + [ + linkIsWeb ? ( + + openPreview( + { kind: 'url', label: hostPathLabel(linkUrl), source: linkUrl, url: linkUrl }, + 'explicit-link' + ) + } + /> + ) : null, + openExternalLink(linkUrl)} + />, + void writeClipboardText(linkUrl)} + />, + showResolvedCopy ? ( + void reachablePreviewUrl(linkUrl).then(writeClipboardText)} + /> + ) : null + ].filter(Boolean) + ) + } + + if (target.onImage) { + sections.push( + [ + imageIsWeb ? ( + + openPreview( + { kind: 'url', label: hostPathLabel(target.imageUrl), source: target.imageUrl, url: target.imageUrl }, + 'explicit-link' + ) + } + /> + ) : null, + imageIsWeb ? ( + openExternalLink(target.imageUrl)} + /> + ) : null, + void window.hermesDesktop?.contextMenuCopyImage?.()} + />, + target.imageUrl ? ( + void writeClipboardText(target.imageUrl)} + /> + ) : null, + target.imageUrl ? ( + void window.hermesDesktop?.saveImageFromUrl?.(target.imageUrl)} + /> + ) : null + ].filter(Boolean) + ) + } + + if (target.editable) { + if (spellcheck) { + sections.push([ + ...spellcheck.suggestions + .slice(0, 5) + .map(suggestion => ( + spellcheckAction({ kind: 'replace', word: suggestion })} + /> + )), + spellcheckAction({ kind: 'add', word: spellcheck.misspelledWord })} + /> + ]) + } + + // Verb availability mirrors the native menu: cut/copy act on the + // SELECTION, so they need selected text — not just field content. + // Inputs and textareas carry their selection on the element (Chrome + // never reflects it into window.getSelection()); contenteditable uses + // the document selection the resolver captured. Paste needs a + // non-empty clipboard, select all needs the field to hold anything. + const formField = + target.editable instanceof HTMLInputElement || target.editable instanceof HTMLTextAreaElement + ? target.editable + : null + + const fieldText = formField ? formField.value : (target.editable?.textContent ?? '') + + const hasFieldText = fieldText.length > 0 + + const canCutCopy = formField + ? (formField.selectionStart ?? 0) !== (formField.selectionEnd ?? 0) + : target.selectionText.length > 0 + + sections.push([ + editableCommand('cut')} + shortcut={EDIT_SHORTCUTS.cut} + />, + editableCommand('copy')} + shortcut={EDIT_SHORTCUTS.copy} + />, + editableCommand('paste')} + shortcut={EDIT_SHORTCUTS.paste} + /> + ]) + sections.push([ + + ]) + } else if (target.selectionText) { + sections.push([ + void writeClipboardText(target.selectionText)} + /> + ]) + } + + return sections +} + +/** The guest (in-app browser) menu: link/image/selection/editable sections + * from the Chromium params, plus select all for the page, closed by + * Inspect element. The page-level verbs (copy URL, open externally, + * console) live on the browser bar, not here. */ +function guestSections(open: Extract, t: Translations): ReactNode[][] { + const copy = t.contextMenu + const { guest, params } = open + const sections: ReactNode[][] = [] + const linkUrl = params.linkURL + const imageUrl = params.srcURL + + // Same trap-timing rule as the dom side: dispatch AFTER the menu closes, + // so the webview's focus() is not stolen back by the radix content. + const guestEdit = (command: 'copy' | 'cut' | 'paste' | 'selectAll') => { + closeContextMenu() + requestAnimationFrame(() => guest.editCommand(command)) + } + + if (linkUrl) { + sections.push( + [ + isWebUrl(linkUrl) ? ( + + openPreview( + { kind: 'url', label: hostPathLabel(linkUrl), source: linkUrl, url: linkUrl }, + 'explicit-link' + ) + } + /> + ) : null, + openExternalLink(linkUrl)} + />, + void writeClipboardText(linkUrl)} + /> + ].filter(Boolean) + ) + } + + if (params.hasImageContents || imageUrl) { + sections.push( + [ + isWebUrl(imageUrl) ? ( + openExternalLink(imageUrl)} + /> + ) : null, + params.hasImageContents ? ( + + ) : null, + imageUrl ? ( + void writeClipboardText(imageUrl)} + /> + ) : null, + imageUrl ? ( + void window.hermesDesktop?.saveImageFromUrl?.(imageUrl)} + /> + ) : null + ].filter(Boolean) + ) + } + + if (params.isEditable) { + if (params.misspelledWord && params.dictionarySuggestions.length > 0) { + sections.push([ + ...params.dictionarySuggestions + .slice(0, 5) + .map(suggestion => ( + guest.replaceMisspelling(suggestion)} + /> + )), + guest.addToDictionary(params.misspelledWord)} + /> + ]) + } + + // Chromium's editFlags gate the verbs — the same availability verdict + // the native menu showed (empty field → no cut/copy/select-all, empty + // clipboard → no paste). + sections.push([ + guestEdit('cut')} + shortcut={EDIT_SHORTCUTS.cut} + />, + guestEdit('copy')} + shortcut={EDIT_SHORTCUTS.copy} + />, + guestEdit('paste')} + shortcut={EDIT_SHORTCUTS.paste} + /> + ]) + sections.push([ + guestEdit('selectAll')} + shortcut={EDIT_SHORTCUTS.selectAll} + /> + ]) + } else if (params.selectionText.trim()) { + sections.push([ + guestEdit('copy')} /> + ]) + } + + // Text tool for the page itself: select all works everywhere Chromium + // says it can (a bare page has no field to scope to, so it selects the + // page content). + if (!params.isEditable) { + sections.push([ + guestEdit('selectAll')} + shortcut={EDIT_SHORTCUTS.selectAll} + /> + ]) + } + + // Inspect element closes every guest menu — the one page tool that earns + // its place on any click. The other page verbs (copy URL, open + // externally, console) live on the browser bar only. + sections.push([ + + ]) + + return sections +} + +/** Bare right-click on app chrome: the window verbs (the old shell fallback). */ +function shellSections({ navigate, t }: ShellVerbs): ReactNode[][] { + return [ + [ + navigateToWorkspacePage(navigate, NEW_CHAT_ROUTE)} + />, + canOpenNewWindow() ? ( + void openNewWindow()} + /> + ) : null, + + ].filter(Boolean), + [ + , + navigateToWorkspacePage(navigate, SETTINGS_ROUTE)} + /> + ], + [ + + ] + ] +} + +/** + * THE app context menu: one capture-phase listener, one store, one menu. + * + * Every right-click in the app resolves here first. Radix-owned surfaces + * (session rows and other `context-menu-trigger` wrappers) keep their own + * menus; the reaction bubble keeps plain right-clicks; terminals answer + * through their registered xterm handles; everything else gets a menu + * assembled from what the click landed on — link, image, editable, + * selection — with the window verbs as the empty-target fallback. Replaced + * both the native Electron menu and the shell fallback wrapper, so labels + * come from the locale files like every other surface. + */ +export function AppContextMenu() { + const { t } = useI18n() + const navigate = useNavigate() + const open = useStore($contextMenu) + + useEffect(() => { + // stopPropagation beats other renderer handlers; preventDefault is never + // called because Chromium emits the main-process context-menu event (the + // spellcheck + image-coordinate source) only for unprevented gestures — + // and with no Menu.popup anywhere, "default" means no menu at all. + const onContextMenu = (event: MouseEvent) => { + const element = event.target instanceof Element ? event.target : null + + // Surfaces with their own Radix context menu keep the whole gesture. + // Guard the dedicated marker first: Radix `asChild` Slot merges + // `mergeProps(slotProps, childProps)` so the child's `data-slot` wins + // (status bar footer is `data-slot="statusbar"`). The marker is stamped + // after `{...props}` on ContextMenuTrigger and is not overwritten. + if (element?.closest(`[${HERMES_CONTEXT_MENU_TRIGGER_ATTR}], [data-slot="context-menu-trigger"]`)) { + return + } + + // A terminal's canvas has no DOM to resolve; its registered handle + // carries the xterm selection and paste path instead. + const terminal = terminalMenuHandleFor(element) + + if (terminal) { + event.stopPropagation() + openTerminalContextMenu(event.clientX, event.clientY, terminal) + + return + } + + const target = resolveDomTarget(element) + const owned = Boolean(target.linkUrl || target.onImage || target.editable || target.selectionText) + + // The reaction bubble owns bare right-clicks; a link inside it still + // opens the link menu. + if (!owned && element?.closest(`[${CONTEXT_MENU_SKIP_ATTR}]`)) { + return + } + + event.stopPropagation() + openDomContextMenu(event.clientX, event.clientY, target) + } + + window.addEventListener('contextmenu', onContextMenu, true) + + return () => window.removeEventListener('contextmenu', onContextMenu, true) + }, []) + + // Spell-check facts arrive from main after the menu opens (Chromium reports + // them on its own context-menu event); attach them to the open menu. + useEffect(() => window.hermesDesktop?.onContextMenuSpellcheck?.(augmentSpellcheck), []) + + if (!open) { + return null + } + + const sections = + open.kind === 'terminal' + ? terminalSections(open, t) + : open.kind === 'guest' + ? guestSections(open, t) + : (list => (list.length ? list : shellSections({ navigate, t })))(domSections(open, t)) + + return ( + { + if (!openState) { + closeContextMenu() + } + }} + open + > + + {/* A zero-size anchor at the click point: the menu positions against + it exactly like a real trigger. */} + + + event.preventDefault()} + side="bottom" + > + {sections.map((section, index) => ( + // Sections are positional by construction, so the index IS the key. +
+ {index > 0 && } + {section} +
+ ))} +
+
+ ) +} diff --git a/apps/desktop/src/app/context-menu/store.ts b/apps/desktop/src/app/context-menu/store.ts new file mode 100644 index 0000000000..f88a60ae66 --- /dev/null +++ b/apps/desktop/src/app/context-menu/store.ts @@ -0,0 +1,134 @@ +import { atom } from 'nanostores' + +import type { TerminalMenuHandle } from '@/app/right-sidebar/terminal/terminal-context-menu' + +import type { ContextMenuDomTarget } from './target' + +/** Spell-check facts for the open editable menu. They arrive AFTER the menu + * opens: Chromium reports them on the main-process `context-menu` event, + * which fires after the DOM gesture that opened the menu. */ +export interface SpellcheckContext { + misspelledWord: string + suggestions: string[] +} + +/** What the guest page reported for the click, straight off the webview's + * `context-menu` event. Unlike the DOM shape, spell-check facts ride along + * immediately — the guest event IS the Chromium report. */ +export interface GuestMenuParams { + /** Chromium's own availability verdict for the edit verbs at the click + * point. This is what grays out cut/copy/paste/select-all — the same + * source the native menu used. */ + editFlags: { + canCopy: boolean + canCut: boolean + canPaste: boolean + canSelectAll: boolean + } + dictionarySuggestions: string[] + hasImageContents: boolean + isEditable: boolean + linkURL: string + misspelledWord: string + selectionText: string + srcURL: string +} + +/** Verbs the preview pane binds over its webview element (and the guest IPC + * for the two things the tag cannot do: image bytes and the dictionary). */ +export interface GuestMenuHandle { + addToDictionary: (word: string) => void + copyImage: () => void + editCommand: (command: 'copy' | 'cut' | 'paste' | 'selectAll') => void + inspectElement: () => void + replaceMisspelling: (word: string) => void +} + +export type OpenContextMenu = + | { + kind: 'dom' + x: number + y: number + target: ContextMenuDomTarget + spellcheck: SpellcheckContext | null + /** Whether the clipboard held text when the menu opened (grays out + * Paste). Arrives async right after open; false until then. */ + clipboardHasText: boolean + } + | { + kind: 'guest' + x: number + y: number + params: GuestMenuParams + guest: GuestMenuHandle + } + | { + kind: 'terminal' + x: number + y: number + terminal: TerminalMenuHandle + /** Same async clipboard fact as the dom shape, for the paste item. */ + clipboardHasText: boolean + } + +/** The one open context menu, or null. A single atom because two context + * menus can never be open at once. */ +export const $contextMenu = atom(null) + +/** Read the clipboard and flag the OPEN menu when text is available. The + * read is an IPC round-trip, so the menu opens first (empty-clipboard + * verdict) and the flag lands a tick later — same late-fact pattern as + * spellcheck. Guarded by identity: a stale read never flags a newer menu. */ +function probeClipboard(opened: OpenContextMenu): void { + void window.hermesDesktop + ?.readClipboard?.() + .then((text: string) => { + const current = $contextMenu.get() + + if (current === opened && (current.kind === 'dom' || current.kind === 'terminal') && text) { + $contextMenu.set({ ...current, clipboardHasText: true }) + } + }) + .catch(() => undefined) +} + +export function openDomContextMenu(x: number, y: number, target: ContextMenuDomTarget): void { + const opened: OpenContextMenu = { kind: 'dom', x, y, target, spellcheck: null, clipboardHasText: false } + + $contextMenu.set(opened) + + if (target.editable) { + probeClipboard(opened) + } +} + +export function openGuestContextMenu(x: number, y: number, params: GuestMenuParams, guest: GuestMenuHandle): void { + $contextMenu.set({ kind: 'guest', x, y, params, guest }) +} + +export function openTerminalContextMenu(x: number, y: number, terminal: TerminalMenuHandle): void { + const opened: OpenContextMenu = { kind: 'terminal', x, y, terminal, clipboardHasText: false } + + $contextMenu.set(opened) + + if (terminal.paste) { + probeClipboard(opened) + } +} + +export function closeContextMenu(): void { + $contextMenu.set(null) +} + +/** Attach late-arriving spell-check facts to the open editable menu. Ignored + * when the menu already closed or the click was not in an editable — the + * forward always belongs to the gesture that opened the current menu. */ +export function augmentSpellcheck(payload: SpellcheckContext): void { + const open = $contextMenu.get() + + if (!open || open.kind !== 'dom' || !open.target.editable || !payload.misspelledWord) { + return + } + + $contextMenu.set({ ...open, spellcheck: payload }) +} diff --git a/apps/desktop/src/app/context-menu/target.ts b/apps/desktop/src/app/context-menu/target.ts new file mode 100644 index 0000000000..b1a60118e7 --- /dev/null +++ b/apps/desktop/src/app/context-menu/target.ts @@ -0,0 +1,58 @@ +/** + * What a right-click landed on, resolved from the DOM. + * + * One resolver so every surface agrees on ownership. Order encodes priority: + * an editable wins over the link wrapping it (the caret is where the user is + * working), a link wins over the image inside it for the LINK section — the + * image section still appears because the target carries both. + */ + +export interface ContextMenuDomTarget { + /** The clicked editable, when the click landed in one. */ + editable: HTMLElement | null + /** `href` of the enclosing anchor, as written (never absolutized). */ + linkUrl: string + /** Source URL of the clicked image, when the click landed on one. */ + imageUrl: string + /** True when the click landed on an `` (imageUrl may still be empty + * for a broken image; Copy image works through coordinates either way). */ + onImage: boolean + /** The live selection's text at the moment of the click. */ + selectionText: string +} + +/** Form fields and `contenteditable` hosts. Mirrors the keybind helper, but + * returns the element so the menu can act on it. */ +function editableFrom(element: Element | null): HTMLElement | null { + if (!element) { + return null + } + + if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) { + return element.disabled || element.readOnly ? null : element + } + + const host = element.closest('[contenteditable]') + + return host instanceof HTMLElement && host.isContentEditable ? host : null +} + +export function resolveDomTarget(element: Element | null): ContextMenuDomTarget { + const anchor = element?.closest('a[href]') + const image = element?.closest('img') + const linkUrl = anchor?.getAttribute('href')?.trim() ?? '' + + return { + editable: editableFrom(element), + // A placeholder anchor is not a link the menu can act on. + linkUrl: linkUrl === '#' ? '' : linkUrl, + imageUrl: image instanceof HTMLImageElement ? image.currentSrc || image.src : '', + onImage: Boolean(image), + selectionText: window.getSelection()?.toString().trim() ?? '' + } +} + +/** True when `url` is something the in-app browser can render. */ +export function isWebUrl(url: string): boolean { + return /^https?:\/\//i.test(url) +} diff --git a/apps/desktop/src/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx index f14f957c33..f5d400a204 100644 --- a/apps/desktop/src/app/contrib/controller.tsx +++ b/apps/desktop/src/app/contrib/controller.tsx @@ -29,6 +29,7 @@ import { removeTreePane, resetLayoutTree, revealTreePane, + setStripTabHidden, togglePaneVisible, watchContributedPanes } from '@/components/pane-shell/tree/store' @@ -38,6 +39,7 @@ import { Slot } from '@/contrib/react/slot' import { useContributions } from '@/contrib/react/use-contributions' import { registry } from '@/contrib/registry' import { discoverRuntimePlugins } from '@/contrib/runtime-loader' +import { translateNow } from '@/i18n' import { NEW_SESSION_TITLE, sessionTitle as storedSessionTitle } from '@/lib/chat-runtime' import { Download, FileText, LayoutDashboard, PanelBottom, Terminal, Upload, Zap } from '@/lib/icons' import { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions' @@ -57,7 +59,14 @@ import { SIDEBAR_MAX_WIDTH } from '@/store/layout' import { runExportProfileFlow, runImportProfileFlow } from '@/store/profile-share' -import { $reviewOpen, closeReview, openReview, REVIEW_PANE_ID } from '@/store/review' +import { + $reviewOpen, + $reviewScopeCwd, + $reviewScopeTarget, + closeReview, + openReview, + REVIEW_PANE_ID +} from '@/store/review' import { $currentCwd, $selectedStoredSessionId, $sessions, $yoloActive, sessionMatchesStoredId } from '@/store/session' import { watchSessionPins } from '@/store/session-pin-sync' import { watchUnreadWriteGuard } from '@/store/session-unread-remote' @@ -74,10 +83,10 @@ import { watchSessionTiles, WorkspaceTabMenu } from '../chat/session-tile' +import { AppContextMenu } from '../context-menu/app-context-menu' import { HudShell } from '../hud/hud-shell' import { $terminalTakeover, setTerminalTakeover } from '../right-sidebar/store' import { $workspaceIsPage } from '../routes' -import { ShellContextMenu } from '../shell/shell-context-menu' import { FilesPane, LogsPane, ReviewPaneContent } from './panes' import { ContribWiring, WiredPane } from './wiring' @@ -154,6 +163,10 @@ registry.registerMany([ collapsible: true, dock: { pane: 'workspace', pos: 'left' }, revealAliases: ['chat-sidebar'], + showCloseButton: false, + // Standing chrome: no close gestures at all — the tab is shown/hidden + // (zone menu Show/Hide rows + the auto-registered ⌘K toggle below). + hideOnly: true, width: `${SIDEBAR_DEFAULT_WIDTH}px`, minWidth: `${SIDEBAR_DEFAULT_WIDTH}px`, maxWidth: `${SIDEBAR_MAX_WIDTH}px` @@ -574,7 +587,7 @@ bindPaneVisibility( 'review', computed([$reviewOpen, $hasWorkspace], (open, workspace) => open && workspace), closeReview, - openReview + () => openReview($reviewScopeCwd.get(), $reviewScopeTarget.get()) ) // ⌃` / statusbar toggle — the terminal COLLAPSES to a rail (tab stays), not // hides; PTYs stay alive while collapsed (see PersistentTerminal). @@ -672,6 +685,64 @@ registry.register( }) ) +// Hide-only chrome tabs (sessions / Bots) get a ⌘K toggle each — the palette +// door onto the same show/hide the zone menu offers. Auto-registered from the +// panes area so a plugin's hideOnly pane (Bots registers at plugin load, after +// this module runs) gets its row for free; disposers keep it in step when a +// plugin unloads. Registry writes during a subscriber callback are safe (the +// registry snapshots per-area and re-notifies), and re-registering the same +// palette id replaces the row instead of stacking duplicates. +{ + const stripTabToggles = new Map void>() + + const syncStripTabToggles = () => { + const hideOnlyPanes = registry + .getArea('panes') + .filter(c => (c.data as { hideOnly?: boolean } | undefined)?.hideOnly) + + const wanted = new Set(hideOnlyPanes.map(c => c.id)) + + for (const [paneId, dispose] of stripTabToggles) { + if (!wanted.has(paneId)) { + dispose() + stripTabToggles.delete(paneId) + } + } + + for (const pane of hideOnlyPanes) { + if (stripTabToggles.has(pane.id)) { + continue + } + + const title = String(pane.title ?? pane.id) + + stripTabToggles.set( + pane.id, + registry.register( + paletteToggle({ + id: `strip-tab.${pane.id}`, + label: translateNow('zones.toggleStripTab', title), + icon: LayoutDashboard, + keywords: [title.toLowerCase(), 'tab', 'pane', 'sidebar', 'show', 'hide'], + // On-screen truth, same contract as the logs toggle above. + get: () => isPaneVisible(pane.id), + set: visible => { + if (visible) { + revealTreePane(pane.id) + } else { + setStripTabHidden(pane.id, true) + } + } + }) + ) + ) + } + } + + syncStripTabToggles() + registry.subscribeArea('panes', syncStripTabToggles) +} + // YOLO (dangerous-command approval bypass) is a status-bar zap and a /yolo // command; ⌘K is the third door onto the SAME store function, so a user who // lives in the palette never has to hunt for the pill. @@ -744,18 +815,18 @@ export function ContribController() { style={{ '--sidebar-width': '100%' } as CSSProperties} > - -
is the only thing between the page and the - // vibrancy material. - data-contrib-shell="" - style={{ '--titlebar-height': '0px' } as CSSProperties} - > - {/* Title bar: fixed chrome outside the grid, composable via slots. + +
is the only thing between the page and the + // vibrancy material. + data-contrib-shell="" + style={{ '--titlebar-height': '0px' } as CSSProperties} + > + {/* Title bar: fixed chrome outside the grid, composable via slots. Layout contract (no contribution can break it): - a full-bar DRAG BASE underneath (pointer-events-none, like AppShell's drag strips) — everywhere without content drags @@ -766,57 +837,56 @@ export function ContribController() { tree-published --workspace-left/right vars (pure CSS, no rect threading), clamped to clear the REAL TitlebarControls clusters (fixed, z-70); center is truly window-centered. */} -
- {/* Drag strips, AppShell-style: cut to AVOID the fixed control +
+ {/* Drag strips, AppShell-style: cut to AVOID the fixed control clusters instead of overlapping them — Electron's no-drag carve-out of fixed/transformed elements is unreliable, so a full-bar drag base kills their clicks. In-flow slot content still carves via its own no-drag wrapper (the same pattern as the app's session-title button). */} - ) diff --git a/apps/desktop/src/app/contrib/dev/credits-notice-demo.ts b/apps/desktop/src/app/contrib/dev/credits-notice-demo.ts index 1766489818..4f934b8e53 100644 --- a/apps/desktop/src/app/contrib/dev/credits-notice-demo.ts +++ b/apps/desktop/src/app/contrib/dev/credits-notice-demo.ts @@ -2,7 +2,7 @@ // usage bands. Each trigger emits ONE synthetic `notification.show` / // `notification.clear` gateway event through the real fan-out // (`emitLocalGatewayEvent`), so it exercises the actual dispatcher branch in -// `use-message-stream/gateway-event.ts` — toast render, key-replacement +// `use-message-stream/gateway-event/status.ts` — toast render, key-replacement // escalation, TTL self-dismiss, native OS notification, and billing re-poll. // // Installed only under `import.meta.env.DEV` (see contrib/wiring.tsx), so none diff --git a/apps/desktop/src/app/contrib/hooks/use-background-sync.test.ts b/apps/desktop/src/app/contrib/hooks/use-background-sync.test.ts index 1b70bb29bd..bef4635b70 100644 --- a/apps/desktop/src/app/contrib/hooks/use-background-sync.test.ts +++ b/apps/desktop/src/app/contrib/hooks/use-background-sync.test.ts @@ -83,6 +83,7 @@ function useSyncHarness({ refreshActiveTranscript: () => Promise }) { useBackgroundSync({ + activeConnectionId: 'local', activeGatewayProfile: 'default', activeIsMessaging, activeSessionId, diff --git a/apps/desktop/src/app/contrib/hooks/use-background-sync.test.tsx b/apps/desktop/src/app/contrib/hooks/use-background-sync.test.tsx index c11a40bc15..cfea238630 100644 --- a/apps/desktop/src/app/contrib/hooks/use-background-sync.test.tsx +++ b/apps/desktop/src/app/contrib/hooks/use-background-sync.test.tsx @@ -9,16 +9,18 @@ import { useBackgroundSync } from './use-background-sync' const noop = () => undefined const requestGateway = async () => ({ sessions: [] }) -function render(activeGatewayProfile: string, refreshSessions: () => Promise) { +function render(activeGatewayProfile: string, activeConnectionId: string, refreshSessions: () => Promise) { return renderHook( - ({ profile }: { profile: string }) => { + ({ connectionId, profile }: { connectionId: string; profile: string }) => { useBackgroundSync({ + activeConnectionId: connectionId, activeGatewayProfile: profile, activeIsMessaging: false, activeSessionId: null, + activeStoredSessionId: null, freshDraftReady: false, gatewayState: 'open', - refreshActiveMessagingTranscript: noop, + refreshActiveTranscript: noop, refreshCronJobs: noop, refreshCurrentModel: noop, refreshHermesConfig: noop, @@ -27,7 +29,7 @@ function render(activeGatewayProfile: string, refreshSessions: () => Promise { it('refreshes the session list after the active gateway profile changes', async () => { const refreshSessions = vi.fn(async () => undefined) - const hook = render('default', refreshSessions) + const hook = render('default', 'local', refreshSessions) await act(async () => undefined) expect(refreshSessions).toHaveBeenCalledTimes(1) refreshSessions.mockClear() - hook.rerender({ profile: 'nova' }) + hook.rerender({ connectionId: 'local', profile: 'nova' }) + + await act(async () => undefined) + expect(refreshSessions).toHaveBeenCalledTimes(1) + }) + + it('refreshes the session list when the backend changes but the profile name does not', async () => { + const refreshSessions = vi.fn(async () => undefined) + const hook = render('default', 'work', refreshSessions) + + await act(async () => undefined) + refreshSessions.mockClear() + + hook.rerender({ connectionId: 'homelab', profile: 'default' }) await act(async () => undefined) expect(refreshSessions).toHaveBeenCalledTimes(1) diff --git a/apps/desktop/src/app/contrib/hooks/use-background-sync.ts b/apps/desktop/src/app/contrib/hooks/use-background-sync.ts index 0db1ef4deb..b1c51e0966 100644 --- a/apps/desktop/src/app/contrib/hooks/use-background-sync.ts +++ b/apps/desktop/src/app/contrib/hooks/use-background-sync.ts @@ -284,6 +284,7 @@ export function resetLiveRuntimeTracking(): void { } interface BackgroundSyncParams { + activeConnectionId: null | string activeGatewayProfile: string activeIsMessaging: boolean activeSessionId: null | string @@ -351,6 +352,7 @@ function visiblePoll(intervalMs: number, tick: () => void): () => void { * All the "the desktop websocket won't tell us, so poll" logic in one place. */ export function useBackgroundSync({ + activeConnectionId, activeGatewayProfile, activeIsMessaging, activeSessionId, @@ -441,7 +443,7 @@ export function useBackgroundSync({ }) .catch(() => undefined) } - }, [activeGatewayProfile, gatewayState, refreshCurrentModel, refreshSessions, requestGateway]) + }, [activeConnectionId, activeGatewayProfile, gatewayState, refreshCurrentModel, refreshSessions, requestGateway]) // A reconnect loses renderer-only working/attention atoms while the backend // keeps the actual turns alive. Re-seed from the gateway's in-memory session diff --git a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.test.tsx b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.test.tsx index 6a6e47397a..5dd54083cf 100644 --- a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.test.tsx +++ b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.test.tsx @@ -6,6 +6,8 @@ import { _resetLegacyDiscardForTests } from '@/store/session' import type * as WindowsStore from '@/store/windows' import type { SessionInfo } from '@/types/hermes' +import { makeSessionInfo } from '../../../test/session-info' + import { useDesktopIntegrations } from './use-desktop-integrations' // Mutable HUD-window flag so the restore tests can flip the window kind the @@ -36,24 +38,7 @@ vi.mock('@/store/windows', async importOriginal => { const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] } const initialHermesDesktop = desktopWindow.hermesDesktop -const session = (over: Partial = {}): SessionInfo => ({ - archived: false, - cwd: null, - ended_at: null, - id: 'live', - input_tokens: 0, - is_active: false, - last_active: 0, - message_count: 0, - model: null, - output_tokens: 0, - preview: null, - source: null, - started_at: 0, - title: null, - tool_call_count: 0, - ...over -}) +const session = (over: Partial = {}): SessionInfo => makeSessionInfo({ id: 'live', ...over }) describe('useDesktopIntegrations', () => { let navigate: ReturnType void>> diff --git a/apps/desktop/src/app/contrib/mcp-install-deeplink-dialog.tsx b/apps/desktop/src/app/contrib/mcp-install-deeplink-dialog.tsx index aa4b1b3cab..a657c98687 100644 --- a/apps/desktop/src/app/contrib/mcp-install-deeplink-dialog.tsx +++ b/apps/desktop/src/app/contrib/mcp-install-deeplink-dialog.tsx @@ -16,19 +16,12 @@ import { getHermesConfigRecord, saveMcpServers } from '@/hermes' import { useI18n } from '@/i18n' import { AlertTriangle } from '@/lib/icons' import { MCP_DEEPLINK_NAME_RE } from '@/lib/mcp-deeplink' +import { getServers } from '@/lib/mcp-servers' import { $mcpInstallRequest } from '@/store/mcp-deeplink-install' import { notify, readableError } from '@/store/notifications' import { setHermesConfigCache } from '../hooks/use-config-record' -type McpServers = Record> - -const getServers = (config: { mcp_servers?: unknown } | null): McpServers => { - const raw = config?.mcp_servers - - return raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as McpServers) : {} -} - /** * Explicit-confirm gate for `hermes://mcp/install` deep links. The payload is * arbitrary attacker-controllable input (any web page can open the link), so diff --git a/apps/desktop/src/app/contrib/surfaces.test.tsx b/apps/desktop/src/app/contrib/surfaces.test.tsx index 472bf8cbea..361590226c 100644 --- a/apps/desktop/src/app/contrib/surfaces.test.tsx +++ b/apps/desktop/src/app/contrib/surfaces.test.tsx @@ -11,6 +11,7 @@ import { ChatRoutesSurface } from './surfaces' import type { WiringActions } from './types' vi.mock('@/contrib/react/use-contributions', () => ({ useContributions: vi.fn() })) +vi.mock('@/store/connections', () => ({ $activeConnectionId: atom('local') })) vi.mock('@/store/gateway', () => ({ $gateway: atom(null) })) vi.mock('@/store/profile', () => ({ $activeGatewayProfile: atom('default') })) vi.mock('@/store/session', () => ({ diff --git a/apps/desktop/src/app/contrib/surfaces.tsx b/apps/desktop/src/app/contrib/surfaces.tsx index abd3fb04d0..d2108a751f 100644 --- a/apps/desktop/src/app/contrib/surfaces.tsx +++ b/apps/desktop/src/app/contrib/surfaces.tsx @@ -13,6 +13,7 @@ import { Navigate, Route, Routes, useParams } from 'react-router' import { ContribBoundary, ContribRender } from '@/contrib/react/boundary' import { useContributions } from '@/contrib/react/use-contributions' +import { $activeConnectionId } from '@/store/connections' import { $gateway } from '@/store/gateway' import { $activeGatewayProfile } from '@/store/profile' import { $freshDraftReady, $gatewayState } from '@/store/session' @@ -77,9 +78,12 @@ export const StatusbarSurface = memo(function StatusbarSurface({ chatOpen: boolean commandCenterOpen: boolean }) { + const activeConnectionId = useStore($activeConnectionId) + const activeGatewayProfile = useStore($activeGatewayProfile) const gatewayState = useStore($gatewayState) const freshDraftReady = useStore($freshDraftReady) - const { inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, actions.requestGateway) + const gatewayScope = `${activeConnectionId ?? ''}\0${activeGatewayProfile}` + const { inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, actions.requestGateway, gatewayScope) const extraLeftItems = useStatusbarContributions('left') const extraRightItems = useStatusbarContributions('right') diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index bdea85b421..1b418400ea 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -16,6 +16,7 @@ import { useLocation, useNavigate } from 'react-router' import { graftRefreshedTailOntoBackfill } from '@/app/chat/transcript-backfill' import { formatRefValue } from '@/components/assistant-ui/directive-text' import { BootFailureOverlay } from '@/components/boot-failure-overlay' +import { ConfirmHost } from '@/components/confirm-host' import { DesktopInstallOverlay } from '@/components/desktop-install-overlay' import { FindBar } from '@/components/find-bar' import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay' @@ -34,6 +35,7 @@ import { playWakeSound } from '@/lib/wake-sound' import { $billingSettingsRequest } from '@/store/billing-block' import { $desktopBoot } from '@/store/boot' import { requestVoiceConversationStart } from '@/store/composer' +import { $activeConnectionId } from '@/store/connections' import { $cronReviewRequest, setCronFocusJobId } from '@/store/cron' import { $pinnedSessionIds, pinSession, restoreWorktree, unpinSession } from '@/store/layout' import { $previewTarget } from '@/store/preview' @@ -61,12 +63,14 @@ import { $selectedStoredSessionId, $sessionResumeRequest, $sessions, + rememberedSessionProfile, sessionMatchesStoredId, sessionPinId, setAwaitingResponse, setBusy, setMessages } from '@/store/session' +import { requestForSessionProfile } from '@/store/session-request-router' import { clearSessionTodos, setSessionTodos, todosForHydration } from '@/store/todos' import { armWakeWord, stopClientCapture } from '@/store/wake-word' import { isAuxiliaryWindow, isHudWindow } from '@/store/windows' @@ -212,6 +216,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { const selectedStoredSessionId = useStore($selectedStoredSessionId) const messagingSessions = useStore($messagingSessions) const sessions = useStore($sessions) + const activeConnectionId = useStore($activeConnectionId) const activeGatewayProfile = useStore($activeGatewayProfile) const profileScope = useStore($profileScope) const boot = useStore($desktopBoot) @@ -277,7 +282,22 @@ export function ContribWiring({ children }: { children: ReactNode }) { setMessages }) - const { connectionRef, gateway, gatewayRef, requestGateway } = useGatewayRequest() + const { connectionRef, gateway, gatewayRef, requestGateway: ambientRequestGateway } = useGatewayRequest() + + // When chrome stays on the launch backend (Bot Mode / all-profiles + // navigation), session-owned RPCs still have to hit the session's backend. + const requestGateway = useCallback( + (method: string, params?: Record, timeoutMs?: number, signal?: AbortSignal) => { + const owner = rememberedSessionProfile( + $sessions.get(), + selectedStoredSessionIdRef.current, + $activeGatewayProfile.get() + ) + + return requestForSessionProfile(owner, ambientRequestGateway, method, params ?? {}, timeoutMs, signal) + }, + [ambientRequestGateway] + ) const { loadMoreMessagingForPlatform, loadMoreSessions, refreshCronJobs, refreshMessagingSessions, refreshSessions } = useSessionListActions({ profileScope }) @@ -491,25 +511,28 @@ export function ContribWiring({ children }: { children: ReactNode }) { startFreshSessionDraft() }, [freshSessionRequest, startFreshSessionDraft]) - // Swapping the live gateway to another profile must re-pull that profile's - // global model + active-profile pill (both are nanostores — the blanket - // invalidateQueries on swap doesn't touch them). - const lastGatewayProfileRef = useRef(activeGatewayProfile) + // Swapping the live gateway to another source or profile must re-pull that + // source's model/config/profile state. Two sources commonly both expose a + // `default` profile, so profile alone is not a sufficient identity. + const gatewayScope = `${activeConnectionId ?? ''}\0${activeGatewayProfile}` + const lastGatewayScopeRef = useRef(gatewayScope) // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { - if (activeGatewayProfile === lastGatewayProfileRef.current) { + if (gatewayScope === lastGatewayScopeRef.current) { return } - lastGatewayProfileRef.current = activeGatewayProfile - // Force: the new profile has its own defaults, so reseed the selector even - // if the composer already shows values from the previous profile. Both - // refreshes carry an intent token so a picker click made in flight wins. + lastGatewayScopeRef.current = gatewayScope + // Force: the new source/profile pair has its own defaults, so reseed the + // selector even if the composer already shows values from the previous + // backend. These refreshes carry intent tokens so an in-flight picker + // click still wins. void refreshCurrentModel(true) void refreshHermesConfig(true) void refreshActiveProfile() - }, [activeGatewayProfile, refreshCurrentModel, refreshHermesConfig]) + resetProjectTreeState() + }, [gatewayScope, refreshCurrentModel, refreshHermesConfig]) // New session anchored to a workspace. Seeds cwd + branch from the clicked // workspace; an explicit worktree path also drills the sidebar into that @@ -776,6 +799,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { // Keep app data live while the gateway is open (on-connect reseed + the // cron / messaging / transcript visibility polls + fresh-draft reseed). useBackgroundSync({ + activeConnectionId, activeGatewayProfile, activeIsMessaging, activeSessionId, @@ -1154,6 +1178,9 @@ export function ContribWiring({ children }: { children: ReactNode }) { {/* Toasts above everything. */} + {/* Backs confirm() from @/store/confirm — renders only while one is open. */} + + {/* Petdex floating mascot — renders nothing unless installed + enabled. Never in the HUD: that window is the chat bar and nothing else. */} {!isHudWindow() && } diff --git a/apps/desktop/src/app/cron/cron-actions.test.ts b/apps/desktop/src/app/cron/cron-actions.test.ts index 654f65b6b7..f7cbe89755 100644 --- a/apps/desktop/src/app/cron/cron-actions.test.ts +++ b/apps/desktop/src/app/cron/cron-actions.test.ts @@ -1,29 +1,25 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const getCronJobs = vi.fn() +const getApiRequestConnection = vi.fn<() => null | string>(() => null) const triggerCronJob = vi.fn() vi.mock('@/hermes', () => ({ + getApiRequestConnection: () => getApiRequestConnection(), getCronJobs: (...args: unknown[]) => getCronJobs(...args), triggerCronJob: (...args: unknown[]) => triggerCronJob(...args) })) import { beginCronJobsRequest } from '@/store/cron' +import { deferred } from '../../test/deferred' + import { mutateAndRefreshCronJobs, refreshCronJobs, triggerAndRefreshCronJobs } from './cron-actions' -function deferred() { - let resolve!: (value: T) => void - - const promise = new Promise(res => { - resolve = res - }) - - return { promise, resolve } -} - describe('triggerAndRefreshCronJobs', () => { beforeEach(() => { + getApiRequestConnection.mockReset() + getApiRequestConnection.mockReturnValue(null) getCronJobs.mockReset() triggerCronJob.mockReset() }) @@ -106,6 +102,8 @@ describe('triggerAndRefreshCronJobs', () => { describe('mutateAndRefreshCronJobs', () => { beforeEach(() => { + getApiRequestConnection.mockReset() + getApiRequestConnection.mockReturnValue(null) getCronJobs.mockReset() }) diff --git a/apps/desktop/src/app/cron/cron-actions.ts b/apps/desktop/src/app/cron/cron-actions.ts index 7fafe13c59..e49c541f64 100644 --- a/apps/desktop/src/app/cron/cron-actions.ts +++ b/apps/desktop/src/app/cron/cron-actions.ts @@ -1,4 +1,4 @@ -import { type CronJob, getCronJobs, triggerCronJob } from '@/hermes' +import { type CronJob, getApiRequestConnection, getCronJobs, triggerCronJob } from '@/hermes' import { beginCronJobsAction, beginCronJobsRequest, @@ -18,6 +18,10 @@ export interface CronMutationRefreshResult extends CronTriggerRefreshResult { value: T | null } +function cronRequestScope(profile: string): string { + return `${getApiRequestConnection() ?? ''}\u0000${profile}` +} + async function refreshForGeneration(profile: string, request: CronJobsRequest): Promise { try { const jobs = await getCronJobs(profile) @@ -37,14 +41,14 @@ async function refreshForGeneration(profile: string, request: CronJobsRequest): } export function refreshCronJobs(profile: string): Promise { - return refreshForGeneration(profile, beginCronJobsRequest(profile)) + return refreshForGeneration(profile, beginCronJobsRequest(cronRequestScope(profile))) } export async function mutateAndRefreshCronJobs( profile: string, mutate: () => Promise ): Promise> { - const scopeToken = beginCronJobsAction(profile) + const scopeToken = beginCronJobsAction(cronRequestScope(profile)) let value: T try { diff --git a/apps/desktop/src/app/cron/index.tsx b/apps/desktop/src/app/cron/index.tsx index 9bf6923b5c..ee7f0af5aa 100644 --- a/apps/desktop/src/app/cron/index.tsx +++ b/apps/desktop/src/app/cron/index.tsx @@ -8,6 +8,7 @@ import { PageLoader } from '@/components/page-loader' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { Codicon } from '@/components/ui/codicon' +import { ConfirmDialog } from '@/components/ui/confirm-dialog' import { Dialog, DialogContent, @@ -343,7 +344,6 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt const [editor, setEditor] = useState({ mode: 'closed' }) const [pendingDelete, setPendingDelete] = useState(null) - const [deleting, setDeleting] = useState(false) // Jobs live per-profile on disk and the list endpoint aggregates 'all' by // default — scope the fetch to the sidebar's profile scope so this overlay @@ -533,31 +533,23 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt } } + // Throws on failure — ConfirmDialog reports it inline and stays open. async function handleConfirmDelete() { if (!pendingDelete) { return } - setDeleting(true) + const { refreshError, stale } = await mutateAndRefreshCronJobs(profile, () => deleteCronJob(pendingDelete.id)) - try { - const { refreshError, stale } = await mutateAndRefreshCronJobs(profile, () => deleteCronJob(pendingDelete.id)) - - if (stale) { - return - } - - if (refreshError) { - notifyError(refreshError, c.failedLoad) - } - - notify({ kind: 'success', title: c.deleted, message: truncate(jobTitle(pendingDelete), 60) }) - setPendingDelete(null) - } catch (err) { - notifyError(err, c.failedDelete) - } finally { - setDeleting(false) + if (stale) { + return } + + if (refreshError) { + notifyError(refreshError, c.failedLoad) + } + + notify({ kind: 'success', title: c.deleted, message: truncate(jobTitle(pendingDelete), 60) }) } async function handleEditorSave(values: EditorValues) { @@ -682,7 +674,9 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt /> ))} {visibleJobs.length === 0 && ( -

{c.emptyTitleSearch}

+

+ {query.trim() ? c.emptyTitleSearch : c.emptyTitleNew} +

)} setEditor({ mode: 'create' })} /> {visibleBlueprints.length > 0 && ( @@ -711,8 +705,17 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt onPauseResume={() => void handlePauseResume(selectedJob)} onTrigger={() => void handleTrigger(selectedJob)} /> - ) : ( + ) : query.trim() ? ( + // A search with no selected job: search-flavored copy is right. + ) : ( + // No selection and no search — "Try a broader search query" here + // just confused people staring at an empty panel with zero jobs. + )} )} @@ -724,30 +727,24 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt onSave={handleEditorSave} /> - !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}> - - - {c.deleteTitle} - - {pendingDelete ? ( - <> - {c.deleteDescPrefix} - {truncate(jobTitle(pendingDelete), 60)} - {c.deleteDescSuffix} - - ) : null} - - - - - - - - + + {c.deleteDescPrefix} + {truncate(jobTitle(pendingDelete), 60)} + {c.deleteDescSuffix} + + ) : null + } + destructive + onClose={() => setPendingDelete(null)} + onConfirm={handleConfirmDelete} + open={pendingDelete !== null} + title={c.deleteTitle} + /> ) } diff --git a/apps/desktop/src/app/hud/glass.ts b/apps/desktop/src/app/hud/glass.ts index 572f68b58f..2a917e4433 100644 --- a/apps/desktop/src/app/hud/glass.ts +++ b/apps/desktop/src/app/hud/glass.ts @@ -3,14 +3,18 @@ import { type RefObject, useEffect } from 'react' /** The caret is in the composer — see the `:has()` rules in styles.css. */ const TYPING_SELECTOR = '[data-slot="composer-rich-input"]:focus' +/** An open completion list owns the surface; the band falls back behind it. */ +const DRAWER_SELECTOR = '[data-slot="composer-completion-drawer"]' + /** * Native frost behind the band. * - * macOS vibrancy, not CSS — `backdrop-filter` reaches nothing here, because a - * transparent window's backdrop root is the document and the desktop was never - * in it. Vibrancy is composited by WindowServer BELOW the web contents, which - * is what lets it see the desktop and also what makes it untouchable from the - * page: no mask, clip or stacking order can shape it. + * A platform material, not CSS — `backdrop-filter` reaches nothing here, + * because a transparent window's backdrop root is the document and the desktop + * was never in it. The material is composited BELOW the web contents (macOS + * vibrancy via WindowServer, Windows 11 via the DWM backdrop), which is what + * lets it see the desktop and also what makes it untouchable from the page: no + * mask, clip or stacking order can shape it. * * That is survivable because the band is a flat panel. It was NOT survivable * while the band carried a vertical gradient — the frost stayed a slab under a @@ -31,38 +35,79 @@ const TYPING_SELECTOR = '[data-slot="composer-rich-input"]:focus' * document.activeElement, which stays put when the window is blurred and would * latch the frost on forever once the user had ever typed here. * - * `backing` is the veto over both of those. Because the frost is the window and - * not the sheet, it is only ever right when the sheet covers the window; short - * of that the excess is frost over empty space. Gating the caller's `engaged` - * alone would not do it — focus turns the frost on by itself, which is how a - * brand new thread still frosted its whole empty window. + * Two vetoes sit over that: + * + * - `backing` — because the frost is the window and not the sheet, it is only + * ever right when the sheet covers the window; short of that the excess is + * frost over empty space. Gating the caller's `engaged` alone would not do + * it — focus turns the frost on by itself, which is how a brand new thread + * still frosted its whole empty window. + * - An open completion drawer, which drops the band to 25% and blurs it + * (see the `composer-completion-drawer` rule in styles.css). Full-strength + * frost behind a band that has deliberately stepped back is the same bare + * slab in a different disguise. Observed rather than passed in: the drawer + * mounts inside the composer subtree from three different call sites, so a + * prop would need every one of them to remember. + * + * Whether the frost is wanted AT ALL is the user's translucency setting, and + * that answer lives in main (`hudFrostFor`) next to the state it reads. This + * hook reports what the band is doing; it does not decide the material. */ export function useHudGlass(rootRef: RefObject, engaged: boolean, backing: boolean): void { useEffect(() => { const root = rootRef.current - const setVibrancy = window.hermesDesktop?.hud?.setVibrancy + const setFrost = window.hermesDesktop?.hud?.setFrost - if (!root || !setVibrancy) { + if (!root || !setFrost) { return } let on: boolean | null = null const apply = () => { - const next = backing && (engaged || root.querySelector(TYPING_SELECTOR) !== null) + const next = + backing && + root.querySelector(DRAWER_SELECTOR) === null && + (engaged || root.querySelector(TYPING_SELECTOR) !== null) if (on !== next) { on = next - void setVibrancy(next) + void setFrost(next) } } + // The drawer mounts and unmounts without any focus change, so neither + // focusin/focusout nor a re-render is guaranteed to follow it. Coalesced + // to a frame: this observes the whole shell, and a streaming reply mutates + // the transcript tens of times a second — the drawer's state cannot change + // more than once per paint, so re-deciding per mutation is pure churn. + let frame: null | number = null + + const schedule = () => { + if (frame === null) { + frame = requestAnimationFrame(() => { + frame = null + apply() + }) + } + } + + const observer = new MutationObserver(schedule) + + observer.observe(root, { childList: true, subtree: true }) + apply() root.addEventListener('focusin', apply) root.addEventListener('focusout', apply) return () => { - void setVibrancy(false) + void setFrost(false) + observer.disconnect() + + if (frame !== null) { + cancelAnimationFrame(frame) + } + root.removeEventListener('focusin', apply) root.removeEventListener('focusout', apply) } diff --git a/apps/desktop/src/app/hud/hud-shell.tsx b/apps/desktop/src/app/hud/hud-shell.tsx index 9b5d175503..0053d2cb9b 100644 --- a/apps/desktop/src/app/hud/hud-shell.tsx +++ b/apps/desktop/src/app/hud/hud-shell.tsx @@ -2,18 +2,12 @@ import { useStore } from '@nanostores/react' import { type CSSProperties, useCallback, useEffect, useRef, useState } from 'react' import { useNavigate } from 'react-router' -import { TitlebarIcon } from '@/app/shell/titlebar-icon' -import { Button } from '@/components/ui/button' -import { Tip } from '@/components/ui/tooltip' -import { useI18n } from '@/i18n' import { chatMessageText } from '@/lib/chat-messages' -import { closeHud } from '@/store/hud' import { $activeSessionAwaitingInput } from '@/store/prompts' import { $busy, $messages } from '@/store/session' import { RICH_INPUT_SLOT } from '../chat/composer/rich-editor' import { WiredPane } from '../contrib/wiring' -import { titlebarButtonClass } from '../shell/titlebar' import { useHudClickThrough } from './click-through' import { useHudGlass } from './glass' @@ -170,7 +164,6 @@ function useHudHeld(): boolean { * `useRecentActivity`). */ export function HudShell() { - const { t } = useI18n() const [recent, holdBand] = useRecentActivity() const held = useHudHeld() @@ -388,22 +381,6 @@ export function HudShell() { - {/* The way back — without it the only exits are ⌘⇧H and ⌘W, both - invisible. Placed and revealed entirely from styles.css. */} - - - - {/* The resize handle: bottom-right corner, the one sanctioned way to change the HUD's size. Invisible chrome — a hot corner, not a button — so it never reads as part of the surface. `data-hud-grabbing` diff --git a/apps/desktop/src/app/overlays/overlay-split-layout.tsx b/apps/desktop/src/app/overlays/overlay-split-layout.tsx index b4a90dd0f3..d1ef5d2710 100644 --- a/apps/desktop/src/app/overlays/overlay-split-layout.tsx +++ b/apps/desktop/src/app/overlays/overlay-split-layout.tsx @@ -31,6 +31,8 @@ interface OverlayMainProps { interface OverlayNavItemProps { active: boolean icon: IconComponent + /** Stable identity for the row, used as its `data-tour` handle. */ + id?: string label: string // Renders as an indented child of another nav item: smaller icon and a // lighter active state so it never competes with the boxed parent item. @@ -67,6 +69,10 @@ export function OverlaySidebar({ children, className }: OverlaySidebarProps) { OVERLAY_TOP_CLEARANCE, className )} + // Every overlay's left nav (settings, cron, profiles, agents) answers to + // one name, so a tour can point at "the nav" without knowing which + // overlay is open. See lib/tour. + data-tour="overlay-nav" > {children} @@ -97,6 +103,7 @@ export function OverlayMain({ children, className }: OverlayMainProps) { export const OverlayNavItem = memo(function OverlayNavItem({ active, icon: Icon, + id, label, nested, onClick, @@ -114,6 +121,9 @@ export const OverlayNavItem = memo(function OverlayNavItem({ ? 'border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary) text-foreground' : 'border-transparent bg-transparent text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground' )} + // Names the row by its own id, so a tour can address one link + // (`[data-tour="nav-models"]`) instead of guessing at nth-child. + data-tour={id ? `nav-${id}` : undefined} onClick={onClick} type="button" > @@ -159,13 +169,20 @@ export function OverlayNav({ footer, groups }: { footer?: ReactNode; groups: Ove {groups.map(group => ( {group.gapBefore &&
} - + {group.children && group.active && (
{group.children.map(child => ( )}
- {hasTabs ? : } + {hasTabs ? ( +
+ +
+ ) : ( + + )}
{searchTrailingAction}
)} diff --git a/apps/desktop/src/app/right-sidebar/file-actions.tsx b/apps/desktop/src/app/right-sidebar/file-actions.tsx index dbb2ccb65d..e5e9443f32 100644 --- a/apps/desktop/src/app/right-sidebar/file-actions.tsx +++ b/apps/desktop/src/app/right-sidebar/file-actions.tsx @@ -19,11 +19,13 @@ import { cancelInlineRename, closeFileActionDialog, copyFilePath, + downloadRemoteFile, executeFileDelete, executeFileRename, type FileActionTarget, requestFileDelete, revealFile, + shouldOfferRemoteFileDownload, toRelativePath } from '@/store/file-actions' import { notifyError } from '@/store/notifications' @@ -57,8 +59,10 @@ export function FileEntryContextMenu({ children, isDirectory, name, path, relati const { t } = useI18n() const m = t.fileMenu // Reveal / rename / delete need the local filesystem; hide them on a remote - // backend (copy-path still works everywhere). + // backend (copy-path still works everywhere). Download uses the existing + // gateway save bridge so a remote file can land on this machine. const localFs = !isDesktopFsRemoteMode() + const remoteDownload = shouldOfferRemoteFileDownload(isDirectory) const target: FileActionTarget = { isDirectory, name, path } const revealLabel = pickRevealLabel(m.revealFinder, m.revealExplorer, m.revealFileManager) @@ -80,6 +84,12 @@ export function FileEntryContextMenu({ children, isDirectory, name, path, relati {m.copyRelativePath} )} + {remoteDownload && ( + <> + + void downloadRemoteFile(path)}>{m.download} + + )} {localFs && ( <> diff --git a/apps/desktop/src/app/right-sidebar/review/file-tree.tsx b/apps/desktop/src/app/right-sidebar/review/file-tree.tsx index 745839c523..1ae6e21bc6 100644 --- a/apps/desktop/src/app/right-sidebar/review/file-tree.tsx +++ b/apps/desktop/src/app/right-sidebar/review/file-tree.tsx @@ -20,7 +20,14 @@ import { isDesktopFsRemoteMode } from '@/lib/desktop-fs' import { displayPath } from '@/lib/display-path' import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview' import { cn } from '@/lib/utils' -import { $renamingPath, copyFilePath, revealFile, toRelativePath } from '@/store/file-actions' +import { + $renamingPath, + copyFilePath, + downloadRemoteFile, + revealFile, + shouldOfferRemoteFileDownload, + toRelativePath +} from '@/store/file-actions' import { $sidebarWorkspaceNodeOpen, revealFileInTree, toggleWorkspaceNodeCollapsed } from '@/store/layout' import { notifyError } from '@/store/notifications' import { openPreview } from '@/store/preview' @@ -514,6 +521,12 @@ function ReviewFileContextMenu({ {m.copyRelativePath} )} + {shouldOfferRemoteFileDownload(false) && ( + <> + + void downloadRemoteFile(dragPath)}>{m.download} + + )} ) diff --git a/apps/desktop/src/app/right-sidebar/review/index.tsx b/apps/desktop/src/app/right-sidebar/review/index.tsx index 5b719b01cf..08c82fb466 100644 --- a/apps/desktop/src/app/right-sidebar/review/index.tsx +++ b/apps/desktop/src/app/right-sidebar/review/index.tsx @@ -4,14 +4,7 @@ import { FileDiffPanel } from '@/components/chat/diff-lines' import { DiffSkeleton, TreeSkeleton } from '@/components/chat/skeletons' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' +import { ConfirmDialog } from '@/components/ui/confirm-dialog' import { DiffCount } from '@/components/ui/diff-count' import { Tip } from '@/components/ui/tooltip' import { useDelayedTrue } from '@/hooks/use-delayed-true' @@ -209,32 +202,30 @@ export function ReviewPane() { - !open && cancelRevert()} open={revertTarget !== undefined}> - - - {revertingAll ? c.revertAll : c.revert} - - {revertingAll ? c.revertAllConfirm : c.revertConfirm} - {!revertingAll && revertTarget?.path && ( - - {displayPath(revertTarget.path)} - - )} - - - - - - - - + + {revertingAll ? c.revertAllConfirm : c.revertConfirm} + {!revertingAll && revertTarget?.path && ( + + {displayPath(revertTarget.path)} + + )} + + } + destructive + // confirmRevert closes the dialog itself, then reverts in the + // background — so the failure lands in a toast, not inline. + dismissOnConfirm + onClose={cancelRevert} + onConfirm={() => confirmRevert().catch(err => void notifyError(err, c.revert))} + open={revertTarget !== undefined} + title={revertingAll ? c.revertAll : c.revert} + /> ) } diff --git a/apps/desktop/src/app/right-sidebar/review/ship-bar.tsx b/apps/desktop/src/app/right-sidebar/review/ship-bar.tsx index 2f85a9ade4..3d0e03325f 100644 --- a/apps/desktop/src/app/right-sidebar/review/ship-bar.tsx +++ b/apps/desktop/src/app/right-sidebar/review/ship-bar.tsx @@ -15,6 +15,7 @@ import { $reviewCommitDefault, $reviewCommitMsgBusy, $reviewFiles, + $reviewScopeTarget, $reviewShipBusy, $reviewShipInfo, cancelCommitMessage, @@ -35,6 +36,7 @@ export function ReviewShipBar() { const c = t.statusStack.coding const files = useStore($reviewFiles) const ship = useStore($reviewShipInfo) + const scopeTarget = useStore($reviewScopeTarget) const busy = useStore($reviewShipBusy) const generating = useStore($reviewCommitMsgBusy) const commitDefault = useStore($reviewCommitDefault) @@ -129,7 +131,11 @@ export function ReviewShipBar() {
- } - below={ - translucency.mode === 'glass' && GLASS_SUPPORTED ? ( -
-
- - {a.translucencyFrostTitle} - + {/* Linux has neither half of this setting (see TRANSLUCENCY_SUPPORTED), + so the row is absent there rather than offering a dead lever. */} + {TRANSLUCENCY_SUPPORTED && ( + + {GLASS_SUPPORTED && ( ({ - id: material, - label: a.translucencyFrost[material] - }))} - value={translucency.material} + onChange={pickTranslucency(setTranslucencyMode)} + options={[ + { id: 'clear' as const, label: a.translucencyModeClear }, + { id: 'glass' as const, label: a.translucencyModeGlass } + ]} + value={translucency.mode} /> -
-
- - {a.translucencyScopeTitle} - - ({ - id: scope, - label: a.translucencyScope[scope] - }))} - value={translucency.scope} + )} + {/* Clear has one lever and it belongs beside the mode. Glass + has four controls, so they move into the labelled panel + below rather than crowding this line with an unlabelled + slider that means something different. */} + {!glassMode && ( + -
+ )}
- ) : undefined - } - description={translucency.mode === 'glass' ? a.translucencyGlassDesc : a.translucencyDesc} - id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.translucency)} - title={a.translucencyTitle} - /> + } + below={ + glassMode ? ( +
+ + + + + + + + ({ + id: material, + label: a.translucencyFrost[material] + }))} + value={glassMaterialForPicker(translucency.material, GLASS_IS_WINDOWS)} + /> + + + ({ + id: scope, + label: a.translucencyScope[scope] + }))} + value={translucency.scope} + /> + +
+ ) : undefined + } + description={glassMode ? a.translucencyGlassDesc : a.translucencyDesc} + id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.translucency)} + title={a.translucencyTitle} + /> + )} + { + triggerHaptic('selection') + setIntroSplash(id === 'on') + }} + options={[ + { id: 'off', label: t.common.off }, + { id: 'on', label: t.common.on } + ]} + value={introSplash ? 'on' : 'off'} + /> + } + description={a.introSplashDesc} + id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.introSplash)} + title={a.introSplashTitle} + /> + ( - + ) // `fallback_providers` is a list of {provider, model} objects; the generic diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx index 77d501b731..c44dd8b6a7 100644 --- a/apps/desktop/src/app/settings/config-settings.tsx +++ b/apps/desktop/src/app/settings/config-settings.tsx @@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input' import { getElevenLabsVoices, getHermesConfigSchema, saveHermesConfig } from '@/hermes' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' +import { confirm } from '@/store/confirm' import { $dataUrlReadMaxMb, clampDataUrlReadMaxMb, @@ -213,19 +214,29 @@ function ConfigSettingsInner({ // eslint-disable-next-line react-hooks/exhaustive-deps -- copy is stable; avoid re-scheduling autosave on locale change }, [config, onConfigSaved, saveVersion]) + const applyConfig = (next: HermesConfigRecord) => { + saveVersionRef.current += 1 + setConfig(next) + setSaveVersion(saveVersionRef.current) + } + const updateConfig = (next: HermesConfigRecord) => { // Guard the single most destructive config edit: clearing the entire // "Enabled Toolsets" list silently disables memory, terminal, web search, // delegation, and most tools, and a stray select-all + Backspace can do it. // Auto-save is debounced with no undo, so confirm a non-empty → empty // transition before applying it. Every other edit passes through untouched. - if (config && clearsEnabledToolsets(config, next) && !window.confirm(c.toolsetsWipeConfirm)) { + if (config && clearsEnabledToolsets(config, next)) { + void confirm({ destructive: true, title: c.toolsetsWipeConfirm }).then(ok => { + if (ok) { + applyConfig(next) + } + }) + return } - saveVersionRef.current += 1 - setConfig(next) - setSaveVersion(saveVersionRef.current) + applyConfig(next) } const sectionFields = useMemo(() => { diff --git a/apps/desktop/src/app/settings/connections-registry.test.tsx b/apps/desktop/src/app/settings/connections-registry.test.tsx index c03f85d617..dcc4c0b2ea 100644 --- a/apps/desktop/src/app/settings/connections-registry.test.tsx +++ b/apps/desktop/src/app/settings/connections-registry.test.tsx @@ -2,6 +2,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { DesktopConnectionsRegistry } from '@/global' +import { $connection } from '@/store/session' import { ConnectionsRegistrySection, @@ -14,6 +15,7 @@ import { const list = vi.fn() const save = vi.fn() const remove = vi.fn() +const setLaunchMode = vi.fn() const setPrimary = vi.fn() const test = vi.fn() @@ -36,30 +38,44 @@ const registry: DesktopConnectionsRegistry = { } beforeEach(() => { + $connection.set({ + baseUrl: 'http://homelab.lan:9119', + connectionId: 'homelab', + isFullscreen: false, + logs: [], + mode: 'remote', + nativeOverlayWidth: 0, + token: 'test-token', + windowButtonPosition: null, + wsUrl: 'ws://homelab.lan:9119/ws' + }) list.mockResolvedValue(registry) save.mockResolvedValue({ connection: registry.connections[1], ok: true, registry }) remove.mockResolvedValue({ ok: true, registry: { ...registry, connections: [registry.connections[0]] } }) + setLaunchMode.mockResolvedValue({ ok: true, registry: { ...registry, launchMode: 'last-used' } }) setPrimary.mockResolvedValue({ ok: true, registry: { ...registry, primary: 'homelab' } }) test.mockResolvedValue({ ok: true, reachable: true }) Object.defineProperty(window, 'hermesDesktop', { configurable: true, - value: { connections: { list, remove, save, setPrimary, test } } + value: { connections: { list, remove, save, setLaunchMode, setPrimary, test } } }) }) afterEach(() => { + $connection.set(null) cleanup() vi.clearAllMocks() }) describe('ConnectionsRegistrySection', () => { - it('lists registered connections with primary + local pills', async () => { + it('distinguishes the current connection from the registry primary', async () => { render() await waitFor(() => expect(screen.getByText('Homelab')).toBeTruthy()) // Label and the managed pill share the copy, so expect both instances. expect(screen.getAllByText('This device').length).toBeGreaterThan(0) - expect(screen.getByText('Primary')).toBeTruthy() + expect(screen.getByText('Current')).toBeTruthy() + expect(screen.getAllByText('Primary').length).toBeGreaterThan(0) expect(list).toHaveBeenCalledTimes(1) }) @@ -120,15 +136,156 @@ describe('ConnectionsRegistrySection', () => { expect(save).not.toHaveBeenCalled() }) - it('makes a non-primary connection primary', async () => { + it('keeps the primary fallback configurable while last-used restore is enabled', async () => { + list.mockResolvedValueOnce({ ...registry, launchMode: 'last-used' }) render() await waitFor(() => expect(screen.getByText('Homelab')).toBeTruthy()) - fireEvent.click(screen.getByText('Make primary')) + const makePrimary = screen.getByText('Make primary').closest('button')! + + expect(makePrimary.disabled).toBe(false) + fireEvent.click(makePrimary) await waitFor(() => expect(setPrimary).toHaveBeenCalledWith('homelab')) }) + it('lets users opt into restoring the last-used source', async () => { + render() + + const launchSetting = await screen.findByText('At startup, return to Sessions on the last-used gateway') + const addConnection = screen.getByText('Add connection') + + expect(addConnection.compareDocumentPosition(launchSetting) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + fireEvent.click(screen.getByRole('switch', { name: 'At startup, return to Sessions on the last-used gateway' })) + + await waitFor(() => expect(setLaunchMode).toHaveBeenCalledWith('last-used')) + }) + + it('keeps the launch preference out of the way for a single source', async () => { + list.mockResolvedValueOnce({ ...registry, connections: [registry.connections[0]] }) + + render() + + await waitFor(() => expect(list).toHaveBeenCalledTimes(1)) + expect(screen.queryByText('At startup, return to Sessions on the last-used gateway')).toBeNull() + }) + + it('keeps search out of the way for a small registry', async () => { + render() + + await waitFor(() => expect(screen.getByText('Homelab')).toBeTruthy()) + expect(screen.queryByRole('searchbox', { name: 'Search gateways…' })).toBeNull() + }) + + it('sorts a large registry and searches names and endpoints', async () => { + const largeRegistry: DesktopConnectionsRegistry = { + ...registry, + connections: [ + { + authMode: 'token', + id: 'zulu', + kind: 'remote', + label: 'Zulu', + tokenPreview: null, + tokenSet: false, + url: 'https://zulu.example.test' + }, + registry.connections[0], + ...Array.from({ length: 6 }, (_, index) => ({ + authMode: 'token' as const, + id: `gateway-${index}`, + kind: 'remote' as const, + label: index === 0 ? 'Alpha' : `Gateway ${index}`, + tokenPreview: null, + tokenSet: false, + url: + index === 4 + ? 'https://studio.example.test' + : index === 5 + ? 'https://studio-archive.example.test' + : `https://gateway-${index}.example.test` + })) + ] + } + + list.mockResolvedValueOnce(largeRegistry) + render( +
+ +
+ ) + + const search = await screen.findByRole('searchbox', { name: 'Search gateways…' }) + expect(search.parentElement?.className).toContain('mt-3') + expect(search.parentElement?.className).toContain('mb-0') + const settingsScroller = screen.getByTestId('settings-scroller') + settingsScroller.scrollTop = 200 + vi.spyOn(search, 'getBoundingClientRect') + .mockReturnValueOnce({ + bottom: 152, + height: 32, + left: 0, + right: 0, + top: 120, + width: 0, + x: 0, + y: 120, + toJSON: () => ({}) + }) + .mockReturnValueOnce({ + bottom: 152, + height: 32, + left: 0, + right: 0, + top: 120, + width: 0, + x: 0, + y: 120, + toJSON: () => ({}) + }) + .mockReturnValueOnce({ + bottom: 152, + height: 32, + left: 0, + right: 0, + top: 120, + width: 0, + x: 0, + y: 120, + toJSON: () => ({}) + }) + .mockReturnValue({ + bottom: 182, + height: 32, + left: 0, + right: 0, + top: 150, + width: 0, + x: 0, + y: 150, + toJSON: () => ({}) + }) + const alpha = screen.getByText('Alpha') + const zulu = screen.getByText('Zulu') + expect(alpha.compareDocumentPosition(zulu) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + + fireEvent.change(search, { target: { value: 'studio' } }) + + expect(settingsScroller.scrollTop).toBe(200) + expect(screen.getByText('Gateway 4')).toBeTruthy() + expect(screen.getByText('Gateway 5')).toBeTruthy() + expect(screen.queryByText('Alpha')).toBeNull() + + settingsScroller.scrollTop = 260 + fireEvent.change(search, { target: { value: 'studio.example' } }) + expect(settingsScroller.scrollTop).toBe(290) + expect(screen.getByText('Gateway 4')).toBeTruthy() + expect(screen.queryByText('Gateway 5')).toBeNull() + + fireEvent.change(search, { target: { value: '' } }) + expect(search.closest('.border-t')?.style.minHeight).toBe('') + }) + it('tests a connection through the bridge', async () => { render() diff --git a/apps/desktop/src/app/settings/connections-registry.tsx b/apps/desktop/src/app/settings/connections-registry.tsx index 76c7a48075..131f21a3b6 100644 --- a/apps/desktop/src/app/settings/connections-registry.tsx +++ b/apps/desktop/src/app/settings/connections-registry.tsx @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useState } from 'react' +import { useStore } from '@nanostores/react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { Button } from '@/components/ui/button' import { ConfirmDialog } from '@/components/ui/confirm-dialog' @@ -10,11 +11,17 @@ import type { DesktopRegistryConnectionInput } from '@/global' import { useI18n } from '@/i18n' +import { + CONNECTION_SEARCH_THRESHOLD, + connectionMatchesQuery, + sortConnectionsForDisplay +} from '@/lib/connection-display' import { triggerHaptic } from '@/lib/haptics' -import { Cloud, Globe, Loader2, Monitor, Pencil, Plus, RefreshCw, Terminal, Trash2 } from '@/lib/icons' +import { Cloud, Globe, Loader2, Monitor, Pencil, Plus, RefreshCw, SearchIcon, Terminal, Trash2 } from '@/lib/icons' +import { $activeConnectionId, setConnectionsRegistry } from '@/store/connections' import { notify, notifyError } from '@/store/notifications' -import { EmptyState, ListRow, Pill, SectionHeading } from './primitives' +import { EmptyState, ListRow, Pill, SectionHeading, ToggleRow } from './primitives' const KIND_ICONS: Record = { cloud: Cloud, @@ -191,6 +198,20 @@ export function sameBackendPeerLabel( return null } +function scrollableAncestor(element: HTMLElement): HTMLElement | null { + let parent = element.parentElement + + while (parent) { + if (/(auto|scroll)/.test(window.getComputedStyle(parent).overflowY)) { + return parent + } + + parent = parent.parentElement + } + + return null +} + /** * The connections registry section of Settings → Gateways: manage the named * agent sources (local runtime + any number of remote gateways / Hermes Cloud @@ -200,6 +221,7 @@ export function sameBackendPeerLabel( export function ConnectionsRegistrySection() { const { t } = useI18n() const s = t.settings.connections + const activeConnectionId = useStore($activeConnectionId) const [registry, setRegistry] = useState(null) const [loading, setLoading] = useState(true) const [editor, setEditor] = useState(null) @@ -208,7 +230,11 @@ export function ConnectionsRegistrySection() { const [testingId, setTestingId] = useState(null) const [removeTarget, setRemoveTarget] = useState(null) const [plainTextConfirm, setPlainTextConfirm] = useState(false) + const [launchModeBusy, setLaunchModeBusy] = useState(false) const [updatingAll, setUpdatingAll] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + const searchInputRef = useRef(null) + const pendingSearchTopRef = useRef(null) // Inline duplicate rejection from the save path (dedupe is also enforced in // the main process, so a crafted payload can't slip past the UI check). const [dupeError, setDupeError] = useState(null) @@ -217,6 +243,11 @@ export function ConnectionsRegistrySection() { const hasLocal = Boolean(registry?.connections.some(c => c.kind === 'local')) + const publishRegistry = useCallback((next: DesktopConnectionsRegistry) => { + setRegistry(next) + setConnectionsRegistry(next) + }, []) + const load = useCallback(async () => { if (!bridge) { setLoading(false) @@ -227,13 +258,13 @@ export function ConnectionsRegistrySection() { setLoading(true) try { - setRegistry(await bridge.list()) + publishRegistry(await bridge.list()) } catch (err) { notifyError(err, s.loadFailed) } finally { setLoading(false) } - }, [bridge, s.loadFailed]) + }, [bridge, publishRegistry, s.loadFailed]) useEffect(() => { void load() @@ -312,7 +343,7 @@ export function ConnectionsRegistrySection() { } const result = await bridge.save(payload) - setRegistry(result.registry) + publishRegistry(result.registry) setEditor(null) setPlainTextConfirm(false) } catch (err) { @@ -336,7 +367,7 @@ export function ConnectionsRegistrySection() { setSaving(false) } }, - [bridge, editor, registry?.connections, registry?.secureTokenStorage, s] + [bridge, editor, publishRegistry, registry?.connections, registry?.secureTokenStorage, s] ) const remove = useCallback(async () => { @@ -348,14 +379,14 @@ export function ConnectionsRegistrySection() { try { const result = await bridge.remove(removeTarget.id) - setRegistry(result.registry) + publishRegistry(result.registry) } catch (err) { notifyError(err, s.removeFailed) } finally { setBusyId(null) setRemoveTarget(null) } - }, [bridge, removeTarget, s.removeFailed]) + }, [bridge, publishRegistry, removeTarget, s.removeFailed]) const makePrimary = useCallback( async (id: string) => { @@ -367,14 +398,34 @@ export function ConnectionsRegistrySection() { try { const result = await bridge.setPrimary(id) - setRegistry(result.registry) + publishRegistry(result.registry) } catch (err) { notifyError(err, s.saveFailed) } finally { setBusyId(null) } }, - [bridge, s.saveFailed] + [bridge, publishRegistry, s.saveFailed] + ) + + const setLaunchMode = useCallback( + async (mode: 'last-used' | 'primary') => { + if (!bridge?.setLaunchMode) { + return + } + + setLaunchModeBusy(true) + + try { + const result = await bridge.setLaunchMode(mode) + publishRegistry(result.registry) + } catch (err) { + notifyError(err, s.saveFailed) + } finally { + setLaunchModeBusy(false) + } + }, + [bridge, publishRegistry, s.saveFailed] ) const test = useCallback( @@ -438,6 +489,46 @@ export function ConnectionsRegistrySection() { ssh: { desc: s.kindSshDesc, label: s.kindSsh } } + const sortedConnections = useMemo( + () => sortConnectionsForDisplay(registry?.connections ?? []), + [registry?.connections] + ) + + const showSearch = sortedConnections.length >= CONNECTION_SEARCH_THRESHOLD + const effectiveSearchQuery = showSearch ? searchQuery : '' + + const displayedConnections = sortedConnections.filter(connection => + connectionMatchesQuery(connection, effectiveSearchQuery, [kindMeta[connection.kind].label]) + ) + + useLayoutEffect(() => { + const previousTop = pendingSearchTopRef.current + const input = searchInputRef.current + + pendingSearchTopRef.current = null + + if (previousTop == null || !input) { + return + } + + const scroller = scrollableAncestor(input) + + if (!scroller) { + return + } + + const delta = input.getBoundingClientRect().top - previousTop + + if (Math.abs(delta) > 0.5) { + scroller.scrollTop += delta + } + }, [displayedConnections.length, effectiveSearchQuery]) + + const updateSearchQuery = (nextQuery: string) => { + pendingSearchTopRef.current = searchInputRef.current?.getBoundingClientRect().top ?? null + setSearchQuery(nextQuery) + } + if (!bridge) { return null } @@ -446,26 +537,43 @@ export function ConnectionsRegistrySection() {

{s.intro}

- {/* Storage-only slice: be explicit that routing consumption is staged so - "Make primary" isn't read as an immediate connection switch. */} + {/* Source selection lives in Sessions. Primary is the registry fallback, + not an immediate workspace switch. */}

{s.stagedNote}

+ {!loading && showSearch && ( + updateSearchQuery(event.target.value)} + placeholder={s.searchPlaceholder} + prefix={} + ref={searchInputRef} + size="sm" + type="search" + value={searchQuery} + /> + )} + {loading ? (
) : !registry || registry.connections.length === 0 ? ( + ) : displayedConnections.length === 0 ? ( + ) : ( - registry.connections.map(conn => { + displayedConnections.map(conn => { const Icon = KIND_ICONS[conn.kind] + const isCurrent = activeConnectionId === conn.id const isPrimary = registry.primary === conn.id const busy = busyId === conn.id // Display-only: this connection is a second address for a backend // already registered under another entry (same install_id). - const sameBackendPeer = sameBackendPeerLabel(conn, registry.connections) + const sameBackendPeer = sameBackendPeerLabel(conn, sortedConnections) const baseDescription = conn.kind === 'ssh' @@ -525,7 +633,8 @@ export function ConnectionsRegistrySection() { {conn.label} - {isPrimary && {s.primaryPill}} + {isCurrent && {s.currentPill}} + {isPrimary && {s.primaryPill}} {conn.kind === 'local' && {s.managedPill}} } @@ -742,6 +851,18 @@ export function ConnectionsRegistrySection() {
)} + {!loading && registry && registry.connections.length > 1 && ( +
+ void setLaunchMode(enabled ? 'last-used' : 'primary')} + /> +
+ )} + { - if (!window.confirm(t.settings.resetConfirm)) { + const ok = await confirm({ + confirmLabel: t.settings.resetToDefaults, + destructive: true, + title: t.settings.resetConfirm + }) + + if (!ok) { return } diff --git a/apps/desktop/src/app/settings/keys-settings.test.tsx b/apps/desktop/src/app/settings/keys-settings.test.tsx index 483be4eb33..87217b2e15 100644 --- a/apps/desktop/src/app/settings/keys-settings.test.tsx +++ b/apps/desktop/src/app/settings/keys-settings.test.tsx @@ -2,17 +2,13 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra import { MemoryRouter, useNavigate } from 'react-router' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { EnvVarInfo } from '@/types/hermes' +import { stubResizeObserver } from '@/test/jsdom' + +import { envVar } from './test-utils' const getEnvVars = vi.fn() -class TestResizeObserver { - observe() {} - unobserve() {} - disconnect() {} -} - -vi.stubGlobal('ResizeObserver', TestResizeObserver) +stubResizeObserver() vi.mock('@/hermes', () => ({ deleteEnvVar: vi.fn(), @@ -22,20 +18,6 @@ vi.mock('@/hermes', () => ({ setEnvVar: vi.fn() })) -function envVar(category: string, patch: Partial = {}): EnvVarInfo { - return { - advanced: false, - category, - description: '', - is_password: true, - is_set: false, - redacted_value: null, - tools: [], - url: '', - ...patch - } -} - beforeEach(() => { getEnvVars.mockResolvedValue({}) Object.defineProperty(Element.prototype, 'scrollIntoView', { diff --git a/apps/desktop/src/app/settings/primitives.tsx b/apps/desktop/src/app/settings/primitives.tsx index 59f3e1fe00..ab875ed703 100644 --- a/apps/desktop/src/app/settings/primitives.tsx +++ b/apps/desktop/src/app/settings/primitives.tsx @@ -111,6 +111,7 @@ export function ListRow({ hint, action, below, + 'data-tour': dataTour, id, wide = false, className @@ -120,6 +121,8 @@ export function ListRow({ hint?: ReactNode action?: ReactNode below?: ReactNode + /** Durable handle for tours (see lib/tour) — usually the field's schema key. */ + 'data-tour'?: string id?: string wide?: boolean className?: string @@ -128,7 +131,7 @@ export function ListRow({ // Container-queried, not viewport-queried: the label/control split keys on // the row's own pane width, so a narrow detail column (messaging, split // views) stacks instead of squishing the label against minmax(15rem,…). -
+
{ listOAuthProviders.mockResolvedValue({ providers: [provider('nous', true), provider('minimax-oauth', false)] }) - vi.spyOn(window, 'confirm').mockReturnValue(true) }) afterEach(() => { cleanup() + $confirmRequest.set(null) vi.restoreAllMocks() vi.clearAllMocks() }) +// Removal goes through confirm() from @/store/confirm, so the host has to be +// mounted for the prompt to render — same as in the real app shell. async function renderProvidersSettings() { const { ProvidersSettings } = await import('./providers-settings') let result: ReturnType await act(async () => { - result = render() + result = render( + <> + + + + ) }) return result! @@ -92,10 +101,32 @@ describe('ProvidersSettings', () => { fireEvent.click(remove) }) + // Removal is confirmed first — nothing has been disconnected yet. + expect(await screen.findByRole('dialog')).toBeTruthy() + expect(disconnectOAuthProvider).not.toHaveBeenCalled() + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Disconnect' })) + }) + await waitFor(() => expect(disconnectOAuthProvider).toHaveBeenCalledWith('nous')) expect(listOAuthProviders).toHaveBeenCalledTimes(2) }) + it('leaves the account connected when the removal prompt is dismissed', async () => { + await renderProvidersSettings() + + await act(async () => { + fireEvent.click(await screen.findByRole('button', { name: 'Remove Nous Portal' })) + }) + + await act(async () => { + fireEvent.click(await screen.findByRole('button', { name: 'Cancel' })) + }) + + expect(disconnectOAuthProvider).not.toHaveBeenCalled() + }) + it('keeps provider selection separate from account removal', async () => { await renderProvidersSettings() diff --git a/apps/desktop/src/app/settings/providers-settings.tsx b/apps/desktop/src/app/settings/providers-settings.tsx index 9fb69784ec..982b39b6ce 100644 --- a/apps/desktop/src/app/settings/providers-settings.tsx +++ b/apps/desktop/src/app/settings/providers-settings.tsx @@ -20,6 +20,7 @@ import { useI18n } from '@/i18n' import { Check, ChevronDown, ChevronRight, KeyRound, Loader2, Terminal, Trash2 } from '@/lib/icons' import { normalize } from '@/lib/text' import { cn } from '@/lib/utils' +import { confirm } from '@/store/confirm' import { notify, notifyError } from '@/store/notifications' import { $desktopOnboarding, startManualLocalEndpoint, startManualProviderOAuth } from '@/store/onboarding' import type { EnvVarInfo, OAuthProvider } from '@/types/hermes' @@ -382,7 +383,7 @@ export function ProvidersSettings({ // Hermes never deletes creds another tool owns behind a silent API call. // Instead we run the documented removal command in the embedded terminal so // the user sees exactly what executes, then return them to chat to watch it. - function handleTerminalDisconnect(provider: OAuthProvider) { + async function handleTerminalDisconnect(provider: OAuthProvider) { const command = provider.disconnect_command if (!command) { @@ -391,7 +392,13 @@ export function ProvidersSettings({ const name = providerTitle(provider) - if (!window.confirm(t.settings.providers.removeTerminalConfirm(name, command))) { + const ok = await confirm({ + confirmLabel: t.settings.providers.disconnect, + destructive: true, + title: t.settings.providers.removeTerminalConfirm(name, command) + }) + + if (!ok) { return } @@ -408,7 +415,13 @@ export function ProvidersSettings({ async function handleDisconnect(provider: OAuthProvider) { const name = providerTitle(provider) - if (!window.confirm(t.settings.providers.removeConfirm(name))) { + const ok = await confirm({ + confirmLabel: t.settings.providers.disconnect, + destructive: true, + title: t.settings.providers.removeConfirm(name) + }) + + if (!ok) { return } @@ -499,7 +512,7 @@ export function ProvidersSettings({ void handleDisconnect(provider)} - onTerminalDisconnect={handleTerminalDisconnect} + onTerminalDisconnect={provider => void handleTerminalDisconnect(provider)} onWantApiKey={() => onViewChange('keys')} providers={oauthProviders} /> diff --git a/apps/desktop/src/app/settings/searchable-select.test.tsx b/apps/desktop/src/app/settings/searchable-select.test.tsx index 2e87c15c92..61f0d77ffa 100644 --- a/apps/desktop/src/app/settings/searchable-select.test.tsx +++ b/apps/desktop/src/app/settings/searchable-select.test.tsx @@ -1,21 +1,14 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { stubResizeObserver } from '@/test/jsdom' import type { ConfigFieldSchema } from '@/types/hermes' import { ConfigField } from './config-field' import { rankSearchOption, SearchableSelect } from './searchable-select' -// Radix Popover + cmdk call scrollIntoView / pointer-capture / ResizeObserver -// APIs jsdom lacks. -class TestResizeObserver { - disconnect() {} - observe() {} - unobserve() {} -} - beforeAll(() => { - vi.stubGlobal('ResizeObserver', TestResizeObserver) + stubResizeObserver() Element.prototype.scrollIntoView = vi.fn() Element.prototype.hasPointerCapture = vi.fn(() => false) Element.prototype.releasePointerCapture = vi.fn() diff --git a/apps/desktop/src/app/settings/sessions-settings.tsx b/apps/desktop/src/app/settings/sessions-settings.tsx index 6218bce7ea..e990557e7a 100644 --- a/apps/desktop/src/app/settings/sessions-settings.tsx +++ b/apps/desktop/src/app/settings/sessions-settings.tsx @@ -15,6 +15,7 @@ import { sessionTitle } from '@/lib/chat-runtime' import { pathLeaf } from '@/lib/display-path' import { triggerHaptic } from '@/lib/haptics' import { Archive, ArchiveOff, FolderOpen, Loader2, Trash2 } from '@/lib/icons' +import { confirm } from '@/store/confirm' import { notify, notifyError } from '@/store/notifications' import { untombstoneSessions } from '@/store/projects' import { applyConfiguredDefaultProjectDir, ensureDefaultWorkspaceCwd, setSessions } from '@/store/session' @@ -76,7 +77,13 @@ export function SessionsSettings() { const remove = useCallback( async (session: SessionInfo) => { - if (!window.confirm(s.deleteConfirm(sessionTitle(session)))) { + const ok = await confirm({ + confirmLabel: s.deletePermanently, + destructive: true, + title: s.deleteConfirm(sessionTitle(session)) + }) + + if (!ok) { return } diff --git a/apps/desktop/src/app/settings/settings-search.test.ts b/apps/desktop/src/app/settings/settings-search.test.ts index 051a9a643b..34935cc9b7 100644 --- a/apps/desktop/src/app/settings/settings-search.test.ts +++ b/apps/desktop/src/app/settings/settings-search.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { Settings2, Wrench } from '@/lib/icons' -import type { ConfigFieldSchema, EnvVarInfo, HermesConfigRecord } from '@/types/hermes' +import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes' import { buildConfigSearchEntries, @@ -9,20 +9,7 @@ import { credentialSettingsView, filterSettingsSearchEntries } from './settings-search' - -function envVar(category: string, patch: Partial = {}): EnvVarInfo { - return { - advanced: false, - category, - description: '', - is_password: true, - is_set: false, - redacted_value: null, - tools: [], - url: '', - ...patch - } -} +import { envVar } from './test-utils' const searchCopy = { fieldDescriptions: { diff --git a/apps/desktop/src/app/settings/settings-search.ts b/apps/desktop/src/app/settings/settings-search.ts index 4fc75eebd3..31a246d7e6 100644 --- a/apps/desktop/src/app/settings/settings-search.ts +++ b/apps/desktop/src/app/settings/settings-search.ts @@ -13,6 +13,7 @@ export type CredentialSettingsView = 'settings' | 'tools' export const APPEARANCE_SETTING_IDS = { backdrop: 'appearance.backdrop', embeds: 'appearance.embeds', + introSplash: 'appearance.intro-splash', language: 'appearance.language', theme: 'appearance.theme', toolView: 'appearance.tool-view', diff --git a/apps/desktop/src/app/settings/test-utils.ts b/apps/desktop/src/app/settings/test-utils.ts new file mode 100644 index 0000000000..92f8eab966 --- /dev/null +++ b/apps/desktop/src/app/settings/test-utils.ts @@ -0,0 +1,17 @@ +import type { EnvVarInfo } from '@/types/hermes' + +/** An unset secret in `category`. The Keys tab and the settings search both + * bucket by category and branch on `is_set`, so those are what tests vary. */ +export function envVar(category: string, patch: Partial = {}): EnvVarInfo { + return { + advanced: false, + category, + description: '', + is_password: true, + is_set: false, + redacted_value: null, + tools: [], + url: '', + ...patch + } +} diff --git a/apps/desktop/src/app/settings/toolset-config-panel.tsx b/apps/desktop/src/app/settings/toolset-config-panel.tsx index 0a1cff1854..8f5ae81540 100644 --- a/apps/desktop/src/app/settings/toolset-config-panel.tsx +++ b/apps/desktop/src/app/settings/toolset-config-panel.tsx @@ -22,6 +22,7 @@ import { useI18n } from '@/i18n' import { Check, Loader2, Save, Terminal } from '@/lib/icons' import { cn } from '@/lib/utils' import { upsertDesktopActionTask } from '@/store/activity' +import { confirm } from '@/store/confirm' import { notify, notifyError } from '@/store/notifications' import type { ActionStatusResponse, @@ -141,7 +142,7 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared, profile }: EnvVarField } async function handleClear() { - if (!window.confirm(copy.removeConfirm(envVar.key))) { + if (!(await confirm({ destructive: true, title: copy.removeConfirm(envVar.key) }))) { return } diff --git a/apps/desktop/src/app/settings/use-settings-search.ts b/apps/desktop/src/app/settings/use-settings-search.ts index 20d9b44173..19de61cab0 100644 --- a/apps/desktop/src/app/settings/use-settings-search.ts +++ b/apps/desktop/src/app/settings/use-settings-search.ts @@ -9,6 +9,7 @@ import { useI18n } from '@/i18n' import { Package, Palette, Settings2, Wrench } from '@/lib/icons' import { $agentPlugins, isDesktopRelevantPlugin, loadAgentPlugins } from '@/store/agent-plugins' import { $gatewayState } from '@/store/session' +import { TRANSLUCENCY_SUPPORTED } from '@/store/translucency' import { useHermesConfigRecord } from '../hooks/use-config-record' import { useOnProfileSwitch } from '../hooks/use-on-profile-switch' @@ -135,15 +136,21 @@ export function useSettingsSearchCatalog(enabled: boolean) { label: appearance.uiScaleTitle, target: { setting: APPEARANCE_SETTING_IDS.uiScale, view: 'config:appearance' } }, - { - context: appearanceContext, - description: appearance.translucencyDesc, - icon: Palette, - id: `setting:${APPEARANCE_SETTING_IDS.translucency}`, - keywords: ['opacity', 'transparent'], - label: appearance.translucencyTitle, - target: { setting: APPEARANCE_SETTING_IDS.translucency, view: 'config:appearance' } - }, + // Linux has no translucency row to land on, and a palette hit that scrolls + // to nothing is worse than no hit. + ...(TRANSLUCENCY_SUPPORTED + ? [ + { + context: appearanceContext, + description: appearance.translucencyDesc, + icon: Palette, + id: `setting:${APPEARANCE_SETTING_IDS.translucency}`, + keywords: ['opacity', 'transparent'], + label: appearance.translucencyTitle, + target: { setting: APPEARANCE_SETTING_IDS.translucency, view: 'config:appearance' as const } + } + ] + : []), { context: appearanceContext, description: appearance.backdropDesc, @@ -153,6 +160,15 @@ export function useSettingsSearchCatalog(enabled: boolean) { label: appearance.backdropTitle, target: { setting: APPEARANCE_SETTING_IDS.backdrop, view: 'config:appearance' } }, + { + context: appearanceContext, + description: appearance.introSplashDesc, + icon: Palette, + id: `setting:${APPEARANCE_SETTING_IDS.introSplash}`, + keywords: ['splash', 'wordmark', 'empty chat', 'new chat'], + label: appearance.introSplashTitle, + target: { setting: APPEARANCE_SETTING_IDS.introSplash, view: 'config:appearance' } + }, { context: appearanceContext, description: appearance.toolViewDesc, diff --git a/apps/desktop/src/app/shell/approval-mode-menu.test.tsx b/apps/desktop/src/app/shell/approval-mode-menu.test.tsx index 040471a19e..4702fc7abd 100644 --- a/apps/desktop/src/app/shell/approval-mode-menu.test.tsx +++ b/apps/desktop/src/app/shell/approval-mode-menu.test.tsx @@ -5,21 +5,13 @@ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import { StatusbarControls } from '@/app/shell/statusbar-controls' import { I18nProvider } from '@/i18n' import { $approvalModes } from '@/store/approval-mode' +import { stubMenuDomApis, stubResizeObserver } from '@/test/jsdom' import { useApprovalModeStatusbarItem } from './approval-mode-menu' -class TestResizeObserver { - observe() {} - unobserve() {} - disconnect() {} -} - beforeAll(() => { - vi.stubGlobal('ResizeObserver', TestResizeObserver) - Element.prototype.hasPointerCapture ??= () => false - Element.prototype.setPointerCapture ??= () => undefined - Element.prototype.releasePointerCapture ??= () => undefined - HTMLElement.prototype.scrollIntoView ??= () => undefined + stubResizeObserver() + stubMenuDomApis() }) afterEach(() => { diff --git a/apps/desktop/src/app/shell/gateway-menu-panel.tsx b/apps/desktop/src/app/shell/gateway-menu-panel.tsx index 8e1516b7bd..ff61fe6c26 100644 --- a/apps/desktop/src/app/shell/gateway-menu-panel.tsx +++ b/apps/desktop/src/app/shell/gateway-menu-panel.tsx @@ -6,7 +6,7 @@ import { LogView } from '@/components/ui/log-view' import { Tip } from '@/components/ui/tooltip' import { getLogs } from '@/hermes' import { useI18n } from '@/i18n' -import { LayoutDashboard, RefreshCw } from '@/lib/icons' +import { LayoutDashboard, Power, RefreshCw } from '@/lib/icons' import type { RuntimeReadinessResult } from '@/lib/runtime-readiness' import { cn } from '@/lib/utils' import { reconnectGateway } from '@/store/gateway-reconnect' @@ -185,17 +185,6 @@ export function GatewayMenuPanel({ )} - - - + {/* Restart is the heavy, disruptive action: keep it visually distinct + (power icon, destructive hover) and separated from the benign + reconnect/system buttons so it can't be hit by mistake. */} + + + +
diff --git a/apps/desktop/src/app/shell/hooks/use-status-snapshot.test.ts b/apps/desktop/src/app/shell/hooks/use-status-snapshot.test.ts index ce3db7e829..94859b09ad 100644 --- a/apps/desktop/src/app/shell/hooks/use-status-snapshot.test.ts +++ b/apps/desktop/src/app/shell/hooks/use-status-snapshot.test.ts @@ -3,6 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getStatus } from '@/hermes' +import { deferred } from '../../../test/deferred' + import { useStatusSnapshot } from './use-status-snapshot' vi.mock('@/hermes', () => ({ @@ -11,18 +13,6 @@ vi.mock('@/hermes', () => ({ type GatewayRequester = (method: string, params?: Record) => Promise -function deferred() { - let resolve: (value: T) => void = () => undefined - let reject: (reason?: unknown) => void = () => undefined - - const promise = new Promise((nextResolve, nextReject) => { - resolve = nextResolve - reject = nextReject - }) - - return { promise, reject, resolve } -} - async function flushAsync() { await act(async () => { await vi.advanceTimersByTimeAsync(0) @@ -155,6 +145,47 @@ describe('useStatusSnapshot', () => { expect(result.current.inferenceStatus).toBeNull() }) + it('refreshes readiness by source and ignores the previous backend response', async () => { + const workRuntime = deferred() + const workSetup = deferred() + const homeRuntime = deferred() + const homeSetup = deferred() + let source = 'work' + + const requestGateway = vi.fn((method: string) => { + if (source === 'work') { + return method === 'setup.runtime_check' ? workRuntime.promise : workSetup.promise + } + + return method === 'setup.runtime_check' ? homeRuntime.promise : homeSetup.promise + }) as unknown as GatewayRequester + + const { rerender, result } = renderHook(({ scope }) => useStatusSnapshot('open', requestGateway, scope), { + initialProps: { scope: 'work\0default' } + }) + + await flushAsync() + source = 'home' + rerender({ scope: 'home\0default' }) + await flushAsync() + + expect(result.current.inferenceStatus).toBeNull() + + await act(async () => { + homeRuntime.resolve({ ok: true }) + homeSetup.resolve({ provider_configured: true }) + await vi.advanceTimersByTimeAsync(0) + }) + expect(result.current.inferenceStatus).toMatchObject({ ready: true, source: 'runtime_check' }) + + await act(async () => { + workRuntime.resolve({ error: 'stale backend', ok: false }) + workSetup.resolve({ provider_configured: false }) + await vi.advanceTimersByTimeAsync(0) + }) + expect(result.current.inferenceStatus).toMatchObject({ ready: true, source: 'runtime_check' }) + }) + it('waits for a slow refresh to settle before scheduling another one', async () => { const setup = deferred() const runtime = deferred() diff --git a/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts b/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts index d9391dae20..5fdc639177 100644 --- a/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts +++ b/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts @@ -11,7 +11,11 @@ const REFRESH_MS = 60_000 type GatewayRequester = (method: string, params?: Record) => Promise -export function useStatusSnapshot(gatewayState: string | undefined, requestGateway: GatewayRequester) { +export function useStatusSnapshot( + gatewayState: string | undefined, + requestGateway: GatewayRequester, + gatewayScope = '' +) { const [statusSnapshot, setStatusSnapshot] = useState(null) const [inferenceStatus, setInferenceStatus] = useState(null) @@ -19,6 +23,12 @@ export function useStatusSnapshot(gatewayState: string | undefined, requestGatew let cancelled = false let timer: number | undefined + // Status and inference readiness belong to one backend. A source switch + // can keep gatewayState="open" throughout, so clear the previous source's + // snapshot and start a fresh scoped request explicitly. + setStatusSnapshot(null) + setInferenceStatus(null) + // A closed/connecting gateway cannot have an authoritative live-runtime // result. Clear readiness before starting the REST status leg so a hung // getStatus() cannot leave a stale "ready" state visible after disconnect. @@ -102,7 +112,7 @@ export function useStatusSnapshot(gatewayState: string | undefined, requestGatew window.clearTimeout(timer) } } - }, [gatewayState, requestGateway]) + }, [gatewayScope, gatewayState, requestGateway]) return { inferenceStatus, statusSnapshot } } diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx index 98fd2dce5e..65e770819f 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -1,6 +1,8 @@ import { useStore } from '@nanostores/react' import { useMemo } from 'react' +import { useNavigate } from 'react-router' +import { ConnectionSwitcher } from '@/app/chat/sidebar/connection-switcher' import type { CommandCenterSection } from '@/app/command-center' import { useApprovalModeStatusbarItem } from '@/app/shell/approval-mode-menu' import { ContextUsagePanel } from '@/app/shell/context-usage-panel' @@ -92,6 +94,8 @@ export function useStatusbarItems({ // the takeover store alone stays true behind a stacked sibling tab or a // minimized zone, which lit the button for a pane the user couldn't see. const terminalShowing = useStore($paneVisible('terminal')) + const sessionsShowing = useStore($paneVisible('sessions')) + const botsShowing = useStore($paneVisible('hermes-bots:pane')) const primaryBusy = useStore($busy) // Draft / primary composer atom — used only while the focused surface is the // primary (or a draft with no runtime slice yet). A focused TILE keeps its @@ -386,34 +390,8 @@ export function useStatusbarItems({ copy ]) - const connectionItem = useMemo(() => { - if (connection?.mode !== 'remote' || !connection.remoteHost) { - return null - } - - const ssh = connection.remoteKind === 'ssh' - const cloud = connection.remoteKind === 'cloud' - - return { - className: cn( - 'px-2 -ml-1 font-medium', - ssh ? 'bg-primary text-primary-foreground' : 'bg-accent text-accent-foreground' - ), - icon: , - id: 'connection', - label: ssh - ? copy.connectionSsh(connection.remoteHost) - : cloud - ? copy.connectionCloud(connection.remoteHost) - : copy.connectionRemote(connection.remoteHost), - // Label already names the host — no "click to manage" tip lecture. - to: `${SETTINGS_ROUTE}?tab=gateway` - } - }, [connection?.mode, connection?.remoteHost, connection?.remoteKind, copy]) - const coreLeftStatusbarItems = useMemo( () => [ - ...(connectionItem ? [connectionItem] : []), { className: `w-7 justify-center px-0${commandCenterOpen ? ' bg-accent/55 text-foreground' : ''}`, icon: , @@ -426,9 +404,16 @@ export function useStatusbarItems({ toggleLabel: copy.toggleCommandCenter, variant: 'action' }, + { + hidden: !sessionsShowing, + id: 'gateway-switcher', + lockedVisible: true, + render: () => + }, { className: gatewayRestarting ? undefined : gatewayClassName, detail: gatewayRestarting ? copy.gatewayRestarting : gatewayDetail, + hidden: botsShowing, icon: gatewayRestarting ? ( ) : inferenceReady ? ( @@ -524,8 +509,8 @@ export function useStatusbarItems({ ], [ agentsOpen, + botsShowing, commandCenterOpen, - connectionItem, copy, currentCwd, fileMenu.copyPath, @@ -539,6 +524,7 @@ export function useStatusbarItems({ inferenceStatus?.reason, openAgents, projectName, + sessionsShowing, subagentsFailed, subagentsRunning, toggleCommandCenter @@ -627,3 +613,9 @@ export function useStatusbarItems({ return { leftStatusbarItems, statusbarItems } } + +function StatusbarGatewaySwitcher() { + const navigate = useNavigate() + + return navigate(`${SETTINGS_ROUTE}?tab=connections`)} /> +} diff --git a/apps/desktop/src/app/shell/model-catalog-menu.tsx b/apps/desktop/src/app/shell/model-catalog-menu.tsx index 2513725fd9..1dfd70f15b 100644 --- a/apps/desktop/src/app/shell/model-catalog-menu.tsx +++ b/apps/desktop/src/app/shell/model-catalog-menu.tsx @@ -458,6 +458,7 @@ export function ModelCatalogMenu({ ) : null}