From 6cc7d54e1ad601e02fc9afac6d30b84e847ceac8 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Fri, 11 Sep 2026 08:38:33 -0700 Subject: [PATCH 01/61] fix(relay): include cwd in session and turn scopes Signed-off-by: Alex Fournier --- agent/relay_cwd.py | 88 +++++++++++++++++ agent/relay_runtime.py | 51 ++++++++-- agent/runtime_cwd.py | 10 ++ agent/turn_facade.py | 6 ++ docs/observability/relay-shared-metrics.md | 14 +++ tests/agent/test_relay_atof_cwd.py | 109 +++++++++++++++++++++ tests/agent/test_relay_cwd.py | 61 ++++++++++++ tests/agent/test_relay_session_segments.py | 33 ++++++- 8 files changed, 360 insertions(+), 12 deletions(-) create mode 100644 agent/relay_cwd.py create mode 100644 tests/agent/test_relay_atof_cwd.py create mode 100644 tests/agent/test_relay_cwd.py diff --git a/agent/relay_cwd.py b/agent/relay_cwd.py new file mode 100644 index 0000000000..63e978b472 --- /dev/null +++ b/agent/relay_cwd.py @@ -0,0 +1,88 @@ +"""Resolve logical working directories for Hermes-owned Relay scopes.""" + +from __future__ import annotations + +import logging +from typing import Any + +logger = logging.getLogger(__name__) + +_CWD_SENTINELS = frozenset({"", ".", "./", "auto", "cwd"}) + + +def _clean_cwd(value: Any) -> str: + if not isinstance(value, str): + return "" + value = value.strip() + return "" if value.lower() in _CWD_SENTINELS else value + + +def _recorded_cwd(key: str) -> str: + if not key: + return "" + from tools.terminal_tool import get_session_cwd + + return _clean_cwd(get_session_cwd(key)) + + +def resolve_relay_scope_cwds( + agent: Any, task_id: str, session_id: str, platform: str +) -> tuple[str, str]: + """Return logical ``(session_cwd, turn_cwd)`` for Relay scope input. + + A task may use a worktree distinct from its owning session. Preserve remote paths as + declared and omit unknown paths instead of substituting the Hermes host's cwd. + """ + try: + task_cwd = _recorded_cwd(task_id) + except Exception: + logger.debug("Unable to read the Relay turn cwd", exc_info=True) + task_cwd = "" + + session_cwd = "" + try: + from agent.runtime_cwd import scoped_session_cwd + + session_cwd = _clean_cwd(scoped_session_cwd()) + except Exception: + logger.debug("Unable to read the scoped Relay session cwd", exc_info=True) + + if not session_cwd: + try: + from gateway.session_context import get_session_env + + session_key = get_session_env("HERMES_SESSION_KEY", "") + for key in dict.fromkeys(key for key in (session_key, session_id) if key): + if recorded := _recorded_cwd(key): + session_cwd = recorded + break + except Exception: + logger.debug("Unable to read the recorded Relay session cwd", exc_info=True) + + if not session_cwd: + session_cwd = _clean_cwd(getattr(agent, "session_cwd", None)) + + backend = "" + if not session_cwd: + try: + from tools.terminal_scope import terminal_env + + backend = terminal_env("TERMINAL_ENV", "local").strip().lower() + session_cwd = _clean_cwd(terminal_env("TERMINAL_CWD", "")) + except Exception: + logger.debug( + "Unable to read the configured Relay session cwd", exc_info=True + ) + + if not session_cwd and platform == "cli" and backend in {"", "local"}: + try: + from agent.runtime_cwd import resolve_agent_cwd + + session_cwd = _clean_cwd(str(resolve_agent_cwd())) + except Exception: + logger.debug("Unable to resolve the local Relay session cwd", exc_info=True) + + turn_cwd = task_cwd or session_cwd + if platform in {"subagent", "cron"} and task_cwd: + session_cwd = task_cwd + return session_cwd or turn_cwd, turn_cwd diff --git a/agent/relay_runtime.py b/agent/relay_runtime.py index a204ec5b76..66ed367e88 100644 --- a/agent/relay_runtime.py +++ b/agent/relay_runtime.py @@ -73,6 +73,11 @@ def runtime_metadata(runtime_id: str, **extra: Any) -> dict[str, Any]: return {RUNTIME_SCHEMA_KEY: RUNTIME_SCHEMA_VERSION, RUNTIME_INSTANCE_KEY: runtime_id, **extra} +def _scope_input(cwd: Any = None) -> dict[str, str]: + """Return Relay scope input for a known logical working directory.""" + return {"cwd": cwd.strip()} if isinstance(cwd, str) and cwd.strip() else {} + + def _run_on_daemon_thread( fn: Callable[[], Any], *, name: str, timeout: float | None = None, timeout_message: str = "" ) -> Any: @@ -154,6 +159,7 @@ class RelaySession: segment_turns: int = 0 # turns completed within the current segment rotate_pending: bool = False # consumed at next begin_turn close_pending: bool = False # rotating compaction hit a live turn; end_turn consumes it + cwd: str = "" # latest logical working directory; reused when a session segment rotates def _load_segments_config() -> dict[str, Any]: @@ -387,7 +393,7 @@ class RelayRuntime: scope_metadata["nemo_relay_scope_role"] = "subagent" context = contextvars.Context() args = (self.relay.scope.push, SESSION_SCOPE, self.relay.ScopeType.Agent) - push_kwargs.update(handle=parent_handle, metadata=scope_metadata, input={}) + push_kwargs.update(handle=parent_handle, metadata=scope_metadata, input=_scope_input(session.cwd)) try: future = _scope_op_executor().submit(context.run, *args, **push_kwargs) session.handle = future.result(timeout=_SCOPE_OP_TIMEOUT) @@ -398,7 +404,12 @@ class RelayRuntime: session.context = context def ensure_session( - self, event: dict[str, Any], *, data: Any = None, metadata: dict[str, Any] | None = None + self, + event: dict[str, Any], + *, + data: Any = None, + metadata: dict[str, Any] | None = None, + cwd: str | None = None, ) -> RelaySession | None: """Return the existing session scope or create it once.""" session_id = _session_id(event) @@ -415,6 +426,8 @@ class RelayRuntime: with session.lock: if session.closing: return None + if isinstance(cwd, str) and cwd.strip(): + session.cwd = cwd.strip() if session.handle is None: self._open_session_scope( session, {**(metadata or {}), **runtime_metadata(self.runtime_id)}, @@ -455,7 +468,8 @@ class RelayRuntime: ) def register_subagent( - self, event: dict[str, Any], *, metadata: dict[str, Any] | None = None + self, event: dict[str, Any], *, metadata: dict[str, Any] | None = None, + cwd: str | None = None, ) -> RelaySession | None: """Open a child Agent scope under its spawning turn when available.""" parent_session_id = str(event.get("parent_session_id") or "") @@ -473,7 +487,7 @@ class RelayRuntime: self._subagent_parents[child_session_id] = parent_session_id if parent_handle is not None: self._subagent_parent_handles[child_session_id] = parent_handle - return self.ensure_session({"session_id": child_session_id}, metadata=metadata) + return self.ensure_session({"session_id": child_session_id}, metadata=metadata, cwd=cwd) def unregister_subagent(self, event: dict[str, Any]) -> None: """Close a delegated session and forget its parent relationship.""" @@ -799,6 +813,7 @@ class ConversationLease: host: RelayHost session: RelaySession | None parent_session_id: str = "" + turn_cwd: str = "" released: bool = False def live_runtime(self) -> RelayRuntime | None: @@ -898,29 +913,44 @@ class RelaySessionCoordinator: logger.warning("Hermes Relay session initializer failed: %s", name, exc_info=True) def acquire_conversation( - self, *, profile_key: str, session_id: str, platform: str, parent_session_id: str = "", model: str = "", + self, + *, + profile_key: str, + session_id: str, + platform: str, + parent_session_id: str = "", + model: str = "", + session_cwd: str | None = None, + turn_cwd: str | None = None, ) -> ConversationLease: host = self.registry.for_profile(profile_key) or NoopRelayRuntime(profile_key, "Relay host creation was disabled") session = None if isinstance(host, RelayRuntime): context = { "profile_key": profile_key, "session_id": session_id, "platform": platform, - "parent_session_id": parent_session_id, "model": model, + "parent_session_id": parent_session_id, "model": model, "cwd": session_cwd, } session = _warn_on_error("conversation initialization", self._open_conversation_session, host, context) + effective_turn_cwd = ( + _scope_input(turn_cwd).get("cwd") or _scope_input(session_cwd).get("cwd") or "" + ) + if not effective_turn_cwd and session is not None: + with session.lock: + effective_turn_cwd = session.cwd return ConversationLease( profile_key=profile_key, session_id=session_id, platform=platform, host=host, - session=session, parent_session_id=parent_session_id, + session=session, parent_session_id=parent_session_id, turn_cwd=effective_turn_cwd, ) def _open_conversation_session(self, host: RelayRuntime, context: dict[str, Any]) -> RelaySession | None: self._prepare_session(host, context) session_id, parent_session_id = context["session_id"], context["parent_session_id"] metadata = {"hermes.execution_surface": context["platform"] or "unknown"} + cwd = context.get("cwd") if parent_session_id and parent_session_id != session_id: event = {"parent_session_id": parent_session_id, "child_session_id": session_id} - return host.register_subagent(event, metadata=metadata) - return host.ensure_session({"session_id": session_id}, metadata=metadata) + return host.register_subagent(event, metadata=metadata, cwd=cwd) + return host.ensure_session({"session_id": session_id}, metadata=metadata, cwd=cwd) def begin_turn(self, lease: ConversationLease, *, turn_id: str, task_id: str) -> RelayTurnContext: if lease.released: @@ -944,7 +974,8 @@ class RelaySessionCoordinator: _warn_on_error("segment rotation", self._maybe_rotate_segment, host, lease.session) turn.handle = _warn_on_error( "turn initialization", host.run_in_session, lease.session, host.relay.scope.push, - TURN_SCOPE, host.relay.ScopeType.Function, handle=lease.session.handle, input={}, + TURN_SCOPE, host.relay.ScopeType.Function, handle=lease.session.handle, + input=_scope_input(lease.turn_cwd), metadata=runtime_metadata(host.runtime_id, **{"hermes.execution_surface": lease.platform or "unknown"}), timeout=_SCOPE_OP_TIMEOUT, ) diff --git a/agent/runtime_cwd.py b/agent/runtime_cwd.py index b70f084c97..71d49587a2 100644 --- a/agent/runtime_cwd.py +++ b/agent/runtime_cwd.py @@ -44,6 +44,16 @@ def clear_session_cwd() -> None: _SESSION_CWD.set("") +def scoped_session_cwd() -> str: + """Return the current session's declared cwd without local path validation. + + Remote and container paths may not exist on the Hermes host. Callers that only need + logical workspace identity should preserve the declared value instead of resolving it. + """ + value = _SESSION_CWD.get() + return "" if value is _UNSET else str(value).strip() + + def scope_terminal_cwd() -> str: """Scope-aware TERMINAL_CWD value (may be empty) — every cwd consumer reads through this. diff --git a/agent/turn_facade.py b/agent/turn_facade.py index acd3bc97ed..676f7886f7 100644 --- a/agent/turn_facade.py +++ b/agent/turn_facade.py @@ -46,6 +46,7 @@ class TurnFacadeMixin: set_conversation_context, ) from agent.prompt_cache_scope import declared_conversation_scope_safe + from agent.relay_cwd import resolve_relay_scope_cwds from agent.review_idle_queue import QUEUE as _review_queue from agent.subagent_lifecycle import bind_subagent_parent from agent.interrupt_scope import track_in_interrupt_scope @@ -88,11 +89,16 @@ class TurnFacadeMixin: lease = admission.lease conversation_history = admission.conversation_history + relay_session_cwd, relay_turn_cwd = resolve_relay_scope_cwds( + self, effective_task_id, session_id, task_context["platform"] + ) relay_lease = relay_runtime.SESSION_COORDINATOR.acquire_conversation( profile_key=relay_runtime.current_profile_key(), session_id=task_context["session_id"], platform=task_context["platform"], parent_session_id=relay_parent_session_id, model=str(getattr(self, "model", None) or ""), + session_cwd=relay_session_cwd, + turn_cwd=relay_turn_cwd, ) relay_turn = relay_runtime.SESSION_COORDINATOR.begin_turn( relay_lease, turn_id=relay_turn_id, task_id=effective_task_id diff --git a/docs/observability/relay-shared-metrics.md b/docs/observability/relay-shared-metrics.md index 23d016659c..d12024321d 100644 --- a/docs/observability/relay-shared-metrics.md +++ b/docs/observability/relay-shared-metrics.md @@ -94,6 +94,20 @@ Both defaults preserve one session scope for the full session. Rotated spans retain the same `session_id` and add `hermes.session.segment` plus `hermes.session.segment_reason` (`compaction` or `max_turns`). +## Working-Directory Scope Data + +When Hermes knows a session or task's logical working directory, its +`hermes.session` and `hermes.turn` start scopes include it as `data.cwd` in +ATOF. A turn running in a task worktree can therefore differ from its owning +session. Unknown directories are omitted, and scope-end data remains reserved +for the outcome. + +The working directory is Relay scope input, so it is visible to every enabled +Relay subscriber, not only ATOF. Paths can reveal usernames, repository names, +or mount layouts. Relay does not filter events by working directory; if a path +must not leave the host, use a trusted local collector or do not enable a remote +exporter for that process. + ## Process-Wide Plugin Policy and Profile Isolation Relay plugin configuration is a process-level deployment choice, not a Hermes diff --git a/tests/agent/test_relay_atof_cwd.py b/tests/agent/test_relay_atof_cwd.py new file mode 100644 index 0000000000..041ec0ad70 --- /dev/null +++ b/tests/agent/test_relay_atof_cwd.py @@ -0,0 +1,109 @@ +"""Hermes scope cwd export through the real NeMo Relay ATOF plugin.""" + +from __future__ import annotations + +import json + +import pytest + + +def test_run_conversation_exports_session_and_turn_cwds(tmp_path, monkeypatch): + relay = pytest.importorskip("nemo_relay") + if getattr(relay, "_native", None) is None: + pytest.skip("NeMo Relay native binding is unavailable on this platform") + + from agent import relay_runtime + from hermes_cli.lifecycle import finalize_session + from run_agent import AIAgent + from tools.terminal_tool import clear_session_cwd, record_session_cwd + + hermes_home = tmp_path / "hermes-home" + session_cwd = tmp_path / "session" + turn_cwd = tmp_path / "task" + atof_dir = tmp_path / "atof" + for directory in (hermes_home, session_cwd, turn_cwd, atof_dir): + directory.mkdir() + + config = tmp_path / "plugins.toml" + config.write_text( + f"""version = 1 + +[[components]] +kind = "observability" +enabled = true + +[components.config] +version = 4 + +[components.config.atof] +enabled = true + +[[components.config.atof.sinks]] +type = "file" +output_directory = {json.dumps(str(atof_dir))} +filename = "events.jsonl" +mode = "overwrite" +""", + encoding="utf-8", + ) + session_id = "cwd-e2e-session" + task_id = "cwd-e2e-task" + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv(relay_runtime.RELAY_PLUGINS_CONFIG_ENV, str(config)) + monkeypatch.chdir(session_cwd) + monkeypatch.setattr( + "agent.conversation_loop.run_conversation", + lambda *_args, **_kwargs: {"final_response": "ok", "completed": True}, + ) + + relay_runtime._reset_for_tests() + relay.plugin.clear() + record_session_cwd(task_id, str(turn_cwd)) + agent = None + try: + agent = AIAgent( + api_key="test-key", + base_url="https://example.invalid/v1", + provider="openai", + model="test-model", + session_id=session_id, + platform="cli", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + disabled_toolsets=["*"], + ) + result = agent.run_conversation("hello", task_id=task_id) + assert result["final_response"] == "ok" + finalize_session(session_id=session_id) + finally: + if agent is not None: + agent.close() + clear_session_cwd(task_id) + relay_runtime._reset_for_tests() + + events = [ + json.loads(line) + for line in (atof_dir / "events.jsonl").read_text(encoding="utf-8").splitlines() + if line.strip() + ] + + def only_event(name: str, category: str) -> dict: + matches = [ + event + for event in events + if event.get("name") == name and event.get("scope_category") == category + ] + assert len(matches) == 1 + return matches[0] + + session_start = only_event("hermes.session", "start") + turn_start = only_event("hermes.turn", "start") + session_end = only_event("hermes.session", "end") + turn_end = only_event("hermes.turn", "end") + + assert session_start["data"] == {"cwd": str(session_cwd)} + assert turn_start["data"] == {"cwd": str(turn_cwd)} + assert turn_start["parent_uuid"] == session_start["uuid"] + assert "cwd" not in session_end["data"] + assert "cwd" not in turn_end["data"] diff --git a/tests/agent/test_relay_cwd.py b/tests/agent/test_relay_cwd.py new file mode 100644 index 0000000000..da53e06fda --- /dev/null +++ b/tests/agent/test_relay_cwd.py @@ -0,0 +1,61 @@ +"""Logical working directories attached to Hermes-owned Relay scopes.""" + +from agent import relay_cwd, runtime_cwd +from tools.terminal_tool import clear_session_cwd, record_session_cwd + + +def test_session_context_and_task_record_remain_distinct(monkeypatch): + token = runtime_cwd.set_session_cwd("/workspace/session") + record_session_cwd("task-1", "/workspace/task") + monkeypatch.setattr( + "gateway.session_context.get_session_env", lambda _name, _default: "" + ) + try: + assert relay_cwd.resolve_relay_scope_cwds( + object(), "task-1", "session-1", "cli" + ) == ("/workspace/session", "/workspace/task") + assert relay_cwd.resolve_relay_scope_cwds( + object(), "task-1", "session-1", "subagent" + ) == ("/workspace/task", "/workspace/task") + finally: + clear_session_cwd("task-1") + runtime_cwd._SESSION_CWD.reset(token) + + +def test_remote_scoped_cwd_is_preserved_without_host_resolution(monkeypatch): + token = runtime_cwd.set_session_cwd("~/remote-project") + monkeypatch.setattr("tools.terminal_tool.get_session_cwd", lambda _key: None) + monkeypatch.setattr( + runtime_cwd, + "resolve_agent_cwd", + lambda: (_ for _ in ()).throw(AssertionError("must not resolve on this host")), + ) + try: + assert relay_cwd.resolve_relay_scope_cwds( + object(), "task-1", "session-1", "gateway" + ) == ("~/remote-project", "~/remote-project") + finally: + runtime_cwd._SESSION_CWD.reset(token) + + +def test_gateway_session_key_uses_its_recorded_cwd(monkeypatch): + token = runtime_cwd.set_session_cwd(None) + record_session_cwd("gateway-key", "/remote/gateway-workspace") + monkeypatch.setattr( + "gateway.session_context.get_session_env", + lambda name, default: ( + "gateway-key" if name == "HERMES_SESSION_KEY" else default + ), + ) + monkeypatch.setattr( + runtime_cwd, + "resolve_agent_cwd", + lambda: (_ for _ in ()).throw(AssertionError("must not resolve on this host")), + ) + try: + assert relay_cwd.resolve_relay_scope_cwds( + object(), "task-1", "session-1", "gateway" + ) == ("/remote/gateway-workspace", "/remote/gateway-workspace") + finally: + clear_session_cwd("gateway-key") + runtime_cwd._SESSION_CWD.reset(token) diff --git a/tests/agent/test_relay_session_segments.py b/tests/agent/test_relay_session_segments.py index 2e778c4d47..92eb0c0772 100644 --- a/tests/agent/test_relay_session_segments.py +++ b/tests/agent/test_relay_session_segments.py @@ -39,7 +39,7 @@ class _FakeScopeModule: def __init__(self, wedge_pop: threading.Event | None = None) -> None: self._wedge = wedge_pop self._seq = 0 - self.pushes: list[dict[str, Any]] = [] # {name, metadata, handle} + self.pushes: list[dict[str, Any]] = [] # {name, metadata, handle, input} self.pops: list[_ScopeHandle] = [] def push(self, name: str, scope_type: Any, **kwargs: Any) -> _ScopeHandle: @@ -49,6 +49,7 @@ class _FakeScopeModule: "name": name, "metadata": dict(kwargs.get("metadata") or {}), "parent": kwargs.get("handle"), + "input": dict(kwargs.get("input") or {}), "seq": self._seq, } ) @@ -143,7 +144,7 @@ def coordinator() -> RelaySessionCoordinator: return RelaySessionCoordinator() -def _acquire(coordinator, runtime, session_id="sess-1"): +def _acquire(coordinator, runtime, session_id="sess-1", **kwargs): class _Registry: def for_profile(self, key): return runtime @@ -154,6 +155,7 @@ def _acquire(coordinator, runtime, session_id="sess-1"): profile_key=runtime.profile_key, session_id=session_id, platform="test", + **kwargs, ) @@ -186,6 +188,33 @@ class TestDefaultsNeverRotate: ) +class TestCwdProjection: + def test_distinct_session_and_turn_cwds_survive_segment_rotation(self, coordinator): + fake = _FakeRelay() + runtime = _make_runtime(fake) + lease = _acquire( + coordinator, runtime, + session_cwd="/workspace/session", turn_cwd="/workspace/task", + ) + + turn = coordinator.begin_turn(lease, turn_id="t1", task_id="task1") + + assert _session_pushes(fake)[-1]["input"] == {"cwd": "/workspace/session"} + assert fake.scope.pushes[-1]["input"] == {"cwd": "/workspace/task"} + coordinator.end_turn(turn, outcome="success") + + lease = _acquire( + coordinator, runtime, + session_cwd="/workspace/moved", turn_cwd="/workspace/next-task", + ) + runtime.rotate_session_scope(lease.session, reason="compaction") + turn = coordinator.begin_turn(lease, turn_id="t2", task_id="task2") + + assert _session_pushes(fake)[-1]["input"] == {"cwd": "/workspace/moved"} + assert fake.scope.pushes[-1]["input"] == {"cwd": "/workspace/next-task"} + coordinator.end_turn(turn, outcome="success") + + class TestCompactionRotation: def test_compaction_rotates_at_next_begin_turn_not_immediately( self, coordinator, monkeypatch From 6bfd5bce4801f3add8688b778c5aec04456917ab Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Fri, 11 Sep 2026 09:31:13 -0700 Subject: [PATCH 02/61] fix(relay): harden cwd resolution Signed-off-by: Alex Fournier --- agent/relay_cwd.py | 7 ++- agent/relay_runtime.py | 3 +- tests/agent/test_relay_atof_cwd.py | 1 - tests/agent/test_relay_cwd.py | 76 ++++++++++++++++++++++++++++-- 4 files changed, 80 insertions(+), 7 deletions(-) diff --git a/agent/relay_cwd.py b/agent/relay_cwd.py index 63e978b472..3eee3a44ef 100644 --- a/agent/relay_cwd.py +++ b/agent/relay_cwd.py @@ -74,11 +74,14 @@ def resolve_relay_scope_cwds( "Unable to read the configured Relay session cwd", exc_info=True ) - if not session_cwd and platform == "cli" and backend in {"", "local"}: + if not session_cwd and platform in {"", "cli"} and backend in {"", "local"}: try: from agent.runtime_cwd import resolve_agent_cwd - session_cwd = _clean_cwd(str(resolve_agent_cwd())) + resolved = resolve_agent_cwd() + session_cwd = _clean_cwd( + str(resolved if resolved.is_absolute() else resolved.resolve()) + ) except Exception: logger.debug("Unable to resolve the local Relay session cwd", exc_info=True) diff --git a/agent/relay_runtime.py b/agent/relay_runtime.py index 66ed367e88..3ca978c61c 100644 --- a/agent/relay_runtime.py +++ b/agent/relay_runtime.py @@ -813,8 +813,9 @@ class ConversationLease: host: RelayHost session: RelaySession | None parent_session_id: str = "" - turn_cwd: str = "" released: bool = False + # Keep new fields after the pre-existing positional constructor fields. + turn_cwd: str = "" def live_runtime(self) -> RelayRuntime | None: """Return the real Relay host when this lease owns an open session.""" diff --git a/tests/agent/test_relay_atof_cwd.py b/tests/agent/test_relay_atof_cwd.py index 041ec0ad70..bb19406b98 100644 --- a/tests/agent/test_relay_atof_cwd.py +++ b/tests/agent/test_relay_atof_cwd.py @@ -67,7 +67,6 @@ mode = "overwrite" provider="openai", model="test-model", session_id=session_id, - platform="cli", quiet_mode=True, skip_context_files=True, skip_memory=True, diff --git a/tests/agent/test_relay_cwd.py b/tests/agent/test_relay_cwd.py index da53e06fda..7932931b4d 100644 --- a/tests/agent/test_relay_cwd.py +++ b/tests/agent/test_relay_cwd.py @@ -1,9 +1,23 @@ """Logical working directories attached to Hermes-owned Relay scopes.""" +from pathlib import Path + +import pytest + from agent import relay_cwd, runtime_cwd +from agent.relay_runtime import ConversationLease from tools.terminal_tool import clear_session_cwd, record_session_cwd +def test_conversation_lease_keeps_released_in_its_original_position(): + lease = ConversationLease( + "profile", "session", "cli", object(), None, "parent", True + ) + + assert lease.released is True + assert lease.turn_cwd == "" + + def test_session_context_and_task_record_remain_distinct(monkeypatch): token = runtime_cwd.set_session_cwd("/workspace/session") record_session_cwd("task-1", "/workspace/task") @@ -22,8 +36,13 @@ def test_session_context_and_task_record_remain_distinct(monkeypatch): runtime_cwd._SESSION_CWD.reset(token) -def test_remote_scoped_cwd_is_preserved_without_host_resolution(monkeypatch): - token = runtime_cwd.set_session_cwd("~/remote-project") +@pytest.mark.parametrize( + "logical_cwd", ["~/remote-project", r"C:\work\project", r"\\server\share\project"] +) +def test_remote_scoped_cwd_is_preserved_without_host_resolution( + monkeypatch, logical_cwd +): + token = runtime_cwd.set_session_cwd(logical_cwd) monkeypatch.setattr("tools.terminal_tool.get_session_cwd", lambda _key: None) monkeypatch.setattr( runtime_cwd, @@ -33,7 +52,7 @@ def test_remote_scoped_cwd_is_preserved_without_host_resolution(monkeypatch): try: assert relay_cwd.resolve_relay_scope_cwds( object(), "task-1", "session-1", "gateway" - ) == ("~/remote-project", "~/remote-project") + ) == (logical_cwd, logical_cwd) finally: runtime_cwd._SESSION_CWD.reset(token) @@ -59,3 +78,54 @@ def test_gateway_session_key_uses_its_recorded_cwd(monkeypatch): finally: clear_session_cwd("gateway-key") runtime_cwd._SESSION_CWD.reset(token) + + +@pytest.mark.parametrize( + ("platform", "backend", "resolved", "expected", "uses_host"), + [ + ("cli", "local", Path("/workspace"), "/workspace", True), + ("", "local", Path("."), str(Path.cwd().resolve()), True), + ("cli", "ssh", Path("/host"), "", False), + ("gateway", "local", Path("/host"), "", False), + ], +) +def test_only_local_cli_uses_host_cwd( + monkeypatch, platform, backend, resolved, expected, uses_host +): + token = runtime_cwd.set_session_cwd(None) + host_lookups = [] + + def resolve_host_cwd(): + host_lookups.append(True) + return resolved + + monkeypatch.setattr("tools.terminal_tool.get_session_cwd", lambda _key: None) + monkeypatch.setattr( + "gateway.session_context.get_session_env", lambda _name, default: default + ) + monkeypatch.setattr( + "tools.terminal_scope.terminal_env", + lambda name, default: backend if name == "TERMINAL_ENV" else default, + ) + monkeypatch.setattr(runtime_cwd, "resolve_agent_cwd", resolve_host_cwd) + try: + assert relay_cwd.resolve_relay_scope_cwds( + object(), "task-1", "session-1", platform + ) == (expected, expected) + assert bool(host_lookups) is uses_host + finally: + runtime_cwd._SESSION_CWD.reset(token) + + +def test_cwd_lookup_failures_do_not_break_the_turn(monkeypatch): + def unavailable(*_args): + raise RuntimeError("unavailable") + + monkeypatch.setattr("tools.terminal_tool.get_session_cwd", unavailable) + monkeypatch.setattr(runtime_cwd, "scoped_session_cwd", unavailable) + monkeypatch.setattr("gateway.session_context.get_session_env", unavailable) + monkeypatch.setattr("tools.terminal_scope.terminal_env", unavailable) + + assert relay_cwd.resolve_relay_scope_cwds( + object(), "task-1", "session-1", "gateway" + ) == ("", "") From 03616c2e245f9b3d25a9b1d542b395537fe5a5b3 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 17 Sep 2026 19:51:57 +0200 Subject: [PATCH 03/61] test(relay): make cwd expectation portable Signed-off-by: Alex Fournier --- tests/agent/test_relay_cwd.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/agent/test_relay_cwd.py b/tests/agent/test_relay_cwd.py index 7932931b4d..8ebf1e8895 100644 --- a/tests/agent/test_relay_cwd.py +++ b/tests/agent/test_relay_cwd.py @@ -83,7 +83,13 @@ def test_gateway_session_key_uses_its_recorded_cwd(monkeypatch): @pytest.mark.parametrize( ("platform", "backend", "resolved", "expected", "uses_host"), [ - ("cli", "local", Path("/workspace"), "/workspace", True), + ( + "cli", + "local", + Path("/workspace"), + str(Path("/workspace").resolve()), + True, + ), ("", "local", Path("."), str(Path.cwd().resolve()), True), ("cli", "ssh", Path("/host"), "", False), ("gateway", "local", Path("/host"), "", False), From d7c657bf3c95368e46b1798e66603966fd3c9434 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Wed, 23 Sep 2026 07:46:43 -0400 Subject: [PATCH 04/61] fix(relay): clear stale cwd scope data Signed-off-by: Alex Fournier --- agent/relay_runtime.py | 15 +++++++++------ agent/turn_facade.py | 5 ++++- tests/agent/test_relay_session_segments.py | 20 ++++++++++++++++++++ 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/agent/relay_runtime.py b/agent/relay_runtime.py index 5434cbe4a5..8981001c1b 100644 --- a/agent/relay_runtime.py +++ b/agent/relay_runtime.py @@ -445,8 +445,8 @@ class RelayRuntime: with session.lock: if session.closing: return None - if isinstance(cwd, str) and cwd.strip(): - session.cwd = cwd.strip() + if cwd is not None: + session.cwd = _scope_input(cwd).get("cwd", "") if session.handle is None: self._open_session_scope( session, {**(metadata or {}), **runtime_metadata(self.runtime_id)}, @@ -950,12 +950,15 @@ class RelaySessionCoordinator: "parent_session_id": parent_session_id, "model": model, "cwd": session_cwd, } session = _warn_on_error("conversation initialization", self._open_conversation_session, host, context) - effective_turn_cwd = ( - _scope_input(turn_cwd).get("cwd") or _scope_input(session_cwd).get("cwd") or "" - ) - if not effective_turn_cwd and session is not None: + if turn_cwd is not None: + effective_turn_cwd = _scope_input(turn_cwd).get("cwd", "") + elif session_cwd is not None: + effective_turn_cwd = _scope_input(session_cwd).get("cwd", "") + elif session is not None: with session.lock: effective_turn_cwd = session.cwd + else: + effective_turn_cwd = "" return ConversationLease( profile_key=profile_key, session_id=session_id, platform=platform, host=host, session=session, parent_session_id=parent_session_id, turn_cwd=effective_turn_cwd, diff --git a/agent/turn_facade.py b/agent/turn_facade.py index 67a2834d4f..3c901a41cb 100644 --- a/agent/turn_facade.py +++ b/agent/turn_facade.py @@ -96,7 +96,10 @@ class TurnFacadeMixin: conversation_history = admission.conversation_history relay_session_cwd, relay_turn_cwd = resolve_relay_scope_cwds( - self, effective_task_id, session_id, task_context["platform"] + self, + effective_task_id, + task_context["session_id"], + task_context["platform"], ) relay_lease = relay_runtime.SESSION_COORDINATOR.acquire_conversation( profile_key=relay_runtime.current_profile_key(), diff --git a/tests/agent/test_relay_session_segments.py b/tests/agent/test_relay_session_segments.py index 9287d6141a..72feeb06f2 100644 --- a/tests/agent/test_relay_session_segments.py +++ b/tests/agent/test_relay_session_segments.py @@ -297,6 +297,26 @@ class TestCwdProjection: assert fake.scope.pushes[-1]["input"] == {"cwd": "/workspace/next-task"} coordinator.end_turn(turn, outcome="success") + def test_explicit_unknown_cwd_clears_prior_scope_input(self, coordinator): + fake = _FakeRelay() + runtime = _make_runtime(fake) + lease = _acquire( + coordinator, runtime, + session_cwd="/workspace/session", turn_cwd="/workspace/task", + ) + coordinator.end_turn( + coordinator.begin_turn(lease, turn_id="t1", task_id="task1"), + outcome="success", + ) + + lease = _acquire(coordinator, runtime, session_cwd="", turn_cwd="") + runtime.rotate_session_scope(lease.session, reason="compaction") + turn = coordinator.begin_turn(lease, turn_id="t2", task_id="task2") + + assert _session_pushes(fake)[-1]["input"] == {} + assert fake.scope.pushes[-1]["input"] == {} + coordinator.end_turn(turn, outcome="success") + class TestCompactionRotation: def test_compaction_rotates_at_next_begin_turn_not_immediately( From 706f296d5abc6955c278882b824c79b659cf077e Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Wed, 23 Sep 2026 07:50:12 -0400 Subject: [PATCH 05/61] test(relay): cover cwd lifecycle handoffs Signed-off-by: Alex Fournier --- tests/agent/test_cross_process_turn_lease.py | 6 ++++++ tests/agent/test_relay_session_segments.py | 14 +------------- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/tests/agent/test_cross_process_turn_lease.py b/tests/agent/test_cross_process_turn_lease.py index 2c306f0d34..017c5c9025 100644 --- a/tests/agent/test_cross_process_turn_lease.py +++ b/tests/agent/test_cross_process_turn_lease.py @@ -82,6 +82,10 @@ def test_run_conversation_acquires_then_reloads_latest_tip(monkeypatch): observed["session_id"] = _agent.session_id return {"final_response": "ok", "messages": history, "failed": False} + def resolve_relay_cwds(_agent, _task_id, session_id, _platform): + observed["relay_cwd_session_id"] = session_id + return "", "" + # Simulate a contended wait so the resume status path is covered. def acquire_with_wait(session_id, holder, **kwargs): db.events.append(("acquire", session_id, holder)) @@ -93,6 +97,7 @@ def test_run_conversation_acquires_then_reloads_latest_tip(monkeypatch): db.acquire_session_turn_lease = acquire_with_wait monkeypatch.setattr("agent.conversation_loop.run_conversation", fake_run) + monkeypatch.setattr("agent.relay_cwd.resolve_relay_scope_cwds", resolve_relay_cwds) result = AIAgent.run_conversation( agent, "new message", @@ -103,6 +108,7 @@ def test_run_conversation_acquires_then_reloads_latest_tip(monkeypatch): assert observed == { "history": [{"role": "user", "content": "durable latest"}], "session_id": "compressed-tip", + "relay_cwd_session_id": "compressed-tip", } assert [event[0] for event in db.events] == [ "acquire", diff --git a/tests/agent/test_relay_session_segments.py b/tests/agent/test_relay_session_segments.py index 72feeb06f2..901e23346e 100644 --- a/tests/agent/test_relay_session_segments.py +++ b/tests/agent/test_relay_session_segments.py @@ -297,21 +297,9 @@ class TestCwdProjection: assert fake.scope.pushes[-1]["input"] == {"cwd": "/workspace/next-task"} coordinator.end_turn(turn, outcome="success") - def test_explicit_unknown_cwd_clears_prior_scope_input(self, coordinator): - fake = _FakeRelay() - runtime = _make_runtime(fake) - lease = _acquire( - coordinator, runtime, - session_cwd="/workspace/session", turn_cwd="/workspace/task", - ) - coordinator.end_turn( - coordinator.begin_turn(lease, turn_id="t1", task_id="task1"), - outcome="success", - ) - lease = _acquire(coordinator, runtime, session_cwd="", turn_cwd="") runtime.rotate_session_scope(lease.session, reason="compaction") - turn = coordinator.begin_turn(lease, turn_id="t2", task_id="task2") + turn = coordinator.begin_turn(lease, turn_id="t3", task_id="task3") assert _session_pushes(fake)[-1]["input"] == {} assert fake.scope.pushes[-1]["input"] == {} From 122ffd3f398cc6aae33604d8cc5d8b7b3ae2921c Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Wed, 23 Sep 2026 21:21:52 -0500 Subject: [PATCH 06/61] =?UTF-8?q?fix(desktop):=20=E2=8C=98K=20session=20se?= =?UTF-8?q?arch=20reflects=20renames=20and=20archives=20immediately?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The palette fetched its own 200-row page through react-query with the client's 60s staleTime, and nothing invalidated it: a rename, pin, or archive lands in the sidebar's session stores while the palette is unmounted, so ⌘K kept showing the old title (or an archived row) for up to a minute, and painted it stale-first even after. Revalidate on every open (staleTime 0; the cached page still paints instantly) and overlay the sidebar's live stores on the fetched page by id, so title and pin come from what the sidebar already knows and rows the user just archived or deleted are dropped. --- .../desktop/src/app/command-palette/index.tsx | 59 +++++++++++++------ 1 file changed, 42 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index 98a3cf7f39..3bed6c48bf 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -74,7 +74,8 @@ import { $dismissedAutoProjectIds, filterVisibleProjects } from '@/store/layout' import { openPetGenerate } from '@/store/pet-generate' import { openBrowserTab } from '@/store/preview' import { $projectTree, goToProject, openFolderAsProject, requestStartWorkSession } from '@/store/projects' -import { $connection } from '@/store/session' +import { $connection, $cronSessions, $messagingSessions, $sessions } from '@/store/session' +import { $removedSessionIds } from '@/store/session-removal' import { runGatewayRestart } from '@/store/system-actions' import { $backendUpdateApply, @@ -393,6 +394,11 @@ const toSessionEntry = (session: SessionRow): SessionEntry => ({ title: sessionTitle(session) }) +// Search terms beyond the label: the preview and branch, so a session is +// findable by what it's about, not only what it's called. +const sessionKeywords = (session: SessionEntry, ...tags: string[]): string[] => + [...tags, 'chat', 'session', session.preview, session.git_branch].filter((word): word is string => !!word) + type NonConfigSettingsLabel = 'about' | 'archivedChats' | 'gateway' | 'keysSettings' | 'keysTools' | 'mcp' | 'providerAccounts' | 'providerApiKeys' @@ -637,21 +643,51 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) { queryFn: () => getHermesConfigRecord() }) + // staleTime 0 (not the 60s client default): renames, pins, and archives + // happen in the sidebar while this component is unmounted, so nothing can + // invalidate these keys — every open must revalidate. The cached page + // still paints instantly; the live-store overlay below covers the gap. const sessionsQuery = useQuery({ queryKey: ['command-palette', 'sessions'], - queryFn: () => listAllProfileSessions(200, 1, 'exclude') + queryFn: () => listAllProfileSessions(200, 1, 'exclude'), + staleTime: 0 }) const archivedQuery = useQuery({ queryKey: ['command-palette', 'archived'], - queryFn: () => listAllProfileSessions(200, 0, 'only') + queryFn: () => listAllProfileSessions(200, 0, 'only'), + staleTime: 0 }) + const liveSessions = useStore($sessions) + const liveCronSessions = useStore($cronSessions) + const liveMessagingSessions = useStore($messagingSessions) + const removedSessionIds = useStore($removedSessionIds) + // getServers is the shared choke point that also drops malformed (null/ // scalar) entries, so the palette never lists a server the MCP tab dropped. const mcpServers = useMemo(() => Object.keys(getServers(configQuery.data ?? null)).sort(), [configQuery.data]) - const sessions = useMemo(() => (sessionsQuery.data?.sessions ?? []).map(toSessionEntry), [sessionsQuery.data]) + // The sidebar's stores are where a rename / pin / archive lands first (the + // server page confirms later). Overlay them on the fetched 200-row page so + // the palette says what the sidebar says: same title, same pin, and no row + // the user just archived or deleted. + const liveRows = useMemo(() => { + const byId = new Map( + [...liveCronSessions, ...liveMessagingSessions, ...liveSessions].map(row => [row.id, row] as const) + ) + + return (sessionsQuery.data?.sessions ?? []) + .filter(session => !removedSessionIds.has(session.id)) + .map(session => { + const live = byId.get(session.id) + + return live ? { ...session, pinned: live.pinned, title: live.title } : session + }) + }, [liveCronSessions, liveMessagingSessions, liveSessions, removedSessionIds, sessionsQuery.data]) + + const sessions = useMemo(() => liveRows.map(toSessionEntry), [liveRows]) + const archivedSessions = useMemo(() => (archivedQuery.data?.sessions ?? []).map(toSessionEntry), [archivedQuery.data]) // Search/sub-page are local to a mount, and this component remounts per open @@ -1168,12 +1204,7 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) { items: sessions.map(session => ({ icon: MessageCircle, id: `session-${session.id}`, - keywords: [ - 'chat', - 'session', - ...(session.preview ? [session.preview] : []), - ...(session.git_branch ? [session.git_branch] : []) - ], + keywords: sessionKeywords(session), label: session.title, runWithEvent: goSession(session.id) })) @@ -1230,13 +1261,7 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) { items: archivedSessions.map(session => ({ icon: Archive, id: `archived-${session.id}`, - keywords: [ - 'archived', - 'chat', - 'session', - ...(session.preview ? [session.preview] : []), - ...(session.git_branch ? [session.git_branch] : []) - ], + keywords: sessionKeywords(session, 'archived'), label: session.title, run: go(`${SETTINGS_ROUTE}?tab=sessions&session=${encodeURIComponent(session.id)}`) })) From 3fd49606fa194e11979ae60bacd5e33d7b9d055a Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Wed, 23 Sep 2026 21:21:52 -0500 Subject: [PATCH 07/61] =?UTF-8?q?feat(desktop):=20=E2=8C=98K=20groups=20pi?= =?UTF-8?q?nned=20sessions=20first?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pinned sessions were mixed into Sessions with no group, icon, or ranking advantage. Resolve them the way the sidebar's Pinned section does (resolvePinnedSessions: local pin order, then server-flagged pins, minus in-flight unpins) and list them under a Pinned heading ahead of Sessions; rankGroups' stable sort then lets a pin win a tie on equal scores. --- .../desktop/src/app/command-palette/index.tsx | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index 3bed6c48bf..695b1c8975 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -46,6 +46,7 @@ import { Package, Palette, PawPrint, + Pin, Plus, RefreshCw, Settings, @@ -70,11 +71,12 @@ import { setCommandPaletteOpen } from '@/store/command-palette' import { $bindings, bindingsFor } from '@/store/keybinds' -import { $dismissedAutoProjectIds, filterVisibleProjects } from '@/store/layout' +import { $dismissedAutoProjectIds, $pinnedSessionIds, filterVisibleProjects } from '@/store/layout' import { openPetGenerate } from '@/store/pet-generate' import { openBrowserTab } from '@/store/preview' import { $projectTree, goToProject, openFolderAsProject, requestStartWorkSession } from '@/store/projects' import { $connection, $cronSessions, $messagingSessions, $sessions } from '@/store/session' +import { $unconfirmedPinWrites } from '@/store/session-pin-sync' import { $removedSessionIds } from '@/store/session-removal' import { runGatewayRestart } from '@/store/system-actions' import { @@ -90,6 +92,7 @@ import { luminance } from '@/themes/color' import { type ThemeMode, useTheme } from '@/themes/context' import { isUserTheme, resolveTheme } from '@/themes/user-themes' +import { buildSessionByAnyId, resolvePinnedSessions } from '../chat/sidebar/session-index' import { openSessionFromPicker, openSessionIntentFromModifiers } from '../open-session' import { AGENTS_ROUTE, @@ -662,6 +665,8 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) { const liveSessions = useStore($sessions) const liveCronSessions = useStore($cronSessions) const liveMessagingSessions = useStore($messagingSessions) + const pinnedSessionIds = useStore($pinnedSessionIds) + const unconfirmedPinWrites = useStore($unconfirmedPinWrites) const removedSessionIds = useStore($removedSessionIds) // getServers is the shared choke point that also drops malformed (null/ @@ -686,7 +691,19 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) { }) }, [liveCronSessions, liveMessagingSessions, liveSessions, removedSessionIds, sessionsQuery.data]) - const sessions = useMemo(() => liveRows.map(toSessionEntry), [liveRows]) + // Same resolution as the sidebar's Pinned section: local pin order first, + // then server-flagged pins, minus our own in-flight unpins. + const pinnedSessions = useMemo(() => { + const byAnyId = buildSessionByAnyId(liveRows, [], []) + + return resolvePinnedSessions(pinnedSessionIds, byAnyId, liveRows, unconfirmedPinWrites).map(toSessionEntry) + }, [liveRows, pinnedSessionIds, unconfirmedPinWrites]) + + const sessions = useMemo(() => { + const pinned = new Set(pinnedSessions.map(session => session.id)) + + return liveRows.filter(session => !pinned.has(session.id)).map(toSessionEntry) + }, [liveRows, pinnedSessions]) const archivedSessions = useMemo(() => (archivedQuery.data?.sessions ?? []).map(toSessionEntry), [archivedQuery.data]) @@ -1198,6 +1215,21 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) { })) }) + // Pinned before Sessions: rankGroups' stable sort keeps source order on + // equal scores, so a pin wins a tie with an unpinned row of the same name. + if (pinnedSessions.length > 0) { + result.push({ + heading: t.sidebar.pinned, + items: pinnedSessions.map(session => ({ + icon: Pin, + id: `pinned-${session.id}`, + keywords: sessionKeywords(session, 'pinned'), + label: session.title, + runWithEvent: goSession(session.id) + })) + }) + } + if (sessions.length > 0) { result.push({ heading: t.commandCenter.sections.sessions, @@ -1276,6 +1308,7 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) { goSession, mcpServers, mode, + pinnedSessions, previewTheme, resolvedMode, resolveThemeMode, From 3cc24b01308f6a75865563541a15edfe2f1a9def Mon Sep 17 00:00:00 2001 From: KoNit-K Date: Thu, 17 Sep 2026 16:49:35 +0800 Subject: [PATCH 08/61] feat(desktop): add composer dictation keybind (cherry picked from commit bb6e188bb6fe6b59ac8f829b3461b2b50ef571e2) --- .../src/app/chat/composer/focus.test.ts | 18 +++++++++++++++++ apps/desktop/src/app/chat/composer/focus.ts | 9 +++++++++ .../chat/composer/hooks/use-composer-voice.ts | 10 +++++++++- apps/desktop/src/app/hooks/use-keybinds.ts | 3 ++- apps/desktop/src/i18n/ar.ts | 1 + apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/i18n/ru.ts | 1 + apps/desktop/src/i18n/zh.ts | 1 + apps/desktop/src/lib/keybinds/actions.test.ts | 20 ++++++++++++++++++- apps/desktop/src/lib/keybinds/actions.ts | 3 +++ apps/desktop/src/lib/keybinds/combo.test.ts | 3 +++ apps/desktop/src/lib/keybinds/combo.ts | 1 + 12 files changed, 68 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/focus.test.ts b/apps/desktop/src/app/chat/composer/focus.test.ts index 2b3cb782e1..dbb7292ba8 100644 --- a/apps/desktop/src/app/chat/composer/focus.test.ts +++ b/apps/desktop/src/app/chat/composer/focus.test.ts @@ -7,9 +7,11 @@ import { focusComposerInput, getActiveComposer, markActiveComposer, + onComposerDictationRequest, onComposerFocusRequest, onComposerModelMenuRequest, releaseActiveComposer, + requestComposerDictation, requestComposerFocus, requestModelMenuToggle } from './focus' @@ -317,3 +319,19 @@ describe('requestModelMenuToggle', () => { expect(await collectModelMenuTargets()).toEqual([]) }) }) + +describe('requestComposerDictation', () => { + it('delivers the request only to the active visible composer', async () => { + mountSurface('main', true) + mountSurface('tile:front') + markActiveComposer('tile:front') + const targets: string[] = [] + const off = onComposerDictationRequest(target => targets.push(target)) + + requestComposerDictation('active') + await new Promise(resolve => window.setTimeout(resolve, 0)) + off() + + expect(targets).toEqual(['tile:front']) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/focus.ts b/apps/desktop/src/app/chat/composer/focus.ts index b9d6caf863..82b6374820 100644 --- a/apps/desktop/src/app/chat/composer/focus.ts +++ b/apps/desktop/src/app/chat/composer/focus.ts @@ -50,6 +50,7 @@ const ATTACH_IMAGES_EVENT = 'hermes:composer-attach-images' const INSERT_REFS_EVENT = 'hermes:composer-insert-refs' const SUBMIT_EVENT = 'hermes:composer-submit' const VOICE_TOGGLE_EVENT = 'hermes:composer-voice-toggle' +const DICTATION_EVENT = 'hermes:composer-dictation' const MODEL_MENU_EVENT = 'hermes:composer-model-menu' /** Inline edit composer root — mounted only while a user bubble is being edited. */ @@ -366,6 +367,14 @@ export const requestVoiceToggle = (target: ComposerTarget | 'active' = 'active') export const onComposerVoiceToggleRequest = (handler: (target: ComposerTarget) => void) => subscribe<{ target: ComposerTarget }>(VOICE_TOGGLE_EVENT, ({ target }) => handler(target)) +/** Start or stop dictation on one composer. Like voice conversation, the + * rebindable action targets only the active visible composer. */ +export const requestComposerDictation = (target: ComposerTarget | 'active' = 'active') => + dispatch<{ target: ComposerTarget }>(DICTATION_EVENT, { target: resolve(target) }) + +export const onComposerDictationRequest = (handler: (target: ComposerTarget) => void) => + subscribe<{ target: ComposerTarget }>(DICTATION_EVENT, ({ target }) => handler(target)) + /** The chat surface inside the zone the pointer is over, if any. Mirrors the * tab verbs' hover-first targeting (`tabTargetGroupId`, #74447): the model * hotkey lands in the pane you're pointing at without clicking into it first. diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts index a46513802c..a411dbcc62 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts @@ -19,7 +19,7 @@ import { resumeWakeAfterVoice } from '@/store/wake-word' import { pinFloatingComposerCapture } from '../floating-target' import type { ComposerTarget } from '../focus' -import { onComposerVoiceToggleRequest } from '../focus' +import { onComposerDictationRequest, onComposerVoiceToggleRequest } from '../focus' import { useComposerScope, useComposerSurfaceId } from '../scope' import type { ChatBarProps } from '../types' @@ -313,6 +313,14 @@ export function useComposerVoice({ [target, toggleVoiceConversation] ) + // The bindable `composer.dictate` action shares the mic button's callback, + // including its recording/transcribing state machine. Ignore disabled + // composers so an unavailable draft cannot acquire the microphone. + useEffect( + () => onComposerDictationRequest(requested => requested === target && !disabled && dictate()), + [dictate, disabled, target] + ) + useEffect(() => { if (target === 'main' && !disabled && takeVoiceConversationStart(voiceStartRequest) && !voiceConversationActive) { activateConversation() diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts index 347f6ebe9e..9efd2d8758 100644 --- a/apps/desktop/src/app/hooks/use-keybinds.ts +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -69,7 +69,7 @@ import { toggleStatusbarVisible } from '@/store/statusbar-prefs' import { openNewWindow } from '@/store/windows' import { useTheme } from '@/themes/context' -import { requestComposerFocus, requestModelMenuToggle, requestVoiceToggle } from '../chat/composer/focus' +import { requestComposerDictation, requestComposerFocus, requestModelMenuToggle, requestVoiceToggle } from '../chat/composer/focus' import { handleComposerFocusChord } from '../chat/composer/focus-chord' import { handleWindowPaste } from '../chat/composer/paste-to-focus' import { openSession } from '../open-session' @@ -197,6 +197,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { } }, 'composer.voice': requestVoiceToggle, + 'composer.dictate': requestComposerDictation, // On the Settings overlay, ⌘K scopes to settings search; the second press // (or Esc) still closes as usual via toggle. diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index fb1b022ab7..0e3e7ad456 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -311,6 +311,7 @@ export const ar = defineLocale({ 'composer.focus': 'التركيز على المحرّر', 'composer.modelPicker': 'فتح منتقي النموذج', 'composer.voice': 'بدء / إيقاف المحادثة الصوتية', + 'composer.dictate': 'بدء / إيقاف الإملاء', 'view.toggleSidebar': 'تبديل الشريط الجانبي للجلسات', 'view.toggleRightSidebar': 'تبديل متصفح الملفات', 'view.toggleReview': 'تبديل لوحة المراجعة', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 4a0b524887..6b1ed6af50 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -661,6 +661,7 @@ export const en: Translations = { 'composer.focus': 'Focus composer', 'composer.modelPicker': 'Open model picker', 'composer.voice': 'Start / stop voice conversation', + 'composer.dictate': 'Start / stop dictation', 'view.toggleSidebar': 'Toggle sessions sidebar', 'view.cycleSidebarGrouping': 'Cycle session grouping', 'view.toggleRightSidebar': 'Toggle file browser', diff --git a/apps/desktop/src/i18n/ru.ts b/apps/desktop/src/i18n/ru.ts index 1d02c77451..e3f6dbcc0d 100644 --- a/apps/desktop/src/i18n/ru.ts +++ b/apps/desktop/src/i18n/ru.ts @@ -333,6 +333,7 @@ export const ru = defineLocale({ 'composer.focus': 'Сфокусировать композер', 'composer.modelPicker': 'Открыть выбор модели', 'composer.voice': 'Начать / остановить голосовой диалог', + 'composer.dictate': 'Начать / остановить диктовку', 'view.toggleSidebar': 'Показать / скрыть панель сеансов', 'view.toggleRightSidebar': 'Показать / скрыть браузер файлов', 'view.toggleReview': 'Показать / скрыть панель ревью', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 41eb6100dc..b58b0afd83 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -379,6 +379,7 @@ export const zh = defineLocale({ 'composer.focus': '聚焦输入框', 'composer.modelPicker': '打开模型选择器', 'composer.voice': '开始 / 停止语音对话', + 'composer.dictate': '开始 / 停止听写', 'view.toggleSidebar': '切换会话侧边栏', 'view.toggleRightSidebar': '切换文件浏览器', 'view.toggleReview': '切换审查面板', diff --git a/apps/desktop/src/lib/keybinds/actions.test.ts b/apps/desktop/src/lib/keybinds/actions.test.ts index 25acc27356..b3ad202d61 100644 --- a/apps/desktop/src/lib/keybinds/actions.test.ts +++ b/apps/desktop/src/lib/keybinds/actions.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { en } from '@/i18n/en' -import { KEYBIND_ACTIONS } from './actions' +import { defaultBindings, KEYBIND_ACTIONS, keybindAction } from './actions' // Relationship checks between the action table and its consumers, not the // specific chord or wording any one action ships with. @@ -19,4 +19,22 @@ describe('KEYBIND_ACTIONS', () => { expect(missing).toEqual([]) }) + + it('keeps session archive registered and unbound by default', () => { + const action = keybindAction('session.archive') + + expect(action).toMatchObject({ category: 'session', defaults: [] }) + expect(defaultBindings()['session.archive']).toEqual([]) + expect(en.keybinds.actions['session.archive']).toBe('Archive current session') + expect(KEYBIND_ACTIONS.filter(candidate => candidate.id === 'session.archive')).toHaveLength(1) + }) + + it('registers dictation with an English label and no default chord', () => { + const action = keybindAction('composer.dictate') + + expect(action).toMatchObject({ category: 'composer', defaults: [] }) + expect(defaultBindings()['composer.dictate']).toEqual([]) + expect(en.keybinds.actions['composer.dictate']).toBe('Start / stop dictation') + expect(KEYBIND_ACTIONS.filter(candidate => candidate.id === 'composer.dictate')).toHaveLength(1) + }) }) diff --git a/apps/desktop/src/lib/keybinds/actions.ts b/apps/desktop/src/lib/keybinds/actions.ts index be9efa3c3a..cdcd62dc66 100644 --- a/apps/desktop/src/lib/keybinds/actions.ts +++ b/apps/desktop/src/lib/keybinds/actions.ts @@ -67,6 +67,9 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [ // chord, so ship it unbound there (rebindable in the panel) rather than // stealing the long-standing sidebar binding. { id: 'composer.voice', category: 'composer', defaults: IS_MAC ? ['ctrl+b'] : [] }, + // Dictation is intentionally unbound: it is available for users who prefer + // a keyboard trigger without claiming a chord from text entry by default. + { id: 'composer.dictate', category: 'composer', defaults: [] }, // ── Profiles ───────────────────────────────────────────────────────────── { id: 'profile.default', category: 'profiles', defaults: ['mod+d'] }, diff --git a/apps/desktop/src/lib/keybinds/combo.test.ts b/apps/desktop/src/lib/keybinds/combo.test.ts index cbc9592c74..4a8a035f3d 100644 --- a/apps/desktop/src/lib/keybinds/combo.test.ts +++ b/apps/desktop/src/lib/keybinds/combo.test.ts @@ -92,6 +92,9 @@ describe('actionAllowedInInput', () => { // (or the pre-#76185 'shift+n') must not fire while the user types N. expect(actionAllowedInInput('session.new', 'n')).toBe(false) expect(actionAllowedInInput('session.new', 'shift+n')).toBe(false) + // Dictation is intentionally bindable without a shipped chord. A user who + // assigns a bare/Shift chord expects it to remain reachable from the draft. + expect(actionAllowedInInput('composer.dictate', 'shift+d')).toBe(true) }) it('leaves text navigation chords with the focused input even when rebound to an allowed action', () => { diff --git a/apps/desktop/src/lib/keybinds/combo.ts b/apps/desktop/src/lib/keybinds/combo.ts index b844de725f..e63da9a74a 100644 --- a/apps/desktop/src/lib/keybinds/combo.ts +++ b/apps/desktop/src/lib/keybinds/combo.ts @@ -258,6 +258,7 @@ export function isEditableTarget(target: EventTarget | null): boolean { const INPUT_SAFE_ACTIONS = new Set([ 'composer.modelPicker', 'composer.voice', + 'composer.dictate', 'keybinds.openPanel', 'nav.commandPalette', 'session.next', From 352b89d466a5af8f24427087b42c369b6b67fd18 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Wed, 23 Sep 2026 21:22:52 -0500 Subject: [PATCH 09/61] feat(desktop): add function-key voice shortcuts --- .../electron/desktop-profile-preload.test.ts | 13 ++ apps/desktop/electron/f12-shortcut.test.ts | 32 ++++ apps/desktop/electron/f12-shortcut.ts | 40 ++++ apps/desktop/electron/main.ts | 33 +++- apps/desktop/electron/preload.ts | 7 + .../use-composer-voice-shortcuts.test.tsx | 174 ++++++++++++++++++ apps/desktop/src/app/hooks/use-keybinds.ts | 44 ++++- .../src/app/settings/config-settings.tsx | 3 + .../src/app/settings/config-subpages.ts | 2 +- apps/desktop/src/app/settings/constants.ts | 2 - .../app/settings/voice-field-visible.test.ts | 2 +- apps/desktop/src/global.d.ts | 12 ++ apps/desktop/src/i18n/ar.ts | 6 +- apps/desktop/src/i18n/de.ts | 1 - apps/desktop/src/i18n/en.ts | 3 + apps/desktop/src/i18n/es.ts | 1 - apps/desktop/src/i18n/fr.ts | 1 - apps/desktop/src/i18n/ja.ts | 6 +- apps/desktop/src/i18n/ru.ts | 6 +- apps/desktop/src/i18n/types.ts | 2 + apps/desktop/src/i18n/zh-hant.ts | 4 +- apps/desktop/src/i18n/zh.ts | 4 +- apps/desktop/src/lib/keybinds/actions.ts | 8 +- apps/desktop/src/lib/keybinds/combo.test.ts | 12 ++ apps/desktop/src/lib/keybinds/combo.ts | 4 + 25 files changed, 399 insertions(+), 23 deletions(-) create mode 100644 apps/desktop/electron/f12-shortcut.test.ts create mode 100644 apps/desktop/electron/f12-shortcut.ts create mode 100644 apps/desktop/src/app/chat/composer/hooks/use-composer-voice-shortcuts.test.tsx diff --git a/apps/desktop/electron/desktop-profile-preload.test.ts b/apps/desktop/electron/desktop-profile-preload.test.ts index d8e07a0a79..954f8cdd48 100644 --- a/apps/desktop/electron/desktop-profile-preload.test.ts +++ b/apps/desktop/electron/desktop-profile-preload.test.ts @@ -8,6 +8,7 @@ const electron = vi.hoisted(() => ({ invoke: vi.fn(async () => ({ ok: true })), on: vi.fn(), removeListener: vi.fn(), + send: vi.fn(), sendSync: vi.fn(() => ({})) }, webFrame: {}, @@ -30,6 +31,18 @@ test('the native preload exposes routed peer opening and default preference even assert.deepEqual(electron.ipcRenderer.invoke.mock.lastCall, ['hermes:profile:default:set', route]) await bridge.profile.getDefault() assert.deepEqual(electron.ipcRenderer.invoke.mock.lastCall, ['hermes:profile:default:get']) + bridge.setF12ShortcutActive(true) + assert.equal(electron.ipcRenderer.send.mock.lastCall?.[0], 'hermes:f12ShortcutActive') + assert.equal(electron.ipcRenderer.send.mock.lastCall?.[1], true) + + const shortcutInputs: unknown[] = [] + const stopShortcut = bridge.onF12Shortcut((input: unknown) => shortcutInputs.push(input)) + const [shortcutChannel, shortcutListener] = electron.ipcRenderer.on.mock.lastCall! + assert.equal(shortcutChannel, 'hermes:f12-shortcut') + shortcutListener({}, { key: 'F12', repeat: true }) + assert.deepEqual(shortcutInputs, [{ key: 'F12', repeat: true }]) + stopShortcut() + assert.deepEqual(electron.ipcRenderer.removeListener.mock.lastCall, [shortcutChannel, shortcutListener]) const changes: unknown[] = [] const unsubscribe = bridge.profile.onDefaultChanged((value: unknown) => changes.push(value)) diff --git a/apps/desktop/electron/f12-shortcut.test.ts b/apps/desktop/electron/f12-shortcut.test.ts new file mode 100644 index 0000000000..5efe4b7388 --- /dev/null +++ b/apps/desktop/electron/f12-shortcut.test.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { f12ShortcutDecision, toF12KeyboardEventPayload } from './f12-shortcut' + +test('arbitrates only native F12 keydown and preserves repeat/modifiers for forwarding', () => { + const input = { + alt: true, + code: 'F12', + control: false, + isAutoRepeat: true, + key: 'F12', + meta: true, + shift: false, + type: 'keyDown' + } + + assert.equal(f12ShortcutDecision(input, true, false), 'forward') + assert.equal(f12ShortcutDecision({ ...input, type: 'keyUp' }, true, false), 'ignore') + assert.equal(f12ShortcutDecision(input, false, true), 'block') + assert.equal(f12ShortcutDecision(input, false, false), 'devtools') + assert.deepEqual(toF12KeyboardEventPayload(input), { + alt: true, + code: 'F12', + control: false, + key: 'F12', + meta: true, + repeat: true, + shift: false + }) +}) diff --git a/apps/desktop/electron/f12-shortcut.ts b/apps/desktop/electron/f12-shortcut.ts new file mode 100644 index 0000000000..b976df0a17 --- /dev/null +++ b/apps/desktop/electron/f12-shortcut.ts @@ -0,0 +1,40 @@ +export interface F12Input { + alt?: boolean + code?: string + control?: boolean + isAutoRepeat?: boolean + key: string + meta?: boolean + shift?: boolean + type: string +} + +export type F12ShortcutDecision = 'block' | 'devtools' | 'forward' | 'ignore' + +export function f12ShortcutDecision(input: F12Input, shortcutActive: boolean, disabled: boolean): F12ShortcutDecision { + if (input.type !== 'keyDown') { + return 'ignore' + } + + if (shortcutActive) { + return 'forward' + } + + if (disabled) { + return 'block' + } + + return 'devtools' +} + +export function toF12KeyboardEventPayload(input: F12Input) { + return { + alt: Boolean(input.alt), + code: input.code, + control: Boolean(input.control), + key: input.key, + meta: Boolean(input.meta), + repeat: Boolean(input.isAutoRepeat), + shift: Boolean(input.shift) + } +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index b6b23d8d4c..cc4c5ceb3a 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -203,6 +203,7 @@ import { terminalScriptExtension, tuiResumeArgs } from './external-terminal' +import { f12ShortcutDecision, toF12KeyboardEventPayload } from './f12-shortcut' import { type FaviconIo, resolveFavicon } from './favicon' import { findGitBash as _findGitBash } from './find-git-bash' import { @@ -1816,6 +1817,7 @@ const remoteHeaderSessions = new WeakSet() const remoteWsHeaderStore = createRemoteWsHeaderStore() const previewWatchers = new Map() let previewShortcutActive = false +const f12ShortcutActiveWindows = new Set() let nativeThemeListenerInstalled = false let bootProgressState = { @@ -7415,11 +7417,26 @@ function installDevToolsShortcut(window) { // Only Ctrl+Shift+I (or Cmd+Opt+I on Mac) opens DevTools. // F12 is explicitly blocked so Chromium's built-in handler doesn't open it. window.webContents.on('before-input-event', (event, input) => { + if (input.type !== 'keyDown') { + return + } + const key = input.key.toLowerCase() - // F12 opens DevTools by default; block only when the user disabled it. + // A renderer binding gets first refusal. Chromium otherwise claims F12 + // before the renderer can capture or dispatch it. if (input.key === 'F12') { - if (f12Blocked) { + const decision = f12ShortcutDecision(input, f12ShortcutActiveWindows.has(window.webContents.id), f12Blocked) + + if (decision === 'forward') { + event.preventDefault() + + window.webContents.send('hermes:f12-shortcut', toF12KeyboardEventPayload(input)) + + return + } + + if (decision === 'block') { event.preventDefault() return @@ -16825,6 +16842,18 @@ ipcMain.on('hermes:previewShortcutActive', (_event, active) => { previewShortcutActive = Boolean(active) }) +ipcMain.on('hermes:f12ShortcutActive', (event, active) => { + if (active) { + f12ShortcutActiveWindows.add(event.sender.id) + } else { + f12ShortcutActiveWindows.delete(event.sender.id) + } +}) + +app.on('web-contents-created', (_event, contents) => { + contents.once('destroyed', () => f12ShortcutActiveWindows.delete(contents.id)) +}) + ipcMain.handle('hermes:requestMicrophoneAccess', async () => { if (!IS_MAC || typeof systemPreferences.askForMediaAccess !== 'function') { return true diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 5adc951aea..f6f772acf4 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -380,6 +380,13 @@ contextBridge.exposeInMainWorld('hermesDesktop', { } }, setDisableF12: blocked => ipcRenderer.send('hermes:devtools:disable-f12', blocked), + setF12ShortcutActive: active => ipcRenderer.send('hermes:f12ShortcutActive', Boolean(active)), + onF12Shortcut: callback => { + const listener = (_event, input) => callback(input) + ipcRenderer.on('hermes:f12-shortcut', listener) + + return () => ipcRenderer.removeListener('hermes:f12-shortcut', listener) + }, setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)), openExternal: url => ipcRenderer.invoke('hermes:openExternal', url), mcpOauth: { diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice-shortcuts.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice-shortcuts.test.tsx new file mode 100644 index 0000000000..7d80e2211d --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice-shortcuts.test.tsx @@ -0,0 +1,174 @@ +import { act, cleanup, render } from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { markActiveComposer, requestComposerDictation, requestVoiceToggle } from '../focus' +import { ComposerScopeProvider, ComposerSurfaceProvider, MAIN_COMPOSER_SCOPE } from '../scope' + +import { useComposerVoice } from './use-composer-voice' + +const mocks = vi.hoisted(() => ({ + conversationEnabled: [] as boolean[], + dictate: vi.fn(), + endConversation: vi.fn(async () => undefined) +})) + +vi.mock('./use-voice-recorder', () => ({ + useVoiceRecorder: () => ({ + dictate: mocks.dictate, + voiceActivityState: { elapsedSeconds: 0, level: 0, status: 'idle' }, + voiceStatus: 'idle' + }) +})) + +vi.mock('./use-voice-conversation', () => ({ + useVoiceConversation: ({ enabled }: { enabled: boolean }) => { + mocks.conversationEnabled.push(enabled) + + return { end: mocks.endConversation, start: vi.fn(), status: 'idle' } + } +})) + +vi.mock('./use-voice-live-conversation', () => ({ + useVoiceLiveConversation: () => ({ end: mocks.endConversation, start: vi.fn(), status: 'idle' }) +})) + +vi.mock('./use-auto-speak-replies', () => ({ useAutoSpeakReplies: vi.fn() })) + +vi.mock('@/i18n', () => ({ + useI18n: () => ({ + t: { + notifications: { voice: {} }, + assistant: { thread: { readAloudFailed: '' } }, + settings: { config: { autosaveFailed: '' } } + } + }) +})) + +vi.mock('@/lib/haptics', () => ({ triggerHaptic: vi.fn() })) +vi.mock('@/lib/spoken-reply', () => ({ + adoptSpokenReplySession: vi.fn(), + markAssistantIdSpoken: vi.fn(), + resolveSpokenReply: vi.fn(() => null) +})) +vi.mock('@/lib/tts-lease', () => ({ + CONVERSATION_LEASE: 'conversation', + READ_ALOUD_LEASE: 'read-aloud', + syncTtsLease: vi.fn(async () => undefined) +})) +vi.mock('@/lib/wake-indicator', () => ({ clearWakeIndicator: vi.fn(), syncWakeIndicatorWithVoice: vi.fn() })) +vi.mock('@/lib/voice-live', () => ({ toLiveHistory: vi.fn(() => []) })) +vi.mock('@/store/notifications', () => ({ notify: vi.fn(), notifyError: vi.fn() })) +vi.mock('@/store/voice-live', async () => { + const { atom } = await import('nanostores') + + return { + $voiceLiveStatus: atom(null), + refreshVoiceLiveStatus: vi.fn(async () => undefined), + selectedVoiceChatMode: vi.fn(() => 'chained') + } +}) +vi.mock('@/store/voice-prefs', async () => { + const { atom } = await import('nanostores') + + return { + $autoSpeakReplies: atom(false), + $voiceStopPhrase: atom(null), + setAutoSpeakReplies: vi.fn(async () => undefined) + } +}) +vi.mock('@/store/gateway', async () => { + const { atom } = await import('nanostores') + + return { $gateway: atom(null) } +}) +vi.mock('@/store/composer-input-history', () => ({ resetBrowseState: vi.fn() })) +vi.mock('@/store/wake-word', () => ({ resumeWakeAfterVoice: vi.fn(async () => undefined) })) +vi.mock('../floating-target', () => ({ pinFloatingComposerCapture: vi.fn(() => undefined) })) + +function Composer({ disabled, target }: { disabled: boolean; target: string }) { + useComposerVoice({ + busy: false, + clearDraft: vi.fn(), + disabled, + focusInput: vi.fn(), + insertText: vi.fn(), + maxRecordingSeconds: 60, + onSubmit: vi.fn(async () => true), + onTranscribeAudio: vi.fn(async () => 'spoken text'), + sessionId: null, + target + }) + + return null +} + +function mountComposer(target: string, disabled: boolean, hidden = false) { + const scope = { ...MAIN_COMPOSER_SCOPE, target } + + return ( + + +
+ +
+
+
+ ) +} + +function renderComposers(children: ReactNode) { + return render(children) +} + +afterEach(() => { + cleanup() + document.body.innerHTML = '' + mocks.dictate.mockClear() + mocks.endConversation.mockClear() + mocks.conversationEnabled.length = 0 + markActiveComposer('main') +}) + +describe('composer voice shortcuts', () => { + it('dictates only on the active visible target and ignores a disabled target', async () => { + renderComposers( + <> + {mountComposer('main', true, true)} + {mountComposer('tile:front', false)} + + ) + markActiveComposer('tile:front') + + await act(async () => { + requestComposerDictation('active') + await new Promise(resolve => window.setTimeout(resolve, 0)) + }) + expect(mocks.dictate).toHaveBeenCalledTimes(1) + + await act(async () => { + requestComposerDictation('main') + await new Promise(resolve => window.setTimeout(resolve, 0)) + }) + expect(mocks.dictate).toHaveBeenCalledTimes(1) + }) + + it('forwards repeated dictation requests without toggling voice conversation', async () => { + renderComposers(mountComposer('main', false)) + + await act(async () => { + requestComposerDictation('active') + requestComposerDictation('active') + await new Promise(resolve => window.setTimeout(resolve, 0)) + }) + expect(mocks.dictate).toHaveBeenCalledTimes(2) + expect(mocks.endConversation).not.toHaveBeenCalled() + + await act(async () => { + requestVoiceToggle('active') + await new Promise(resolve => window.setTimeout(resolve, 0)) + }) + expect(mocks.dictate).toHaveBeenCalledTimes(2) + expect(mocks.conversationEnabled).toContain(true) + }) +}) diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts index 9efd2d8758..d28a663554 100644 --- a/apps/desktop/src/app/hooks/use-keybinds.ts +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -69,7 +69,12 @@ import { toggleStatusbarVisible } from '@/store/statusbar-prefs' import { openNewWindow } from '@/store/windows' import { useTheme } from '@/themes/context' -import { requestComposerDictation, requestComposerFocus, requestModelMenuToggle, requestVoiceToggle } from '../chat/composer/focus' +import { + requestComposerDictation, + requestComposerFocus, + requestModelMenuToggle, + requestVoiceToggle +} from '../chat/composer/focus' import { handleComposerFocusChord } from '../chat/composer/focus-chord' import { handleWindowPaste } from '../chat/composer/paste-to-focus' import { openSession } from '../open-session' @@ -326,6 +331,43 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { [] ) + useEffect(() => { + const updateF12Ownership = () => { + const hasF12Binding = [...$comboIndex.get().keys()].some(combo => combo === 'f12' || combo.endsWith('+f12')) + window.hermesDesktop?.setF12ShortcutActive?.(hasF12Binding || $capture.get() !== null) + } + + const stopBindings = $comboIndex.subscribe(updateF12Ownership) + const stopCapture = $capture.subscribe(updateF12Ownership) + + return () => { + stopBindings() + stopCapture() + window.hermesDesktop?.setF12ShortcutActive?.(false) + } + }, []) + + useEffect(() => { + const stopF12Shortcut = window.hermesDesktop?.onF12Shortcut?.(input => { + const target = document.activeElement ?? document.body ?? document.documentElement + target.dispatchEvent( + new KeyboardEvent('keydown', { + altKey: input.alt, + bubbles: true, + cancelable: true, + code: input.code, + ctrlKey: input.control, + key: input.key, + metaKey: input.meta, + repeat: input.repeat, + shiftKey: input.shift + }) + ) + }) + + return () => stopF12Shortcut?.() + }, []) + useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { // An active IME composition owns the keyboard. Windows Chinese IMEs diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx index acb7a5e371..defe752199 100644 --- a/apps/desktop/src/app/settings/config-settings.tsx +++ b/apps/desktop/src/app/settings/config-settings.tsx @@ -455,6 +455,9 @@ function ConfigSettingsInner({ where image-attachment behavior already lives, so this sits above the schema fields for that section. */} {showAttachments ? : null} + {activeSectionId === 'voice' ? ( + + ) : null} {showEmptyState ? ( ) : visibleFields.length === 0 ? null : ( diff --git a/apps/desktop/src/app/settings/config-subpages.ts b/apps/desktop/src/app/settings/config-subpages.ts index d3bc519aea..fe6f9d335c 100644 --- a/apps/desktop/src/app/settings/config-subpages.ts +++ b/apps/desktop/src/app/settings/config-subpages.ts @@ -72,7 +72,7 @@ const CONFIG_SUBPAGE_DEFINITIONS: Record = { { id: 'conversation', labelKey: 'voiceConversation', - fields: ['voice.voice_chat_mode', 'voice.record_key', 'voice.max_recording_seconds', 'voice.client_direct'], + fields: ['voice.voice_chat_mode', 'voice.max_recording_seconds', 'voice.client_direct'], prefixes: ['voice.gpt_live.'] }, { id: 'transcription', labelKey: 'voiceTranscription', prefixes: ['stt.'] }, diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts index 28930d893d..4dd955dddb 100644 --- a/apps/desktop/src/app/settings/constants.ts +++ b/apps/desktop/src/app/settings/constants.ts @@ -466,7 +466,6 @@ export const FIELD_LABELS: Record = defineFieldCopy({ maxSnapshots: 'Checkpoint Limit' }, voice: { - recordKey: 'Voice Shortcut', maxRecordingSeconds: 'Max Recording Length', autoTts: 'Read Responses Aloud', voiceChatMode: 'Voice Chat Mode', @@ -805,7 +804,6 @@ export const SECTIONS: DesktopConfigSection[] = [ 'stt.elevenlabs.language_code', 'stt.elevenlabs.tag_audio_events', 'stt.elevenlabs.diarize', - 'voice.record_key', 'voice.max_recording_seconds', 'voice.client_direct' ] diff --git a/apps/desktop/src/app/settings/voice-field-visible.test.ts b/apps/desktop/src/app/settings/voice-field-visible.test.ts index 9873da79fb..897fb72e89 100644 --- a/apps/desktop/src/app/settings/voice-field-visible.test.ts +++ b/apps/desktop/src/app/settings/voice-field-visible.test.ts @@ -15,7 +15,7 @@ describe('voiceFieldVisible', () => { it('always shows top-level + non-provider keys', () => { const config = cfg() - for (const key of ['tts.provider', 'stt.enabled', 'stt.provider', 'voice.auto_tts', 'voice.record_key']) { + for (const key of ['tts.provider', 'stt.enabled', 'stt.provider', 'voice.auto_tts']) { expect(voiceFieldVisible(key, config)).toBe(true) } }) diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index f0e1fa45a4..a10fe6e89b 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -386,6 +386,18 @@ declare global { onChanged: (callback: (status: { enabled: boolean; available: boolean }) => void) => () => void } setDisableF12?: (blocked: boolean) => void + setF12ShortcutActive?: (active: boolean) => void + onF12Shortcut?: ( + callback: (input: { + alt?: boolean + code?: string + control?: boolean + key: string + meta?: boolean + repeat?: boolean + shift?: boolean + }) => void + ) => () => void setPreviewShortcutActive?: (active: boolean) => void openExternal: (url: string) => Promise /** One-shot loopback callback listener for MCP OAuth against remote diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 0e3e7ad456..18e3eecad9 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -763,7 +763,6 @@ export const ar = defineLocale({ 'browser.autoLocalForPrivateUrls': 'متصفح محلي للروابط الخاصة', 'checkpoints.enabled': 'نقاط حفظ الملفات', 'checkpoints.maxSnapshots': 'حد نقاط الحفظ', - 'voice.recordKey': 'اختصار الصوت', 'voice.maxRecordingSeconds': 'أقصى مدة للتسجيل', 'voice.autoTts': 'قراءة الردود صوتياً', 'stt.enabled': 'تحويل الكلام إلى نص', @@ -909,7 +908,10 @@ export const ar = defineLocale({ failedLoad: 'فشل تحميل الإعدادات', autosaveFailed: 'فشل الحفظ التلقائي', imported: 'تم استيراد الإعدادات', - invalidJson: 'JSON غير صالح' + invalidJson: 'JSON غير صالح', + voiceShortcutHintTitle: 'اختصار تسجيل الصوت', + voiceShortcutHintDesc: + 'اضبط اختصار تسجيل الصوت من الإعدادات ← اختصارات لوحة المفاتيح («بدء / إيقاف الإملاء»). قيمة voice.record_key مخصصة لـ CLI وTUI فقط.' }, hudModifier: { title: 'استدعاء HUD بضغطة', diff --git a/apps/desktop/src/i18n/de.ts b/apps/desktop/src/i18n/de.ts index 537a81dee4..689d129cc6 100644 --- a/apps/desktop/src/i18n/de.ts +++ b/apps/desktop/src/i18n/de.ts @@ -1303,7 +1303,6 @@ export const de = defineLocale({ maxSnapshots: 'Checkpoint-Limit' }, voice: { - recordKey: 'Sprach-Tastenkürzel', maxRecordingSeconds: 'Maximale Aufnahmelänge', autoTts: 'Antworten vorlesen', voiceChatMode: 'Sprachchat-Modus', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 6b1ed6af50..59d5e97359 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1342,6 +1342,9 @@ export const en: Translations = { 'How big a local file Desktop will load for previews and image attach, in MB. Default is 16. Remote non-image attach uses a separate 256 MB cap. Setting this very high loads the whole file into memory and can freeze or crash the app.', attachmentSizeUnit: 'MB', attachmentSizeLabel: 'Max preview / image load size in megabytes', + voiceShortcutHintTitle: 'Voice recording shortcut', + voiceShortcutHintDesc: + 'Set the voice recording shortcut in Settings → Keyboard Shortcuts ("Start / stop dictation"). The voice.record_key config value only applies to the CLI and TUI.', showOptions: 'Show options' }, hudModifier: { diff --git a/apps/desktop/src/i18n/es.ts b/apps/desktop/src/i18n/es.ts index 29c8c17bf6..6859777cb8 100644 --- a/apps/desktop/src/i18n/es.ts +++ b/apps/desktop/src/i18n/es.ts @@ -1300,7 +1300,6 @@ export const es = defineLocale({ maxSnapshots: 'Límite de checkpoints' }, voice: { - recordKey: 'Atajo de voz', maxRecordingSeconds: 'Duración máxima de grabación', autoTts: 'Leer respuestas en voz alta', voiceChatMode: 'Modo de chat de voz', diff --git a/apps/desktop/src/i18n/fr.ts b/apps/desktop/src/i18n/fr.ts index 9304114b88..e536dc18c3 100644 --- a/apps/desktop/src/i18n/fr.ts +++ b/apps/desktop/src/i18n/fr.ts @@ -1304,7 +1304,6 @@ export const fr = defineLocale({ maxSnapshots: 'Limite de points de contrôle' }, voice: { - recordKey: 'Raccourci vocal', maxRecordingSeconds: "Durée maximale d'enregistrement", autoTts: 'Lire les réponses à haute voix', voiceChatMode: 'Mode de conversation vocale', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 43fe2f8096..0de1a486f1 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -794,7 +794,6 @@ export const ja = defineLocale({ maxSnapshots: 'チェックポイント上限' }, voice: { - recordKey: '音声ショートカット', maxRecordingSeconds: '最大録音時間', autoTts: '応答を読み上げる' }, @@ -1027,7 +1026,10 @@ export const ja = defineLocale({ imported: '設定をインポートしました', invalidJson: '設定 JSON が無効です', keepAwakeTitle: 'コンピューターをスリープさせない', - keepAwakeDesc: '本体のスリープを防ぎ、長時間や夜通しの実行を継続します。画面は暗転できます。' + keepAwakeDesc: '本体のスリープを防ぎ、長時間や夜通しの実行を継続します。画面は暗転できます。', + voiceShortcutHintTitle: '音声録音ショートカット', + voiceShortcutHintDesc: + '「設定 → キーボードショートカット」で音声録音ショートカット(「音声入力を開始 / 停止」)を設定します。voice.record_key は CLI と TUI 専用です。' }, hudModifier: { title: 'キーをタップして HUD を呼び出す', diff --git a/apps/desktop/src/i18n/ru.ts b/apps/desktop/src/i18n/ru.ts index e3f6dbcc0d..e94ede5072 100644 --- a/apps/desktop/src/i18n/ru.ts +++ b/apps/desktop/src/i18n/ru.ts @@ -821,7 +821,6 @@ export const ru = defineLocale({ maxSnapshots: 'Лимит чекпоинтов' }, voice: { - recordKey: 'Горячая клавиша голосового ввода', maxRecordingSeconds: 'Макс. длительность записи', autoTts: 'Зачитывать ответы вслух' }, @@ -1089,7 +1088,10 @@ export const ru = defineLocale({ attachmentSizeDesc: 'Насколько большой локальный файл приложение будет загружать для превью и вложений, в МБ. По умолчанию 16. Для удалённых неграфических вложений действует отдельный лимит 256 МБ. Слишком большое значение загружает весь файл в память и может подвесить или уронить приложение.', attachmentSizeUnit: 'МБ', - attachmentSizeLabel: 'Макс. размер превью / загрузки изображений в мегабайтах' + attachmentSizeLabel: 'Макс. размер превью / загрузки изображений в мегабайтах', + voiceShortcutHintTitle: 'Горячая клавиша записи голоса', + voiceShortcutHintDesc: + 'Настройте горячую клавишу записи голоса в разделе «Настройки → Горячие клавиши» («Начать / остановить диктовку»). Параметр voice.record_key действует только в CLI и TUI.' }, hudModifier: { title: 'Вызов HUD коротким нажатием', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 3ba6192076..6581d8022d 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1119,6 +1119,8 @@ export interface Translations { attachmentSizeDesc: string attachmentSizeUnit: string attachmentSizeLabel: string + voiceShortcutHintTitle: string + voiceShortcutHintDesc: string showOptions: string } hudModifier: { diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 3e0194cf50..fc51a2f023 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -736,7 +736,6 @@ export const zhHant = defineLocale({ maxSnapshots: '檢查點上限' }, voice: { - recordKey: '語音快捷鍵', maxRecordingSeconds: '最長錄音時間', autoTts: '朗讀回覆', voiceChatMode: '語音聊天模式', @@ -1088,6 +1087,9 @@ export const zhHant = defineLocale({ invalidJson: '設定 JSON 無效', keepAwakeTitle: '保持電腦喚醒', keepAwakeDesc: '阻止本機睡眠,讓長時間或整夜執行持續進行。螢幕仍可變暗。', + voiceShortcutHintTitle: '語音錄製快捷鍵', + voiceShortcutHintDesc: + '請在「設定 → 鍵盤快捷鍵」中設定語音錄製快捷鍵(「開始 / 停止聽寫」)。voice.record_key 設定僅適用於 CLI 和 TUI。', showOptions: '顯示選項' }, hudModifier: { diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index b58b0afd83..f10244c84b 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -947,7 +947,6 @@ export const zh = defineLocale({ maxSnapshots: '检查点上限' }, voice: { - recordKey: '语音快捷键', maxRecordingSeconds: '最长录音时长', autoTts: '朗读回复', voiceChatMode: '语音聊天模式', @@ -1307,6 +1306,9 @@ export const zh = defineLocale({ '桌面端为预览和图片附件加载本地文件的大小上限(MB)。默认为 16。远程非图片附件使用单独的 256 MB 上限。设置过大会将整个文件读入内存,可能导致应用卡死或崩溃。', attachmentSizeUnit: 'MB', attachmentSizeLabel: '预览 / 图片加载大小上限(MB)', + voiceShortcutHintTitle: '语音录制快捷键', + voiceShortcutHintDesc: + '请在“设置 → 键盘快捷键”中设置语音录制快捷键(“开始 / 停止听写”)。voice.record_key 配置项仅适用于 CLI 和 TUI。', showOptions: '显示选项' }, hudModifier: { diff --git a/apps/desktop/src/lib/keybinds/actions.ts b/apps/desktop/src/lib/keybinds/actions.ts index cdcd62dc66..fa59fa0e87 100644 --- a/apps/desktop/src/lib/keybinds/actions.ts +++ b/apps/desktop/src/lib/keybinds/actions.ts @@ -61,11 +61,9 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [ // Open WebUI, and Cherry Studio all ship the same chord). Opens the pill's // live dropdown on the pane under the pointer, else the active composer. { id: 'composer.modelPicker', category: 'composer', defaults: ['mod+shift+m'] }, - // Voice conversation toggle. Matches the documented `voice.record_key` - // (Ctrl+B). On macOS that's literally ⌃B — distinct from the ⌘B sidebar - // toggle. Off macOS `ctrl` folds to `mod`, which IS the ⌘B/Ctrl+B sidebar - // chord, so ship it unbound there (rebindable in the panel) rather than - // stealing the long-standing sidebar binding. + // Voice conversation toggle. On macOS that's literally ⌃B — distinct from + // the ⌘B sidebar toggle. Off macOS `ctrl` folds to `mod`, which IS the + // ⌘B/Ctrl+B sidebar chord, so ship it unbound there. { id: 'composer.voice', category: 'composer', defaults: IS_MAC ? ['ctrl+b'] : [] }, // Dictation is intentionally unbound: it is available for users who prefer // a keyboard trigger without claiming a chord from text entry by default. diff --git a/apps/desktop/src/lib/keybinds/combo.test.ts b/apps/desktop/src/lib/keybinds/combo.test.ts index 4a8a035f3d..1cb16dcc1d 100644 --- a/apps/desktop/src/lib/keybinds/combo.test.ts +++ b/apps/desktop/src/lib/keybinds/combo.test.ts @@ -47,6 +47,18 @@ describe('comboFromEvent', () => { expect(comboFromEvent(keydown({ code: 'Tab', ctrlKey: true }))).toBe('mod+tab') expect(comboFromEvent(keydown({ code: 'Tab', ctrlKey: true, shiftKey: true }))).toBe('mod+shift+tab') }) + + it('keeps function and special keys available for custom bindings', () => { + expect(comboFromEvent(keydown({ code: 'F1', key: 'F1' }))).toBe('f1') + expect(comboFromEvent(keydown({ code: 'F12', key: 'F12' }))).toBe('f12') + expect(comboFromEvent(keydown({ code: 'F19', key: 'F19' }))).toBe('f19') + expect(comboFromEvent(keydown({ code: 'F18', key: 'F18' }))).toBe('f18') + expect(comboFromEvent(keydown({ code: 'CapsLock', key: 'CapsLock' }))).toBe('capslock') + expect(comboFromEvent(keydown({ code: 'Space', key: ' ', altKey: true }))).toBe('alt+space') + expect(comboFromEvent(keydown({ code: 'KeyV', key: 'v', metaKey: true, shiftKey: true }))).toBe('mod+shift+v') + expect(comboFromEvent(keydown({ code: 'F18', key: 'F18', metaKey: true, shiftKey: true }))).toBe('mod+shift+f18') + expect(comboFromEvent(keydown({ code: 'F13', key: 'F13', altKey: true }))).toBe('alt+f13') + }) }) describe('canonicalizeCombo', () => { diff --git a/apps/desktop/src/lib/keybinds/combo.ts b/apps/desktop/src/lib/keybinds/combo.ts index e63da9a74a..7815c3e445 100644 --- a/apps/desktop/src/lib/keybinds/combo.ts +++ b/apps/desktop/src/lib/keybinds/combo.ts @@ -32,6 +32,8 @@ const CODE_TO_KEY: Record = { Enter: 'enter', Escape: 'escape', Backspace: 'backspace', + Delete: 'delete', + CapsLock: 'capslock', Tab: 'tab', PageUp: 'pageup', PageDown: 'pagedown', @@ -170,6 +172,8 @@ const TOKEN_LABELS: Record = { enter: '↵', escape: 'Esc', backspace: '⌫', + delete: 'Del', + capslock: 'Caps Lock', tab: '⇥', pageup: 'PgUp', pagedown: 'PgDn', From 1b7e8c45d62f25d30775b6b02c625172c58a891a Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Wed, 23 Sep 2026 21:50:05 -0500 Subject: [PATCH 10/61] fix(desktop): every settings row renders through one ListRow/ToggleRow/Slider/SearchField Appearance was the only page drawing booleans as an Off | On segmented control, and two rows hand-wrapped their control in items-end, so the same kind of setting sat in a different place depending on the page. ListRow now owns the action slot's alignment (left when stacked, right when wide) and every on/off preference is a ToggleRow; the two hand-rolled range inputs share a Slider, the three boxed search inputs use SearchField, and the rows that cannot be a ListRow lay out on the exported LIST_ROW_COLUMNS token. --- .../src/app/settings/appearance-settings.tsx | 292 ++++++------------ .../settings/billing/account-row-value.tsx | 5 +- .../app/settings/billing/auto-reload-row.tsx | 4 +- .../settings/billing/current-plan-card.tsx | 5 +- .../src/app/settings/billing/index.tsx | 4 +- .../desktop/src/app/settings/config-field.tsx | 75 +++-- .../src/app/settings/credential-key-ui.tsx | 6 +- .../app/settings/local-models-settings.tsx | 17 +- .../src/app/settings/model-settings.tsx | 18 +- .../app/settings/notifications-settings.tsx | 4 +- .../desktop/src/app/settings/pet-settings.tsx | 54 +--- apps/desktop/src/app/settings/primitives.tsx | 77 ++++- apps/desktop/src/components/ui/slider.tsx | 18 ++ apps/desktop/src/i18n/ar.ts | 2 - apps/desktop/src/i18n/de.ts | 2 - apps/desktop/src/i18n/en.ts | 2 - apps/desktop/src/i18n/es.ts | 2 - apps/desktop/src/i18n/fr.ts | 2 - apps/desktop/src/i18n/ja.ts | 2 - apps/desktop/src/i18n/ru.ts | 2 - apps/desktop/src/i18n/types.ts | 2 - apps/desktop/src/i18n/zh-hant.ts | 2 - apps/desktop/src/i18n/zh.ts | 2 - 23 files changed, 253 insertions(+), 346 deletions(-) create mode 100644 apps/desktop/src/components/ui/slider.tsx diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx index 9d38106a30..837cc46b54 100644 --- a/apps/desktop/src/app/settings/appearance-settings.tsx +++ b/apps/desktop/src/app/settings/appearance-settings.tsx @@ -4,8 +4,9 @@ import { useEffect, useState } from 'react' import { useDebounced } from '@/app/hooks/use-debounced' import { LanguageSwitcher } from '@/components/language-switcher' -import { Button } from '@/components/ui/button' +import { SearchField } from '@/components/ui/search-field' import { SegmentedControl } from '@/components/ui/segmented-control' +import { Slider } from '@/components/ui/slider' import type { DesktopMarketplaceSearchItem } from '@/global' import { saveHermesConfig } from '@/hermes' import { useI18n } from '@/i18n' @@ -72,14 +73,13 @@ import { $marketplaceInstalls, isUserTheme, removeUserTheme } from '@/themes/use import { setHermesConfigCache, useHermesConfigRecord } from '../hooks/use-config-record' -import { appearanceSubpageForSetting, type AppearanceSubpageId } from './appearance-subpages' +import { APPEARANCE_SETTING_IDS, appearanceSubpageForSetting, type AppearanceSubpageId } from './appearance-subpages' import { ChatFontSetting } from './chat-font-setting' import { MODE_OPTIONS } from './constants' import { setNested } from './helpers' import { MinimizeToTraySetting } from './minimize-to-tray-setting' import { PetSettings } from './pet-settings' -import { ListRow, SectionHeading, SettingsContent, ToggleRow } from './primitives' -import { APPEARANCE_SETTING_IDS } from './settings-search' +import { ListRow, RowFootnoteAction, SectionHeading, SettingsContent, ToggleRow } from './primitives' import { TerminalFontSetting } from './terminal-font-setting' import { useDeepLinkHighlight } from './use-deep-link-highlight' @@ -121,6 +121,7 @@ function ResumeLastSessionSetting() { checked={checked} description={a.resumeLastSessionDesc} disabled={!config} + id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.resumeLastSession)} label={a.resumeLastSessionTitle} onChange={update} /> @@ -362,9 +363,8 @@ interface TranslucencySliderProps { function TranslucencySlider({ label, onChange, value }: TranslucencySliderProps) { return ( <> - @@ -579,19 +577,27 @@ export function AppearanceSettings({ subpage }: AppearanceSettingsProps = {}) { {show('theme') && ( { + triggerHaptic('crisp') + setMode(id) + }} + options={modeOptions} + value={mode} + /> + } below={ <> {/* One search box: filters your installed themes (the grid) and live-searches the VS Code Marketplace below. */} -
- setQuery(event.target.value)} - placeholder={a.themeSearchPlaceholder} - spellCheck={false} - value={query} - /> -
+ {/* The dedicated theme page uses the page scroller rather than clipping its gallery inside another scroll area. */} @@ -662,19 +668,7 @@ export function AppearanceSettings({ subpage }: AppearanceSettingsProps = {}) { } description={a.themeDesc} id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.theme)} - title={ -
- {a.themeTitle} - { - triggerHaptic('crisp') - setMode(id) - }} - options={modeOptions} - value={mode} - /> -
- } + title={a.themeTitle} wide /> )} @@ -738,6 +732,7 @@ export function AppearanceSettings({ subpage }: AppearanceSettingsProps = {}) { /> } description={a.sessionDensityDesc} + id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.sessionDensity)} title={a.sessionDensityTitle} /> )} @@ -755,6 +750,7 @@ export function AppearanceSettings({ subpage }: AppearanceSettingsProps = {}) { /> } description={a.tabStripDesc} + id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.tabStrip)} title={a.tabStripTitle} /> )} @@ -906,65 +902,32 @@ export function AppearanceSettings({ subpage }: AppearanceSettingsProps = {}) { )} {show('window-layout') && ( - { - triggerHaptic('selection') - setBackdrop(id === 'on') - }} - options={[ - { id: 'off', label: t.common.off }, - { id: 'on', label: t.common.on } - ]} - value={backdrop ? 'on' : 'off'} - /> - } + )} {show('chat-display') && ( - { - triggerHaptic('selection') - setHideThreadTimeline(id === 'on') - }} - options={[ - { id: 'off', label: t.common.off }, - { id: 'on', label: t.common.on } - ]} - value={hideThreadTimeline ? 'on' : 'off'} - /> - } + )} {show('general') && ( - { - triggerHaptic('selection') - setIntroSplash(id === 'on') - }} - options={[ - { id: 'off', label: t.common.off }, - { id: 'on', label: t.common.on } - ]} - value={introSplash ? 'on' : 'off'} - /> - } + )} @@ -972,6 +935,7 @@ export function AppearanceSettings({ subpage }: AppearanceSettingsProps = {}) { @@ -980,98 +944,47 @@ export function AppearanceSettings({ subpage }: AppearanceSettingsProps = {}) { {show('general') && } {show('chat-display') && ( - { - triggerHaptic('selection') - setReactionsEnabled(id === 'on') - }} - options={[ - { id: 'off', label: t.common.off }, - { id: 'on', label: t.common.on } - ]} - value={reactionsEnabled ? 'on' : 'off'} - /> - } + )} {show('general') && ( - - { - triggerHaptic('selection') - setTipsEnabled(id === 'on') - }} - options={[ - { id: 'off', label: t.common.off }, - { id: 'on', label: t.common.on } - ]} - value={tipsEnabled ? 'on' : 'off'} - /> - {/* A tip shows once (✕ or timer), so this is the only way to a - second lap. It appears once there is something to bring back. */} - {spentTips > 0 && ( - - )} - + 0 && {a.tipsReset(spentTips)} } + checked={tipsEnabled} description={a.tipsDesc} - title={a.tipsTitle} + id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.tips)} + label={a.tipsTitle} + onChange={setTipsEnabled} /> )} {show('general') && ( - { - triggerHaptic('selection') - setToursEnabled(id === 'on') - }} - options={[ - { id: 'off', label: t.common.off }, - { id: 'on', label: t.common.on } - ]} - value={toursEnabled ? 'on' : 'off'} - /> - } + )} {show('chat-display') && ( - { - triggerHaptic('selection') - setVibeHeartsEnabled(id === 'on') - }} - options={[ - { id: 'off', label: t.common.off }, - { id: 'on', label: t.common.on } - ]} - value={vibeHeartsEnabled ? 'on' : 'off'} - /> - } + )} @@ -1094,23 +1007,22 @@ export function AppearanceSettings({ subpage }: AppearanceSettingsProps = {}) { )} {show('chat-display') && ( - { - triggerHaptic('selection') - setHideCodeDiffs(id === 'on') - }} - options={[ - { id: 'off', label: t.common.off }, - { id: 'on', label: t.common.on } - ]} - value={hideCodeDiffs ? 'on' : 'off'} - /> - } + + )} + + {show('chat-display') && ( + )} @@ -1120,45 +1032,18 @@ export function AppearanceSettings({ subpage }: AppearanceSettingsProps = {}) { { triggerHaptic('selection') - setReasoningCollapsedByDefault(id === 'on') + setEmbedMode(id) }} - options={[ - { id: 'off', label: t.common.off }, - { id: 'on', label: t.common.on } - ]} - value={reasoningCollapsedByDefault ? 'on' : 'off'} + options={embedOptions} + value={embedMode} /> } - description={withModeNote(a.reasoningCollapsedDesc, reasoningCollapsedShadowed)} - title={a.reasoningCollapsedTitle} - /> - )} - - {show('chat-display') && ( - - { - triggerHaptic('selection') - setEmbedMode(id) - }} - options={embedOptions} - value={embedMode} - /> - {embedAllowed.length > 0 && ( - - )} - + below={ + embedAllowed.length > 0 && ( + + {a.embedsReset(embedAllowed.length)} + + ) } description={a.embedsDesc} id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.embeds)} @@ -1169,7 +1054,10 @@ export function AppearanceSettings({ subpage }: AppearanceSettingsProps = {}) { {show('pet') && ( -
+
)} diff --git a/apps/desktop/src/app/settings/billing/account-row-value.tsx b/apps/desktop/src/app/settings/billing/account-row-value.tsx index 4c54e4cbac..9ab65424fb 100644 --- a/apps/desktop/src/app/settings/billing/account-row-value.tsx +++ b/apps/desktop/src/app/settings/billing/account-row-value.tsx @@ -10,8 +10,9 @@ export function RowValue({ onAction, row }: { onAction?: () => void; row: Billin // Destructure to a const so narrowing survives into the onClick closure below. const { action } = row + // Rendered as a ListRow `action`; the row owns wrapping and alignment. return ( -
+ <> {row.value && ( {row.value} @@ -43,6 +44,6 @@ export function RowValue({ onAction, row }: { onAction?: () => void; row: Billin {!action.disabled && action.url && } )} -
+ ) } diff --git a/apps/desktop/src/app/settings/billing/auto-reload-row.tsx b/apps/desktop/src/app/settings/billing/auto-reload-row.tsx index d430945d1a..4b75a4d99f 100644 --- a/apps/desktop/src/app/settings/billing/auto-reload-row.tsx +++ b/apps/desktop/src/app/settings/billing/auto-reload-row.tsx @@ -6,7 +6,7 @@ import { Input } from '@/components/ui/input' import { useI18n } from '@/i18n' import { cn } from '@/lib/utils' -import { ListRow, Pill } from '../primitives' +import { LIST_ROW_COLUMNS, ListRow, Pill } from '../primitives' import { RowValue } from './account-row-value' import type { BillingRefusal } from './api' @@ -168,7 +168,7 @@ export function AutoReloadRow({ // panes. The form is `invisible` + `aria-hidden` when not editing. return (
-
+
{row.title} diff --git a/apps/desktop/src/app/settings/billing/current-plan-card.tsx b/apps/desktop/src/app/settings/billing/current-plan-card.tsx index 638aae9748..997adcffe3 100644 --- a/apps/desktop/src/app/settings/billing/current-plan-card.tsx +++ b/apps/desktop/src/app/settings/billing/current-plan-card.tsx @@ -1,6 +1,9 @@ import { Button } from '@/components/ui/button' import { useI18n } from '@/i18n' import { ExternalLink } from '@/lib/icons' +import { cn } from '@/lib/utils' + +import { LIST_ROW_COLUMNS } from '../primitives' import { BillingRefusalInline } from './inline-feedback' import { openExternal } from './open-external' @@ -15,7 +18,7 @@ export function CurrentPlanCard({ onViewPlans, plan }: { onViewPlans: () => void return (
-
+
diff --git a/apps/desktop/src/app/settings/billing/index.tsx b/apps/desktop/src/app/settings/billing/index.tsx index c97130dd43..2fe4760f64 100644 --- a/apps/desktop/src/app/settings/billing/index.tsx +++ b/apps/desktop/src/app/settings/billing/index.tsx @@ -195,7 +195,7 @@ function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: B return ( + <> setAmount(value)} @@ -224,7 +224,7 @@ function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: B -
+ } below={ ( - + const dataTour = `field-${schemaKey}` + + const row = (action: ReactNode) => ( + + ) + + // Editors too big for the control column (textareas, structured lists) take + // the full width under the description. + const wideRow = (editor: ReactNode) => ( + {editor}
} + data-tour={dataTour} + description={descriptionNode} + title={label} + wide + /> ) // `fallback_providers` is a list of {provider, model} objects; the generic // `list` branch below would stringify them to "[object Object]". Render the // dedicated structured editor instead. if (schemaKey === 'fallback_providers') { - return row(, true) + return wideRow() } if (schema.type === 'boolean') { - return row( -
- -
+ return ( + ) } @@ -201,7 +218,7 @@ export function ConfigField({ } if (typeof value === 'object' && value !== null) { - return row( + return wideRow(