Compare commits

..

1 Commits

Author SHA1 Message Date
teknium1
76fa55240d fix(gateway): use base pythonw for post-update Windows respawn
The post-update respawn watcher (launch_detached_profile_gateway_restart)
respawned the gateway via `_gateway_run_args_for_profile`, which used
`get_python_path()` — the console `python.exe`. On uv-created venvs even
`venv\Scripts\pythonw.exe` re-execs the base interpreter as a console
`python.exe`, and that re-exec is a fresh CreateProcess that does NOT
inherit the watcher's CREATE_NO_WINDOW flag. Result: a blank console
window pops up after every Desktop-GUI `hermes update`, and the respawned
gateway is tied to it.

`hermes gateway start` already avoids this by routing through
`_resolve_detached_python` to get the base `pythonw.exe` plus a
VIRTUAL_ENV / PYTHONPATH overlay (see `_build_gateway_argv`/`_spawn_detached`).
The post-update respawn path was the one launch site that never got the
same treatment — a sibling call path of the same bug class.

Fix:
- `_gateway_run_args_for_profile` now resolves the base `pythonw.exe` on
  Windows via `_resolve_detached_python` (no-op on POSIX — argv keeps
  `get_python_path()` verbatim).
- New `_gateway_respawn_env` overlays VIRTUAL_ENV / PYTHONPATH /
  HERMES_GATEWAY_DETACHED so the base-interpreter respawn can import
  `hermes_cli` without the venv launcher shim. Returns the env unchanged
  on POSIX, preserving pre-fix spawn behaviour bit-for-bit.
- The watcher interpreter itself is resolved the same way and both
  watcher Popen calls pass the overlay env; the respawned gateway
  inherits it transitively from the watcher.

This complements #41028 / #38605, which fix the same uv-pythonw console
trap in the Scheduled-Task `.cmd` wrapper (`_build_gateway_cmd_script`,
the login path). Neither touches the post-update respawn argv this PR
fixes.

Adds tests/hermes_cli/test_gateway_update_respawn_pythonw.py:
base-pythonw resolution for uv venvs, the env overlay, a POSIX-equivalence
guard, and a live-Windows windowless-interpreter check.
2026-06-11 06:32:25 -07:00
465 changed files with 5115 additions and 37306 deletions

View File

@@ -90,7 +90,7 @@ jobs:
# (see `_SKIP_PARTS` in scripts/run_tests_parallel.py) because each
# shard would otherwise reach the session-scoped ``built_image``
# fixture in ``tests/docker/conftest.py`` and start a 3-7min
# ``docker build`` — guaranteed to
# ``docker build`` under a 180s pytest-timeout cap — guaranteed to
# die in fixture setup.
#
# Piggybacking here avoids a second image build: the smoke test
@@ -114,7 +114,7 @@ jobs:
run: |
uv venv .venv --python 3.11
source .venv/bin/activate
# ``dev`` extra pulls in pytest, pytest-asyncio —
# ``dev`` extra pulls in pytest, pytest-asyncio, pytest-timeout
# everything tests/docker/ needs. We deliberately avoid ``all``
# here because the docker tests only drive the container via
# subprocess and don't import hermes_agent's optional deps.

View File

@@ -4,13 +4,13 @@ on:
push:
branches: [main]
paths-ignore:
- "**/*.md"
- "docs/**"
- '**/*.md'
- 'docs/**'
pull_request:
branches: [main]
paths-ignore:
- "**/*.md"
- "docs/**"
- '**/*.md'
- 'docs/**'
permissions:
contents: read
@@ -30,17 +30,13 @@ jobs:
slice: [1, 2, 3, 4, 5, 6]
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore duration cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: test_durations.json
# main always writes a new suffix, but jobs pick the latest one with the same prefix
# quote from https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching#cache-hits-and-misses
# If you provide restore-keys, the cache action sequentially searches for any caches that match the list of restore-keys.
# If there are no exact matches, the action searches for partial matches of the restore keys.
# When the action finds a partial match, the most recent cache is restored to the path directory.
# Single stable key. main always overwrites, PRs always find it.
key: test-durations
- name: Install ripgrep (prebuilt binary)
@@ -58,7 +54,7 @@ jobs:
rg --version
- name: Install uv
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
with:
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
# Keyed on the dependency manifests, so the cache is reused until
@@ -119,7 +115,7 @@ jobs:
NOUS_API_KEY: ""
- name: Upload per-slice durations
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: test-durations-slice-${{ matrix.slice }}
path: test_durations.json
@@ -129,11 +125,11 @@ jobs:
# (including PRs) get balanced slicing.
save-durations:
needs: test
if: needs.test.result == 'success' && github.ref == 'refs/heads/main'
if: always() && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Download all slice durations
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: test-durations-slice-*
path: durations
@@ -153,17 +149,17 @@ jobs:
"
- name: Save merged duration cache
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: test_durations.json
key: test-durations-${{ github.run_id }}
key: test-durations
e2e:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install ripgrep (prebuilt binary)
run: |
@@ -180,7 +176,7 @@ jobs:
rg --version
- name: Install uv
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
with:
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
# Keyed on the dependency manifests, so the cache is reused until

7
.gitignore vendored
View File

@@ -89,9 +89,6 @@ website/static/api/skills-index.json
# every build).
website/static/api/skills.json
website/static/api/skills-meta.json
# automation-blueprints-index.json is a build artifact emitted by
# website/scripts/extract-automation-blueprints.py during prebuild.
website/static/api/automation-blueprints-index.json
models-dev-upstream/
# Local editor / agent tooling (machine-specific; keep in global config, not the repo)
@@ -132,7 +129,3 @@ scripts/out/
# stores the published notes. They are not a build artifact and must never be
# committed to the repo root. See the hermes-release skill.
RELEASE_v*.md
# Desktop demo-run scratch output (hermes writes demo/*.txt during recorded
# walkthroughs). Throwaway artifacts, never part of the app.
apps/desktop/demo/

View File

@@ -824,7 +824,6 @@ class HermesACPAgent(acp.Agent):
try:
from model_tools import get_tool_definitions
from agent.memory_manager import inject_memory_provider_tools
enabled_toolsets = _expand_acp_enabled_toolsets(
getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"],
@@ -840,7 +839,6 @@ class HermesACPAgent(acp.Agent):
state.agent.valid_tool_names = {
tool["function"]["name"] for tool in state.agent.tools or []
}
inject_memory_provider_tools(state.agent)
invalidate = getattr(state.agent, "_invalidate_system_prompt", None)
if callable(invalidate):
invalidate()
@@ -1781,25 +1779,10 @@ class HermesACPAgent(acp.Agent):
def _cmd_tools(self, args: str, state: SessionState) -> str:
try:
from model_tools import get_tool_definitions
from types import SimpleNamespace
from agent.memory_manager import inject_memory_provider_tools
toolsets = _expand_acp_enabled_toolsets(
getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"]
)
tools = get_tool_definitions(enabled_toolsets=toolsets, quiet_mode=True)
tool_view = SimpleNamespace(
tools=list(tools or []),
valid_tool_names={
tool.get("function", {}).get("name")
for tool in tools or []
if isinstance(tool, dict)
},
enabled_toolsets=toolsets,
_memory_manager=getattr(state.agent, "_memory_manager", None),
)
inject_memory_provider_tools(tool_view)
tools = tool_view.tools
if not tools:
return "No tools available."
lines = [f"Available tools ({len(tools)}):"]

View File

@@ -145,7 +145,7 @@ def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]:
account info to show (fail-open: caller just shows nothing).
"""
try:
from hermes_cli.nous_account import nous_portal_topup_url
from hermes_cli.nous_account import nous_portal_billing_url
if account_info is None or not getattr(account_info, "logged_in", False):
return None
@@ -213,8 +213,7 @@ def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]:
if not windows and not details:
return None
details.append(f"Top up: {nous_portal_topup_url(account_info)}")
details.append("(or run /credits)")
details.append(f"Manage / top up: {nous_portal_billing_url(account_info)}")
plan = getattr(sub, "plan", None) if sub is not None else None
return AccountUsageSnapshot(
@@ -338,93 +337,6 @@ def _snapshot_from_credits_state(state) -> Optional[AccountUsageSnapshot]:
return None
@dataclass(frozen=True)
class CreditsView:
"""Surface-agnostic data for the ``/credits`` command.
One portal fetch, one parse — consumed identically by the CLI panel, the
gateway button, and any other money surface. Fail-open: when not logged in
or the portal is unreachable, ``logged_in`` is False / ``topup_url`` is None
and callers degrade gracefully.
"""
logged_in: bool
balance_lines: tuple[str, ...] = ()
identity_line: Optional[str] = None
topup_url: Optional[str] = None
depleted: bool = False
def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> CreditsView:
"""Build the /credits view: balance block + identity line + top-up URL.
Reuses the same account fetch + snapshot + URL builder as the /usage credits
block, so the numbers always match. The balance block is the rendered
snapshot MINUS its trailing top-up/command-hint lines (the /credits surface
supplies its own affordance). Fail-open → ``CreditsView(logged_in=False)``.
"""
not_logged_in = CreditsView(logged_in=False)
try:
from hermes_cli.auth import get_provider_auth_state
tok = (get_provider_auth_state("nous") or {}).get("access_token")
if not (isinstance(tok, str) and tok.strip()):
return not_logged_in
except Exception:
return not_logged_in
try:
import concurrent.futures
from hermes_cli.nous_account import (
get_nous_portal_account_info,
nous_portal_topup_url,
)
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
account = pool.submit(get_nous_portal_account_info, force_fresh=True).result(
timeout=timeout
)
except Exception:
logger.debug("credits ▸ /credits portal fetch failed (fail-open)", exc_info=True)
return not_logged_in
if account is None or not getattr(account, "logged_in", False):
return not_logged_in
snapshot = build_nous_credits_snapshot(account)
# Balance lines = the snapshot block minus the two trailing affordance lines
# ("Top up: <url>" + "(or run /credits)") that build_nous_credits_snapshot
# appends for the /usage surface. /credits renders its own button/panel.
balance_lines: list[str] = []
if snapshot is not None:
rendered = render_account_usage_lines(snapshot, markdown=markdown)
balance_lines = [
line
for line in rendered
if not line.lstrip().startswith("Top up:")
and not line.lstrip().startswith("(or run")
]
# Identity line — shown before any open (roadmap §4.4).
email = getattr(account, "email", None)
org_name = getattr(account, "org_name", None)
who: list[str] = []
if email:
who.append(str(email))
if org_name:
who.append(f"org {org_name}")
identity_line = ("Topping up as " + " / ".join(who)) if who else None
return CreditsView(
logged_in=True,
balance_lines=tuple(balance_lines),
identity_line=identity_line,
topup_url=nous_portal_topup_url(account),
depleted=getattr(account, "paid_service_access", None) is False,
)
def _resolve_codex_usage_url(base_url: str) -> str:
normalized = (base_url or "").strip().rstrip("/")
if not normalized:

View File

@@ -1193,8 +1193,38 @@ def init_agent(
_ra().logger.warning("Memory provider plugin init failed: %s", _mpe)
agent._memory_manager = None
from agent.memory_manager import inject_memory_provider_tools as _inject_memory_provider_tools
_inject_memory_provider_tools(agent)
# Inject memory provider tool schemas into the tool surface.
# Skip tools whose names already exist (plugins may register the
# same tools via ctx.register_tool(), which lands in agent.tools
# through _ra().get_tool_definitions()). Duplicate function names cause
# 400 errors on providers that enforce unique names (e.g. Xiaomi
# MiMo via Nous Portal).
#
# Respect the platform's enabled_toolsets configuration (#5544):
# enabled_toolsets is None → no filter, inject (backward compat)
# "memory" in enabled_toolsets → user opted in, inject
# otherwise (incl. []) → user excluded memory, skip injection
#
# Without this gate, `platform_toolsets: telegram: []` still leaks memory
# provider tools (fact_store, etc.) into the tool surface — a 10x latency
# penalty on local models and a frequent trigger of tool-call loops.
if agent._memory_manager and agent.tools is not None and (
agent.enabled_toolsets is None or "memory" in agent.enabled_toolsets
):
_existing_tool_names = {
t.get("function", {}).get("name")
for t in agent.tools
if isinstance(t, dict)
}
for _schema in agent._memory_manager.get_all_tool_schemas():
_tname = _schema.get("name", "")
if _tname and _tname in _existing_tool_names:
continue # already registered via plugin path
_wrapped = {"type": "function", "function": _schema}
agent.tools.append(_wrapped)
if _tname:
agent.valid_tool_names.add(_tname)
_existing_tool_names.add(_tname)
# Skills config: nudge interval for skill creation reminders
agent._skill_nudge_interval = 10

View File

@@ -445,45 +445,6 @@ def repair_message_sequence(agent, messages: List[Dict]) -> int:
return repairs
def repair_message_sequence_with_cursor(agent, messages: List[Dict]) -> int:
"""Run :func:`repair_message_sequence` and keep the SessionDB flush
cursor consistent with the compacted list (#44837).
``repair_message_sequence`` merges/drops messages in place, shrinking
the list. ``_last_flushed_db_idx`` (the DB-write cursor) indexes into
that list, so after compaction it can point past the new end — the
turn-end flush would then skip the assistant/tool chain entirely — or
past unflushed messages shifted to lower indexes.
Repair preserves object identity for surviving messages, so counting
the survivors from the previously-flushed prefix gives the exact new
cursor even when messages are dropped/merged at indexes *before* the
cursor — a plain ``min()`` clamp would silently skip that many
unflushed rows. Falls back to the clamp when no prefix snapshot is
available.
Returns the number of repairs made (same as ``repair_message_sequence``).
"""
pre_repair_flushed_ids = None
flush_cursor = getattr(agent, "_last_flushed_db_idx", None)
if isinstance(flush_cursor, int) and flush_cursor > 0:
pre_repair_flushed_ids = {id(m) for m in messages[:flush_cursor]}
repairs = repair_message_sequence(agent, messages)
if repairs > 0 and hasattr(agent, "_last_flushed_db_idx"):
if pre_repair_flushed_ids is not None:
agent._last_flushed_db_idx = sum(
1 for m in messages if id(m) in pre_repair_flushed_ids
)
else:
agent._last_flushed_db_idx = min(
agent._last_flushed_db_idx, len(messages)
)
return repairs
def strip_think_blocks(agent, content: str) -> str:
"""Remove reasoning/thinking blocks from content, returning only visible text.
@@ -618,33 +579,12 @@ def recover_with_credential_pool(
current_provider = (getattr(agent, "provider", "") or "").strip().lower()
pool_provider = (getattr(pool, "provider", "") or "").strip().lower()
if current_provider and pool_provider and current_provider != pool_provider:
# Custom endpoints use two naming conventions for the SAME provider:
# the agent carries the generic ``custom`` label while the pool is
# keyed ``custom:<name>`` (see CUSTOM_POOL_PREFIX). A literal string
# compare treats them as a mismatch and skips recovery for every
# custom-provider user — 401s/429s then burn the full retry cycle
# with no rotation or refresh. Accept the pair as matching only when
# the agent's CURRENT base_url actually resolves to this pool key,
# so a fallback provider (or a different custom endpoint) still
# triggers the guard.
_custom_match = False
if current_provider == "custom" and pool_provider.startswith("custom:"):
try:
from agent.credential_pool import get_custom_provider_pool_key
_agent_base = (getattr(agent, "base_url", "") or "").strip()
_custom_match = bool(_agent_base) and (
(get_custom_provider_pool_key(_agent_base) or "").strip().lower()
== pool_provider
)
except Exception:
_custom_match = False
if not _custom_match:
_ra().logger.warning(
"Credential pool provider mismatch: pool=%s, agent=%s"
"skipping pool mutation to avoid cross-provider contamination",
pool_provider, current_provider,
)
return False, has_retried_429
_ra().logger.warning(
"Credential pool provider mismatch: pool=%s, agent=%s"
"skipping pool mutation to avoid cross-provider contamination",
pool_provider, current_provider,
)
return False, has_retried_429
effective_reason = classified_reason
if effective_reason is None:
@@ -739,28 +679,15 @@ def recover_with_credential_pool(
# long-running TUI sessions stuck on stale tokens until the user
# exited and reopened.
is_entitlement = agent._is_entitlement_failure(error_context, status_code)
_auth_haystack = " ".join(
str(error_context.get(k) or "").lower()
for k in ("message", "reason", "code", "error")
if isinstance(error_context, dict)
)
if (
not is_entitlement
and status_code == 403
and "oauth authentication is currently not allowed for this organization" in _auth_haystack
):
is_entitlement = True
if (
not is_entitlement
and status_code == 403
and (agent.provider or "") == "anthropic"
and getattr(agent, "api_mode", "") == "anthropic_messages"
):
is_entitlement = True
if not is_entitlement and status_code == 403 and (agent.provider or "") == "xai-oauth":
_disambiguator_haystack = " ".join(
str(error_context.get(k) or "").lower()
for k in ("message", "reason", "code", "error")
if isinstance(error_context, dict)
)
_is_xai_auth_failure = (
"[wke=unauthenticated:" in _auth_haystack
or "oauth2 access token could not be validated" in _auth_haystack
"[wke=unauthenticated:" in _disambiguator_haystack
or "oauth2 access token could not be validated" in _disambiguator_haystack
)
if not _is_xai_auth_failure:
is_entitlement = True

View File

@@ -3190,7 +3190,7 @@ def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Option
if (main_provider and main_model
and main_provider not in {"auto", ""}):
resolved_provider = main_provider
explicit_base_url = runtime_base_url or None
explicit_base_url = None
explicit_api_key = None
if runtime_base_url and (main_provider == "custom" or main_provider.startswith("custom:")):
resolved_provider = "custom"

View File

@@ -208,41 +208,6 @@ def is_stale_connection_error(exc: BaseException) -> bool:
return False
def is_streaming_access_denied_error(exc: BaseException) -> bool:
"""Return True when AWS denied the ``bedrock:InvokeModelWithResponseStream`` action.
IAM policies scoped to ``bedrock:InvokeModel`` only (a common least-privilege
setup) reject ``converse_stream()`` with an ``AccessDeniedException`` whose
message names the streaming action, e.g.::
User: arn:aws:iam::123456789012:user/x is not authorized to perform:
bedrock:InvokeModelWithResponseStream on resource: ...
This is permanent for the session — retrying the stream can never succeed —
so callers should flip to the non-streaming ``converse()`` path (which maps
to ``bedrock:InvokeModel``) instead of burning retries.
Detection is deliberately message-based: boto3 surfaces this as a
``ClientError`` with ``Error.Code == "AccessDeniedException"``, and the
AnthropicBedrock SDK wraps the same AWS response in its own exception
types, but both preserve the action name in the message.
"""
msg = str(exc).lower()
if "invokemodelwithresponsestream" not in msg:
return False
# ClientError with an explicit access-denied code is the canonical form.
try:
from botocore.exceptions import ClientError
except ImportError: # pragma: no cover — botocore always present with boto3
ClientError = None # type: ignore[assignment]
if ClientError is not None and isinstance(exc, ClientError):
code = (getattr(exc, "response", None) or {}).get("Error", {}).get("Code", "")
return code in ("AccessDeniedException", "UnauthorizedException")
# Wrapped forms (e.g. AnthropicBedrock SDK PermissionDeniedError) — match
# on the authorization-failure phrasing AWS uses.
return "not authorized" in msg or "accessdenied" in msg
# ---------------------------------------------------------------------------
# AWS credential detection
# ---------------------------------------------------------------------------
@@ -1038,16 +1003,6 @@ def call_converse_stream(
try:
response = client.converse_stream(**kwargs)
except Exception as exc:
if is_streaming_access_denied_error(exc):
# IAM allows bedrock:InvokeModel but not
# InvokeModelWithResponseStream — permanent for this session.
# Fall back to the non-streaming converse() path.
logger.info(
"bedrock: converse_stream denied by IAM on (region=%s, model=%s) — "
"falling back to non-streaming converse().",
region, model,
)
return normalize_converse_response(client.converse(**kwargs))
if is_stale_connection_error(exc):
logger.warning(
"bedrock: stale-connection error on converse_stream(region=%s, "

View File

@@ -1615,8 +1615,6 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
_get_bedrock_runtime_client,
invalidate_runtime_client,
is_stale_connection_error,
is_streaming_access_denied_error,
normalize_converse_response,
stream_converse_with_callbacks,
)
region = api_kwargs.pop("__bedrock_region__", "us-east-1")
@@ -1625,29 +1623,6 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
try:
raw_response = client.converse_stream(**api_kwargs)
except Exception as _bedrock_exc:
# IAM policies scoped to bedrock:InvokeModel only (no
# InvokeModelWithResponseStream) reject converse_stream()
# with AccessDeniedException. That denial is permanent for
# the session — fall back to the non-streaming converse()
# inline (it maps to bedrock:InvokeModel) and disable
# streaming for subsequent calls so we don't re-fail every
# turn.
if is_streaming_access_denied_error(_bedrock_exc):
agent._disable_streaming = True
agent._safe_print(
"\n⚠ AWS IAM denied bedrock:InvokeModelWithResponseStream — "
"falling back to non-streaming InvokeModel.\n"
" Grant that action to restore streaming output.\n"
)
logger.info(
"bedrock: converse_stream denied by IAM (%s) — "
"using non-streaming converse() for this session.",
type(_bedrock_exc).__name__,
)
result["response"] = normalize_converse_response(
client.converse(**api_kwargs)
)
return
# Evict the cached client on stale-connection failures
# so the outer retry loop builds a fresh client/pool.
if is_stale_connection_error(_bedrock_exc):
@@ -2449,34 +2424,9 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
"stream" in _err_lower
and "not supported" in _err_lower
)
# AWS Bedrock (AnthropicBedrock SDK path): IAM policies
# with bedrock:InvokeModel but not
# InvokeModelWithResponseStream reject messages.stream()
# with a permission error naming the streaming action.
# Permanent for the session — flip to non-streaming
# (messages.create() maps to bedrock:InvokeModel).
_is_bedrock_stream_denied = False
if (
not _is_stream_unsupported
and "invokemodelwithresponsestream" in _err_lower
):
# Cheap message pre-check before importing the
# adapter — bedrock_adapter triggers a lazy boto3
# install at import time, which must not run for
# unrelated providers' stream errors.
from agent.bedrock_adapter import (
is_streaming_access_denied_error,
)
_is_bedrock_stream_denied = (
is_streaming_access_denied_error(e)
)
if _is_stream_unsupported or _is_bedrock_stream_denied:
if _is_stream_unsupported:
agent._disable_streaming = True
agent._safe_print(
"\n⚠ AWS IAM denied bedrock:InvokeModelWithResponseStream. "
"Switching to non-streaming.\n"
" Grant that action to restore streaming output.\n"
if _is_bedrock_stream_denied else
"\n⚠ Streaming is not supported for this "
"model/provider. Switching to non-streaming.\n"
" To avoid this delay, set display.streaming: false "

View File

@@ -127,21 +127,14 @@ def _chat_content_to_responses_parts(content: Any, *, role: str = "user") -> Lis
return converted
def _summarize_user_message_for_log(content: Any, *, sep: str = " ") -> str:
"""Flatten message content to a plain-text summary.
def _summarize_user_message_for_log(content: Any) -> str:
"""Return a short text summary of a user message for logging/trajectory.
Multimodal messages arrive as a list of ``{type:"text"|"image_url", ...}``
parts from the API server. Several consumers want a plain string:
- Logging, spinner previews, and trajectory files (the default ``sep=" "``).
- External memory providers, which feed the text to regexes
(``sanitize_context``) and text APIs — a raw list crashes the sync with
``expected string or bytes-like object, got 'list'`` (use ``sep="\\n"``).
Text parts are joined with ``sep``; images become a ``[N image(s)]`` marker
so the turn isn't recorded as if the attachment never existed. Returns an
empty string for empty lists and ``str(content)`` for unexpected scalar
types.
parts from the API server. Logging, spinner previews, and trajectory
files all want a plain string — this helper extracts the first chunk of
text and notes any attached images. Returns an empty string for empty
lists and ``str(content)`` for unexpected scalar types.
"""
if content is None:
return ""
@@ -164,7 +157,7 @@ def _summarize_user_message_for_log(content: Any, *, sep: str = " ") -> str:
text_bits.append(text)
elif ptype in {"image_url", "input_image"}:
image_count += 1
summary = sep.join(text_bits).strip()
summary = " ".join(text_bits).strip()
if image_count:
note = f"[{image_count} image{'s' if image_count != 1 else ''}]"
summary = f"{note} {summary}" if summary else note

View File

@@ -40,11 +40,9 @@ Activation (config ``agent.coding_context``):
* ``auto`` (default) — posture (brief + snapshot) on an interactive coding
surface sitting in a code workspace (git repo or recognised project root).
Prompt-only; toolsets and the skill index untouched.
Prompt-only; toolsets untouched.
* ``focus`` — like ``auto``, but additionally collapses the toolset to the
``coding`` set + enabled MCP servers and demotes non-coding skill
categories to names-only in the prompt's skill index (no skill is ever
hidden). Explicit opt-in for a lean schema.
``coding`` set + enabled MCP servers. Explicit opt-in for a lean schema.
* ``on`` — force the posture anywhere (incl. non-workspaces). Prompt-only.
* ``off`` — disable entirely.
"""
@@ -106,19 +104,13 @@ _GIT_TIMEOUT = 2.5
# multi-file) and mode="replace" (find-and-swap). We nudge each family toward
# its native format. Unknown families get nothing (the brief's neutral wording
# stands). Substrings match the model id; aligned with TOOL_USE_ENFORCEMENT_MODELS.
#
# GPT/Codex get V4A for ALL edits, single-file included: in codex-rs,
# apply_patch (V4A — apply_patch.lark) is the ONLY file editor, no
# str_replace-style tool exists, and the shipped model prompts say to use
# apply_patch even "for single file edits" — so a replace-mode nudge would
# steer those models toward a format their first-party harness never taught
# them.
_EDIT_FORMAT_GUIDANCE: dict[str, tuple[tuple[str, ...], str]] = {
"patch": (
("gpt", "codex"),
"- Edit format: author new files with `write_file`; for edits to "
"existing code use `patch` with `mode='patch'` (V4A diff) — including "
"single-file edits. It's the edit format you handle most reliably.",
"existing code prefer `patch` with `mode='patch'` (V4A multi-file diff) "
"for structured or multi-file changes — it's the diff format you handle "
"most reliably. Use `mode='replace'` for a single small swap.",
),
"replace": (
("claude", "sonnet", "opus", "haiku",
@@ -190,10 +182,6 @@ CODING_AGENT_GUIDANCE = (
"Verify, and know when to stop:\n"
"- Use `terminal` for git, builds, tests, and inspection. Run the relevant "
"tests/linter/build and confirm they pass before claiming the work is done.\n"
"- Terminal state persists across calls: current directory and exported "
"environment variables carry forward. Activate a virtualenv or export setup "
"vars once, then reuse that state instead of re-sourcing it before every "
"test command.\n"
"- Fix root causes, not symptoms: when you find a bug, check sibling call "
"paths for the same flaw and fix the class, not just the reported site.\n"
"- When fixing linter/type errors on a file, stop after about three "
@@ -224,13 +212,11 @@ class ContextProfile:
``model_hint`` — routing preference key for smart model routing
(extension seam; not yet consumed by the router).
``memory_policy``— memory namespace/weighting hint (extension seam).
``compact_skill_categories`` — skill categories DEMOTED to names-only in
the system-prompt skill index under the opt-in ``focus``
mode. Never hidden: every skill name stays visible
(so memory-anchored recall keeps working) — only the
descriptions are dropped to cut index noise. Deny-list
semantics so unknown/custom categories keep full
entries.
``hidden_skill_categories`` — skill categories pruned from the system-prompt
skill index while this posture is active. Discovery-only:
nothing is disabled — ``skills_list`` still returns the
full catalog and ``skill_view`` loads anything. Deny-list
semantics so unknown/custom categories stay visible.
"""
name: str
@@ -238,14 +224,14 @@ class ContextProfile:
guidance: str = ""
model_hint: Optional[str] = None
memory_policy: str = "default"
compact_skill_categories: tuple[str, ...] = ()
hidden_skill_categories: tuple[str, ...] = ()
# Skill categories that are clearly not part of a coding workflow. Demoted to
# names-only in the prompt's skill index under the opt-in ``focus`` mode only
# (deny-list — anything not listed here, incl. custom user categories, keeps
# full entries). Coding-adjacent categories (devops, github, mcp,
# data-science, diagramming, research, security, …) are intentionally absent.
# Skill categories that are clearly not part of a coding workflow. Hidden from
# the prompt's skill index in the coding posture (deny-list — anything not
# listed here, incl. custom user categories, stays visible). Coding-adjacent
# categories (devops, github, mcp, data-science, diagramming, research,
# security, …) are intentionally absent.
_NON_CODING_SKILL_CATEGORIES = (
"apple", "communication", "cooking", "creative", "email", "finance",
"gaming", "gifs", "health", "media", "music", "note-taking",
@@ -261,7 +247,7 @@ CODING_PROFILE = ContextProfile(
guidance=CODING_AGENT_GUIDANCE,
model_hint="coding",
memory_policy="project",
compact_skill_categories=_NON_CODING_SKILL_CATEGORIES,
hidden_skill_categories=_NON_CODING_SKILL_CATEGORIES,
)
_PROFILES: dict[str, ContextProfile] = {
@@ -446,27 +432,9 @@ class RuntimeMode:
blocks.append(workspace)
return blocks
def compact_skill_categories(self) -> frozenset[str]:
"""Skill categories to demote to names-only in the prompt's skill index.
Gated on the opt-in ``focus`` mode, like the toolset collapse: the
default posture leaves the skill index untouched. Users who didn't ask
for a lean prompt keep full entries for every category — index changes
under ``auto`` proved too surprising in practice, even names-only ones
(a demoted description is information the model no longer weighs when
deciding what to load).
Demoted — never hidden — even under ``focus``. An earlier revision
fully pruned these categories from the index, which caused silent
capability loss in a real workflow: agent-created skills are the
model's accumulated project memory (server-ops runbooks, learned
pitfalls, …), and models do not reliably reach for ``skills_list`` to
rediscover what the index stopped showing them. Names-only keeps every
skill loadable on recall while still cutting the description noise.
"""
if not self.is_coding or self.config_mode != "focus":
return frozenset()
return frozenset(self.profile.compact_skill_categories)
def hidden_skill_categories(self) -> frozenset[str]:
"""Skill categories to prune from the prompt's skill index (may be empty)."""
return frozenset(self.profile.hidden_skill_categories)
def resolve_runtime_mode(
@@ -544,23 +512,20 @@ def coding_system_blocks(
).system_blocks()
def coding_compact_skill_categories(
def coding_hidden_skill_categories(
*,
platform: Optional[str] = None,
cwd: Optional[str | Path] = None,
config: Optional[dict[str, Any]] = None,
) -> frozenset[str]:
"""Skill categories the active posture demotes to names-only in the index.
"""Skill categories the active posture prunes from the prompt's skill index.
Empty outside the coding posture and outside the opt-in ``focus`` mode —
the default posture never touches the skill index. Under ``focus``,
demoted — never hidden: every skill name stays in the index and remains
loadable via ``skill_view`` / ``skills_list``; only descriptions are
dropped.
Empty outside the coding posture. Discovery-only: hidden skills remain
loadable via ``skills_list`` / ``skill_view``.
"""
return resolve_runtime_mode(
platform=platform, cwd=cwd, config=config
).compact_skill_categories()
).hidden_skill_categories()
def _enabled_mcp_servers(config: Optional[dict[str, Any]]) -> list[str]:
@@ -715,13 +680,10 @@ def build_coding_workspace_block(cwd: Optional[str | Path] = None) -> str:
lines.append("- Branch: (detached HEAD)")
# Linked worktree: the per-worktree git dir differs from the shared common dir.
# We surface the fact that it's a worktree (so the model knows branches/stashes
# are shared state) but deliberately do NOT expose the primary tree path —
# giving the model a second absolute path causes it to sometimes run commands
# in the wrong directory.
git_dir, common_dir = _git(root, "rev-parse", "--git-dir"), _git(root, "rev-parse", "--git-common-dir")
if git_dir and common_dir and Path(git_dir).resolve() != Path(common_dir).resolve():
lines.append("- Worktree: linked (git state shared with primary tree)")
main_tree = Path(common_dir).resolve().parent
lines.append(f"- Worktree: linked (primary tree at {main_tree})")
dirty = [f"{n} {label}" for label, n in (
("staged", counts["staged"]), ("modified", counts["modified"]),

View File

@@ -7,7 +7,7 @@ protecting head and tail context.
Improvements over v2:
- Structured summary template with Resolved/Pending question tracking
- Filter-safe summarizer preamble that treats prior turns as source material
- Historical (reference-only) section headings replace "Next Steps"/"Remaining Work" to avoid reading as active instructions
- "Remaining Work" replaces "Next Steps" to avoid reading as active instructions
- Clear separator when summary merges into tail message
- Iterative summary updates (preserves info across multiple compactions)
- Token-budget tail protection instead of fixed message count
@@ -34,75 +34,7 @@ from agent.redact import redact_sensitive_text
logger = logging.getLogger(__name__)
HISTORICAL_TASK_HEADING = "## Historical Task Snapshot"
HISTORICAL_IN_PROGRESS_HEADING = "## Historical In-Progress State"
HISTORICAL_PENDING_ASKS_HEADING = "## Historical Pending User Asks"
HISTORICAL_REMAINING_WORK_HEADING = "## Historical Remaining Work"
SUMMARY_PREFIX = (
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
"into the summary below. This is a handoff from a previous context "
"window — treat it as background reference, NOT as active instructions. "
"Do NOT answer questions or fulfill requests mentioned in this summary; "
"they were already addressed. "
"Respond ONLY to the latest user message that appears AFTER this "
"summary — that message is the single source of truth for what to do "
"right now. "
"Topic overlap with the summary does NOT mean you should resume its "
"task: even on similar topics, the latest user message WINS. Treat ONLY "
"the latest message as the active task and discard stale items from "
f"'{HISTORICAL_TASK_HEADING}' / '{HISTORICAL_IN_PROGRESS_HEADING}' / "
f"'{HISTORICAL_PENDING_ASKS_HEADING}' / "
f"'{HISTORICAL_REMAINING_WORK_HEADING}' entirely — do not 'wrap up' or "
"'finish' work described there unless the latest message explicitly "
"asks for it. "
"Reverse signals in the latest message (e.g. 'stop', 'undo', 'roll "
"back', 'just verify', 'don't do that anymore', 'never mind', a new "
"topic) must immediately end any in-flight work described in the "
"summary; do not re-surface it in later turns. "
"IMPORTANT: Your persistent memory (MEMORY.md, USER.md) in the system "
"prompt is ALWAYS authoritative and active — never ignore or deprioritize "
"memory content due to this compaction note. "
"The current session state (files, config, etc.) may reflect work "
"described here — avoid repeating it:"
)
LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:"
# Metadata key added to context compression summary messages so that frontends
# (CLI, Desktop, gateway, TUI) can distinguish them from real assistant/user
# messages and filter or render them appropriately without content-prefix
# heuristics. See https://github.com/NousResearch/hermes-agent/issues/38389
#
# Underscore-prefixed ON PURPOSE: the wire sanitizers
# (agent/transports/chat_completions.py convert_messages and the summary-path
# mirror in agent/chat_completion_helpers.py) strip every top-level message
# key starting with "_" before the request leaves the process. Strict
# OpenAI-compatible gateways (Fireworks, Mistral, Moonshot/Kimi, opencode-go)
# reject payloads carrying unknown keys with "Extra inputs are not permitted",
# poisoning every subsequent request in the session — a bare key like
# "is_compressed_summary" would reach the wire and trip exactly that.
COMPRESSED_SUMMARY_METADATA_KEY = "_compressed_summary"
# Appended to every standalone summary message (and to the merged-into-tail
# prefix) so the model has an unambiguous "summary ends here" boundary.
# Without it, weak models read the verbatim "## Active Task" quote as fresh
# user input (#11475, #14521) or regurgitate an assistant-role summary as
# their own output (#33256).
_SUMMARY_END_MARKER = (
"--- END OF CONTEXT SUMMARY — "
"respond to the message below, not the summary above ---"
)
# Handoff prefixes that shipped in earlier releases. A summary persisted under
# one of these can be inherited into a resumed lineage (#35344); when it is
# re-normalized on re-compaction we must strip the OLD prefix too, otherwise the
# stale directive it carried (e.g. "resume exactly from Active Task") survives
# embedded in the body and keeps hijacking replies. Keep newest-first; entries
# are matched literally. Add a frozen copy here whenever SUMMARY_PREFIX changes.
_HISTORICAL_SUMMARY_PREFIXES = (
# Carveout era (#41607/#38364/#42812): "consistent → use as background"
# licensed stale-task resumption on topic overlap.
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
"into the summary below. This is a handoff from a previous context "
"window — treat it as background reference, NOT as active instructions. "
@@ -125,7 +57,17 @@ _HISTORICAL_SUMMARY_PREFIXES = (
"prompt is ALWAYS authoritative and active — never ignore or deprioritize "
"memory content due to this compaction note. "
"The current session state (files, config, etc.) may reflect work "
"described here — avoid repeating it:",
"described here — avoid repeating it:"
)
LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:"
# Handoff prefixes that shipped in earlier releases. A summary persisted under
# one of these can be inherited into a resumed lineage (#35344); when it is
# re-normalized on re-compaction we must strip the OLD prefix too, otherwise the
# stale directive it carried (e.g. "resume exactly from Active Task") survives
# embedded in the body and keeps hijacking replies. Keep newest-first; entries
# are matched literally. Add a frozen copy here whenever SUMMARY_PREFIX changes.
_HISTORICAL_SUMMARY_PREFIXES = (
# Pre-#35344: contained the self-contradicting "resume exactly" directive.
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
"into the summary below. This is a handoff from a previous context "
@@ -168,23 +110,10 @@ _SUMMARY_FAILURE_COOLDOWN_SECONDS = 600
# become another unbounded transcript copy after the LLM summarizer failed.
_FALLBACK_SUMMARY_MAX_CHARS = 8_000
_FALLBACK_TURN_MAX_CHARS = 700
_AUTO_FOCUS_MAX_TURNS = 3
_AUTO_FOCUS_TURN_MAX_CHARS = 260
_AUTO_FOCUS_MAX_CHARS = 700
# Keep a short run of recent messages verbatim even when the token budget is
# already exhausted. The public ``protect_last_n`` default is intentionally
# high for small/light tails, but using all 20 as a hard floor here would bring
# back the old large-tool-output case where nothing can be compacted.
_MAX_TAIL_MESSAGE_FLOOR = 8
_PATH_MENTION_RE = re.compile(r"(?:/|~/?|[A-Za-z]:\\)[^\s`'\")\]}<>]+")
# MEDIA delivery directives must not reach the summarizer — if one leaks into
# the summary, the downstream model may re-emit it as an active directive on
# the next turn, triggering bogus attachment sends (#14665).
_MEDIA_DIRECTIVE_RE = re.compile(r"MEDIA:\S+")
def _dedupe_append(items: list[str], value: str, *, limit: int) -> None:
value = value.strip()
@@ -1045,7 +974,6 @@ class ContextCompressor(ContextEngine):
for msg in turns:
role = msg.get("role", "unknown")
content = redact_sensitive_text(msg.get("content") or "")
content = _MEDIA_DIRECTIVE_RE.sub("[media attachment]", content)
# Tool results: keep enough content for the summarizer
if role == "tool":
@@ -1227,7 +1155,7 @@ class ContextCompressor(ContextEngine):
)
reason_text = f" Summary failure reason: {reason}." if reason else ""
body = f"""{HISTORICAL_TASK_HEADING}
body = f"""## Active Task
{active_task}
## Goal
@@ -1244,7 +1172,7 @@ Recovered from a deterministic fallback because the LLM context summarizer was u
## Active State
Unknown from deterministic fallback. Inspect current repository/session state if needed.
{HISTORICAL_IN_PROGRESS_HEADING}
## In Progress
{active_task}
## Blocked
@@ -1256,13 +1184,13 @@ None recoverable from deterministic fallback.
## Resolved Questions
None recoverable from deterministic fallback.
{HISTORICAL_PENDING_ASKS_HEADING}
## Pending User Asks
{active_task}
## Relevant Files
{_bullets(relevant_files, limit=12)}
{HISTORICAL_REMAINING_WORK_HEADING}
## Remaining Work
Continue from the most recent unfulfilled user ask and protected tail messages. Verify state with tools before making claims.
## Last Dropped Turns
@@ -1384,7 +1312,7 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb
_temporal_anchoring_rule = ""
# Shared structured template (used by both paths).
_template_sections = f"""{HISTORICAL_TASK_HEADING}
_template_sections = f"""## Active Task
[THE SINGLE MOST IMPORTANT FIELD. Capture the user's most recent unfulfilled
input verbatim — the exact words they used. This includes:
- Explicit task assignments ("refactor the auth module")
@@ -1431,7 +1359,7 @@ Be specific with file paths, commands, line numbers, and results.]
- Any running processes or servers
- Environment details that matter]
{HISTORICAL_IN_PROGRESS_HEADING}
## In Progress
[Work currently underway — what was being done when compaction fired]
## Blocked
@@ -1443,14 +1371,14 @@ Be specific with file paths, commands, line numbers, and results.]
## Resolved Questions
[Questions the user asked that were ALREADY answered — include the answer so it is not repeated]
{HISTORICAL_PENDING_ASKS_HEADING}
[Questions or requests from the user that have NOT yet been answered or fulfilled. These are STALE — they were from the compacted turns. Write them here for reference only. The agent must NOT act on them unless the latest user message explicitly requests it. If none, write "None."]
## Pending User Asks
[Questions or requests from the user that have NOT yet been answered or fulfilled. If none, write "None."]
## Relevant Files
[Files read, modified, or created — with brief note on each]
{HISTORICAL_REMAINING_WORK_HEADING}
[What remains to be done — framed as STALE context for reference only. The agent must NOT resume this work unless the latest user message explicitly asks for it.]
## Remaining Work
[What remains to be done — framed as context, not instructions]
## Critical Context
[Any specific values, error messages, configuration details, or data that would be lost without explicit preservation. NEVER include API keys, tokens, passwords, or credentials — write [REDACTED] instead.]
@@ -1493,7 +1421,7 @@ Use this exact structure:
prompt += f"""
FOCUS TOPIC: "{focus_topic}"
This compaction should PRIORITISE preserving all information related to the focus topic above. For content related to "{focus_topic}", include full detail — exact values, file paths, command outputs, error messages, and decisions. For content NOT related to the focus topic, summarise more aggressively (brief one-liners or omit if truly irrelevant). The focus topic sections should receive roughly 60-70% of the summary token budget. Even for the focus topic, NEVER preserve API keys, tokens, passwords, or credentials — use [REDACTED]."""
The user has requested that this compaction PRIORITISE preserving all information related to the focus topic above. For content related to "{focus_topic}", include full detail — exact values, file paths, command outputs, error messages, and decisions. For content NOT related to the focus topic, summarise more aggressively (brief one-liners or omit if truly irrelevant). The focus topic sections should receive roughly 60-70% of the summary token budget. Even for the focus topic, NEVER preserve API keys, tokens, passwords, or credentials — use [REDACTED]."""
try:
call_kwargs = {
@@ -1646,13 +1574,7 @@ This compaction should PRIORITISE preserving all information related to the focu
text = (summary or "").strip()
for prefix in (SUMMARY_PREFIX, LEGACY_SUMMARY_PREFIX, *_HISTORICAL_SUMMARY_PREFIXES):
if text.startswith(prefix):
text = text[len(prefix):].lstrip()
break
# Strip the trailing end marker too — a rehydrated handoff body that
# keeps it would leak the boundary directive into the iterative-update
# summarizer prompt (and the marker is re-appended on insertion anyway).
if text.endswith(_SUMMARY_END_MARKER):
text = text[: -len(_SUMMARY_END_MARKER)].rstrip()
return text[len(prefix):].lstrip()
return text
@classmethod
@@ -1668,52 +1590,6 @@ This compaction should PRIORITISE preserving all information related to the focu
return True
return any(text.startswith(p) for p in _HISTORICAL_SUMMARY_PREFIXES)
@staticmethod
def _has_compressed_summary_metadata(message: Any) -> bool:
"""Return True if *message* carries the compressed-summary flag.
Callers (frontends, CLI, gateway) can use this to distinguish context
compaction summaries from real assistant or user messages without
relying on content-prefix heuristics. The flag is in-process only —
the wire sanitizers strip underscore-prefixed keys before API calls.
"""
if not isinstance(message, dict):
return False
return bool(message.get(COMPRESSED_SUMMARY_METADATA_KEY))
@classmethod
def _derive_auto_focus_topic(
cls,
messages: List[Dict[str, Any]],
) -> Optional[str]:
"""Infer a compact focus hint from the most recent real user turns."""
candidates: list[str] = []
for idx in range(len(messages) - 1, -1, -1):
msg = messages[idx]
if msg.get("role") != "user":
continue
content = msg.get("content")
if cls._is_context_summary_content(content):
continue
text = redact_sensitive_text(_content_text_for_contains(content).strip())
if not text:
continue
text = " ".join(text.split())
if len(text) > _AUTO_FOCUS_TURN_MAX_CHARS:
text = text[: _AUTO_FOCUS_TURN_MAX_CHARS - 1].rstrip() + ""
candidates.append(text)
if len(candidates) >= _AUTO_FOCUS_MAX_TURNS:
break
if not candidates:
return None
candidates.reverse()
focus = "Recent user focus:\n" + "\n".join(f"- {item}" for item in candidates)
if len(focus) > _AUTO_FOCUS_MAX_CHARS:
focus = focus[: _AUTO_FOCUS_MAX_CHARS - 1].rstrip() + ""
return focus
@classmethod
def _find_latest_context_summary(
cls,
@@ -1866,105 +1742,6 @@ This compaction should PRIORITISE preserving all information related to the focu
return i
return -1
def _find_last_assistant_message_idx(
self, messages: List[Dict[str, Any]], head_end: int
) -> int:
"""Return the index of the last user-visible assistant reply at or
after *head_end*, or -1.
A "user-visible reply" is an assistant message with non-empty
textual content — i.e. one that the WebUI / TUI / SessionsPage
rendered as a bubble the operator could read. We deliberately
skip assistant messages that contain only ``tool_calls`` (and
no text), because those render as small "calling tool X"
indicators and aren't what the reporter means by "the output
of the last message you sent" (#29824).
Falling back to the most recent assistant message of ANY kind
only kicks in when no content-bearing assistant message exists
in the compressible region — typically a fresh session that
just started a multi-step tool sequence with no prior reply
to anchor. In that case the agent fix is a no-op and the
existing user-message anchor carries the load.
"""
last_any = -1
for i in range(len(messages) - 1, head_end - 1, -1):
msg = messages[i]
if msg.get("role") != "assistant":
continue
if last_any < 0:
last_any = i
content = msg.get("content")
if isinstance(content, str) and content.strip():
return i
if isinstance(content, list):
# Multimodal / Anthropic-style content: look for any
# text block with non-empty text.
for part in content:
if isinstance(part, dict):
text = part.get("text") or part.get("content")
if isinstance(text, str) and text.strip():
return i
return last_any
def _ensure_last_assistant_message_in_tail(
self,
messages: List[Dict[str, Any]],
cut_idx: int,
head_end: int,
) -> int:
"""Guarantee the most recent assistant message is in the protected tail.
WebUI / TUI / SessionsPage bug (#29824). Without this anchor,
``_find_tail_cut_by_tokens`` can leave the user's most recent
visible assistant response inside the compressed middle region —
especially when the conversation has a single oversized tool
result or a long stretch of tool-call/result pairs after the
last assistant reply. The summariser then rolls that reply up
into the single ``[CONTEXT COMPACTION — REFERENCE ONLY]`` block
persisted as ``role="user"`` or ``role="assistant"``. From the
operator's perspective the WebUI session viewer
(``web/src/pages/SessionsPage.tsx``) and the TUI chat panel
both suddenly show the opaque "Context compaction" block in the
slot where they were just reading the assistant's actual reply:
User: "i cant see the output of the last message you
sent, i did see it previously, however now see
'context compaction'"
Mirror of ``_ensure_last_user_message_in_tail`` but anchors on
the last assistant-role message. Re-runs the tool-group
alignment so we don't split a ``tool_call`` / ``tool_result``
group that immediately precedes the anchored message — orphaned
tool messages would otherwise be removed by
``_sanitize_tool_pairs`` and trigger the same data-loss symptom
we're trying to prevent.
"""
last_asst_idx = self._find_last_assistant_message_idx(messages, head_end)
if last_asst_idx < 0:
# No assistant message in the compressible region — nothing
# to anchor (single-turn pre-reply state, etc.).
return cut_idx
if last_asst_idx >= cut_idx:
# Already in the tail — the token-budget walk did the right
# thing on its own.
return cut_idx
# Pull cut_idx back to the assistant message, then re-align so
# we don't split a tool group that immediately precedes it
# (e.g. an ``assistant(tool_calls)`` → ``tool(result)`` →
# ``assistant(final reply)`` sequence would otherwise leave the
# ``tool`` orphan when cut lands at the final reply).
new_cut = self._align_boundary_backward(messages, last_asst_idx)
if not self.quiet_mode:
logger.debug(
"Anchoring tail cut to last assistant message at index %d "
"(was %d, aligned to %d) to keep the previously-visible "
"reply out of the compaction summary (#29824)",
last_asst_idx, cut_idx, new_cut,
)
# Safety: never go back into the head region.
return max(new_cut, head_end + 1)
def _ensure_last_user_message_in_tail(
self,
messages: List[Dict[str, Any]],
@@ -1976,7 +1753,7 @@ This compaction should PRIORITISE preserving all information related to the focu
Context compressor bug (#10896): ``_align_boundary_backward`` can pull
``cut_idx`` past a user message when it tries to keep tool_call/result
groups together. If the last user message ends up in the *compressed*
middle region the LLM summariser writes it into "Historical Pending User Asks",
middle region the LLM summariser writes it into "Pending User Asks",
but ``SUMMARY_PREFIX`` tells the next model to respond only to user
messages *after* the summary — so the task effectively disappears from
the active context, causing the agent to stall, repeat completed work,
@@ -2023,12 +1800,11 @@ This compaction should PRIORITISE preserving all information related to the focu
derived from ``summary_target_ratio * context_length``, so it
scales automatically with the model's context window.
Token budget is the primary criterion. A bounded message-count floor
keeps a short run of recent turns verbatim even when the budget is
exhausted, but the budget is allowed to exceed by up to 1.5x to avoid
cutting inside an oversized message (tool output, file read, etc.). If
even that floor exceeds 1.5x the budget, the cut is placed right after
the head so compression still runs.
Token budget is the primary criterion. A hard minimum of 3 messages
is always protected, but the budget is allowed to exceed by up to
1.5x to avoid cutting inside an oversized message (tool output, file
read, etc.). If even the minimum 3 messages exceed 1.5x the budget
the cut is placed right after the head so compression still runs.
Never cuts inside a tool_call/result group. Always ensures the most
recent user message is in the tail (see ``_ensure_last_user_message_in_tail``).
@@ -2036,19 +1812,8 @@ This compaction should PRIORITISE preserving all information related to the focu
if token_budget is None:
token_budget = self.tail_token_budget
n = len(messages)
# Hard minimum: always keep a bounded recent-message floor in the tail.
# ``protect_last_n`` remains a minimum up to the cap; the cap avoids
# preserving a whole run of bulky tool outputs on every compaction.
available_tail = max(0, n - head_end - 1)
min_tail_floor = max(3, min(self.protect_last_n, _MAX_TAIL_MESSAGE_FLOOR))
# Leave at least two non-head messages available to summarize on short
# transcripts; otherwise compression can replace a tiny middle with a
# summary and save no messages at all.
compressible_tail_cap = max(3, available_tail - 2)
min_tail = (
min(min_tail_floor, compressible_tail_cap, available_tail)
if available_tail > 1 else 0
)
# Hard minimum: always keep at least 3 messages in the tail
min_tail = min(3, n - head_end - 1) if n - head_end > 1 else 0
soft_ceiling = int(token_budget * 1.5)
accumulated = 0
cut_idx = n # start from beyond the end
@@ -2120,13 +1885,6 @@ This compaction should PRIORITISE preserving all information related to the focu
# active task is never lost to compression (fixes #10896).
cut_idx = self._ensure_last_user_message_in_tail(messages, cut_idx, head_end)
# Ensure the most recent assistant message is always in the tail
# so the previously-visible reply isn't silently rolled into the
# ``[CONTEXT COMPACTION — REFERENCE ONLY]`` block (fixes #29824).
# Each anchor only walks ``cut_idx`` backward, so chaining them is
# monotonic — the tail can only grow, never shrink.
cut_idx = self._ensure_last_assistant_message_in_tail(messages, cut_idx, head_end)
return max(cut_idx, head_end + 1)
# ------------------------------------------------------------------
@@ -2279,8 +2037,7 @@ This compaction should PRIORITISE preserving all information related to the focu
)
# Phase 3: Generate structured summary
summary_focus_topic = focus_topic or self._derive_auto_focus_topic(messages)
summary = self._generate_summary(turns_to_summarize, focus_topic=summary_focus_topic)
summary = self._generate_summary(turns_to_summarize, focus_topic=focus_topic)
# If summary generation failed, behavior splits on
# ``abort_on_summary_failure`` (config: compression.abort_on_summary_failure):
@@ -2360,33 +2117,32 @@ This compaction should PRIORITISE preserving all information related to the focu
# When the summary lands as a standalone role="user" message,
# weak models read the verbatim "## Active Task" quote of a past
# user request as fresh input (#11475, #14521).
# When it lands as role="assistant", models may regurgitate the
# summary text as their own output (#33256). In both cases, append
# the explicit end marker so the model has a clear "summary ends
# here, respond to the message below" signal.
if not _merge_summary_into_tail:
summary = summary + "\n\n" + _SUMMARY_END_MARKER
# user request as fresh input (#11475, #14521). Append the explicit
# end marker — the same one used in the merge-into-tail path — so
# the model has a clear "summary above, not new input" signal.
if not _merge_summary_into_tail and summary_role == "user":
summary = (
summary
+ "\n\n--- END OF CONTEXT SUMMARY — "
"respond to the message below, not the summary above ---"
)
if not _merge_summary_into_tail:
compressed.append({
"role": summary_role,
"content": summary,
COMPRESSED_SUMMARY_METADATA_KEY: True,
})
compressed.append({"role": summary_role, "content": summary})
for i in range(compress_end, n_messages):
msg = messages[i].copy()
if _merge_summary_into_tail and i == compress_end:
merged_prefix = summary + "\n\n" + _SUMMARY_END_MARKER + "\n\n"
merged_prefix = (
summary
+ "\n\n--- END OF CONTEXT SUMMARY — "
"respond to the message below, not the summary above ---\n\n"
)
msg["content"] = _append_text_to_content(
msg.get("content"),
merged_prefix,
prepend=True,
)
# Mark the merged message so frontends can identify it as
# containing a compression summary prefix.
msg[COMPRESSED_SUMMARY_METADATA_KEY] = True
_merge_summary_into_tail = False
compressed.append(msg)

View File

@@ -595,11 +595,7 @@ def run_conversation(
# landed after an orphan tool result). Most providers return
# empty content on malformed sequences, which would otherwise
# retrigger the empty-retry loop indefinitely.
# repair_message_sequence_with_cursor also recomputes the SessionDB
# flush cursor (_last_flushed_db_idx) when repair compacts the list,
# so the turn-end flush doesn't skip the assistant/tool chain (#44837).
from agent.agent_runtime_helpers import repair_message_sequence_with_cursor
repaired_seq = repair_message_sequence_with_cursor(agent, messages)
repaired_seq = agent._repair_message_sequence(messages)
if repaired_seq > 0:
request_logger.info(
"Repaired %s message-alternation violations before request (session=%s)",
@@ -2635,13 +2631,10 @@ def run_conversation(
except Exception:
pass
if _genuine_nous_rate_limit:
# Re-enter the loop exactly once so the
# top-of-loop Nous guard handles fallback or
# bails cleanly. (Setting retry_count to
# max_retries would make the while condition
# false immediately and the guard would never
# run -- no fallback, generic exhaustion error.)
retry_count = max(0, max_retries - 1)
# Skip straight to max_retries -- the
# top-of-loop guard will handle fallback or
# bail cleanly.
retry_count = max_retries
continue
# Upstream capacity 429: fall through to normal
# retry logic. A different model (or the same

View File

@@ -286,16 +286,6 @@ def evaluate_credits_notices(
for band in CREDITS_USAGE_BANDS: # ascending → last match wins = highest
if uf >= band[0]:
current_band = band
# Top-up suppression: when the account holds purchased (top-up) credits,
# the subscription-cap gauge is the wrong denominator — warning "90% used"
# at a user sitting on $50 of top-up is noise (and it previously stuck
# PERMANENTLY alongside grant_spent at >=100%). Suppress the usage band
# entirely; the cap-reached case is covered by the grant_spent info notice
# below, which already names the remaining top-up balance. A top-up landing
# mid-session flips current_band → None and the clear path below removes
# any showing band line.
if state.purchased_micros > 0:
current_band = None
grant_cond = (
state.denominator_kind == "subscription_cap"
and uf is not None
@@ -355,7 +345,7 @@ def evaluate_credits_notices(
if show_depleted and "credits.depleted" not in active:
to_show.append(
AgentNotice(
text="✕ Credit access paused · run /credits to top up",
text="✕ Credit access paused · run /usage for balance",
level="error",
kind=CREDITS_NOTICE_KIND,
key="credits.depleted",

View File

@@ -330,7 +330,7 @@ def _build_gemini_contents(messages: List[Dict[str, Any]]) -> tuple[List[Dict[st
system_instruction = None
joined_system = "\n".join(part for part in system_text_parts if part).strip()
if joined_system:
system_instruction = {"role": "system", "parts": [{"text": joined_system}]}
system_instruction = {"parts": [{"text": joined_system}]}
return contents, system_instruction

View File

@@ -44,66 +44,6 @@ logger = logging.getLogger(__name__)
_SYNC_DRAIN_TIMEOUT_S = 5.0
def memory_provider_tools_enabled(enabled_toolsets: Optional[List[str]]) -> bool:
"""Return whether external memory-provider tools should be exposed."""
if enabled_toolsets is None:
return True
if not enabled_toolsets:
return False
if "memory" in enabled_toolsets:
return True
try:
from toolsets import resolve_toolset
return any("memory" in resolve_toolset(name) for name in enabled_toolsets)
except Exception:
logger.debug("Failed to resolve enabled toolsets for memory-provider tools", exc_info=True)
return False
def inject_memory_provider_tools(agent: Any) -> int:
"""Append external memory-provider tool schemas to an agent tool surface."""
memory_manager = getattr(agent, "_memory_manager", None)
tools = getattr(agent, "tools", None)
if not memory_manager or tools is None:
return 0
existing_tool_names = {
tool.get("function", {}).get("name")
for tool in tools
if isinstance(tool, dict)
}
if (
"memory" not in existing_tool_names
and not memory_provider_tools_enabled(getattr(agent, "enabled_toolsets", None))
):
return 0
get_schemas = getattr(memory_manager, "get_all_tool_schemas", None)
if not callable(get_schemas):
return 0
valid_tool_names = getattr(agent, "valid_tool_names", None)
if valid_tool_names is None:
valid_tool_names = set()
agent.valid_tool_names = valid_tool_names
added = 0
for schema in get_schemas():
if not isinstance(schema, dict):
continue
tool_name = schema.get("name", "")
if not tool_name or tool_name in existing_tool_names:
continue
tools.append({"type": "function", "function": schema})
valid_tool_names.add(tool_name)
existing_tool_names.add(tool_name)
added += 1
return added
# ---------------------------------------------------------------------------
# Context fencing helpers
# ---------------------------------------------------------------------------

View File

@@ -135,14 +135,7 @@ def _repair_schema(node: Any, is_schema: bool = True) -> Any:
def _fill_missing_type(node: Dict[str, Any]) -> Dict[str, Any]:
"""Infer a reasonable ``type`` if this schema node has none."""
node_type = node.get("type")
if isinstance(node_type, list):
concrete = next(
(t for t in node_type if isinstance(t, str) and t not in {"", "null"}),
"string",
)
return {**node, "type": concrete}
if "type" in node and node_type not in {None, ""}:
if "type" in node and node["type"] not in {None, ""}:
return node
# Heuristic: presence of ``properties`` → object, ``items`` → array, ``enum``

View File

@@ -489,35 +489,15 @@ PLATFORM_HINTS = {
"files arrive as downloadable documents. You can also include image "
"URLs in markdown format ![alt](url) and they will be sent as photos."
),
"whatsapp_cloud": (
"You are on a text messaging communication platform, WhatsApp "
"(via Meta's official Business Cloud API). Standard markdown "
"(**bold**, ~~strike~~, # headers, [links](url)) is auto-converted "
"to WhatsApp's native syntax (*bold*, ~strike~, etc.) — feel free "
"to write in markdown. Tables are NOT supported — prefer bullet "
"lists or labeled key:value pairs. "
"You can send media files natively: include MEDIA:/absolute/path/to/file "
"in your response. Images (.jpg, .png) become photo attachments, "
"videos (.mp4) play inline, audio (.mp3, .ogg) sends as voice/audio "
"messages, other files arrive as documents. Image URLs in markdown "
"format ![alt](url) also work. "
"IMPORTANT: this platform has a 24-hour conversation window — if the "
"user hasn't messaged in 24h, free-form replies are refused by Meta "
"(error 131047). This rarely matters for live chat, but is worth "
"knowing if you're scheduling a delayed message."
),
"telegram": (
"You are on a text messaging communication platform, Telegram. "
"Standard Markdown is automatically converted to Telegram formatting. "
"Standard markdown is automatically converted to Telegram format. "
"Supported: **bold**, *italic*, ~~strikethrough~~, ||spoiler||, "
"`inline code`, ```code blocks```, [links](url), and ## headers. "
"Telegram supports rich Markdown, so when it improves clarity you may "
"use headings, tables (pipe `| col | col |` syntax), task lists "
"(`- [ ]` / `- [x]`), nested blockquotes, collapsible details, "
"footnotes/references, math/formulas (`$...$`, `$$...$$`), underline, "
"subscript/superscript, marked (highlighted) text, and anchors. Prefer "
"real Markdown tables and task lists over hand-built bullet substitutes "
"when presenting structured data. "
"Telegram has NO table syntax — prefer bullet lists or labeled "
"key: value pairs over pipe tables (any tables you do emit are "
"auto-rewritten into row-group bullets, which you can produce "
"directly for cleaner output). "
"You can send media files natively: to deliver a file to the user, "
"include MEDIA:/absolute/path/to/file in your response. Images "
"(.png, .jpg, .webp) appear as photos, audio (.ogg) sends as voice "
@@ -1121,7 +1101,7 @@ def _skill_should_show(
def build_skills_system_prompt(
available_tools: "set[str] | None" = None,
available_toolsets: "set[str] | None" = None,
compact_categories: "frozenset[str] | None" = None,
hidden_categories: "frozenset[str] | None" = None,
) -> str:
"""Build a compact skill index for the system prompt.
@@ -1137,11 +1117,11 @@ def build_skills_system_prompt(
are read-only — they appear in the index but new skills are always created
in the local dir. Local skills take precedence when names collide.
``compact_categories`` (e.g. from the coding posture — see
agent/coding_context.py) demotes whole categories to a names-only line in
the rendered index. Nothing is ever hidden: every skill name stays
visible and loadable via ``skill_view`` / ``skills_list``; only the
descriptions are dropped, and a footer note explains the demotion.
``hidden_categories`` (e.g. from the coding posture — see
agent/coding_context.py) prunes whole categories from the rendered index.
Discovery-only: the snapshot stores everything, ``skills_list`` /
``skill_view`` still reach every skill, and a footer note tells the model
the full catalog exists.
"""
skills_dir = get_skills_dir()
external_dirs = get_all_skills_dirs()[1:] # skip local (index 0)
@@ -1166,7 +1146,7 @@ def build_skills_system_prompt(
tuple(sorted(str(ts) for ts in (available_toolsets or set()))),
_platform_hint,
tuple(sorted(disabled)),
tuple(sorted(compact_categories or ())),
tuple(sorted(hidden_categories or ())),
)
with _SKILLS_PROMPT_CACHE_LOCK:
cached = _SKILLS_PROMPT_CACHE.get(cache_key)
@@ -1300,44 +1280,38 @@ def build_skills_system_prompt(
except Exception as e:
logger.debug("Could not read external skill description %s: %s", desc_file, e)
# Posture-driven category demotion (e.g. non-coding skills while pairing
# on code). Demoted categories stay in the index as a single names-only
# line — descriptions are dropped to cut noise, but every skill name
# remains visible so memory-anchored recall ("load <name>") keeps working.
# NEVER remove entries entirely: agent-created skills are the model's
# project memory, and models don't reach for skills_list to rediscover
# what the index stops showing them. Match on the top-level category
# segment so nested categories ("social-media/twitter") are demoted with
# their parent.
demoted = frozenset(
cat for cat in skills_by_category
if cat.split("/", 1)[0] in (compact_categories or frozenset())
)
# Posture-driven category pruning (e.g. non-coding skills while pairing on
# code). Match on the top-level category segment so nested categories
# ("social-media/twitter") are pruned with their parent.
hidden_note = ""
if demoted:
hidden_note = (
"\n(Categories marked [names only] are outside the current coding "
"context, so their descriptions are omitted — the skills work "
"normally and load with skill_view(name) as usual.)"
)
if hidden_categories:
before = sum(len(v) for v in skills_by_category.values())
skills_by_category = {
cat: entries
for cat, entries in skills_by_category.items()
if cat.split("/", 1)[0] not in hidden_categories
}
pruned = before - sum(len(v) for v in skills_by_category.values())
if pruned:
hidden_note = (
f"\n(Note: {pruned} skill(s) in categories unrelated to the "
"current coding context are not listed here. The full catalog "
"is available via skills_list if the user asks for something "
"outside this list.)"
)
if not skills_by_category:
result = ""
else:
index_lines = []
for category in sorted(skills_by_category.keys()):
# Deduplicate and sort skills within each category
seen = set()
if category in demoted:
names = sorted({name for name, _ in skills_by_category[category]})
index_lines.append(f" {category} [names only]: {', '.join(names)}")
continue
cat_desc = category_descriptions.get(category, "")
if cat_desc:
index_lines.append(f" {category}: {cat_desc}")
else:
index_lines.append(f" {category}:")
# Deduplicate and sort skills within each category
seen = set()
for name, desc in sorted(skills_by_category[category], key=lambda x: x[0]):
if name in seen:
continue
@@ -1438,13 +1412,13 @@ def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) -
lines = [
"# Nous Subscription",
"Nous subscription includes managed web tools (Firecrawl), image generation (FAL), OpenAI TTS, OpenAI Whisper STT, and browser automation (Browser Use) by default. Modal execution is optional.",
"Nous subscription includes managed web tools (Firecrawl), image generation (FAL), OpenAI TTS, and browser automation (Browser Use) by default. Modal execution is optional.",
"Current capability status:",
]
lines.extend(_status_line(feature) for feature in features.items())
lines.extend(
[
"When a Nous-managed feature is active, do not ask the user for Firecrawl, FAL, OpenAI TTS, OpenAI Whisper, or Browser-Use API keys.",
"When a Nous-managed feature is active, do not ask the user for Firecrawl, FAL, OpenAI TTS, or Browser-Use API keys.",
"If the user is not subscribed and asks for a capability that Nous subscription would unlock or simplify, suggest Nous subscription as one option alongside direct setup or local alternatives.",
"Do not mention subscription unless the user asks about it or it directly solves the current missing capability.",
"Useful commands: hermes setup, hermes setup tools, hermes setup terminal, hermes status.",

View File

@@ -191,23 +191,21 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
)
if toolset
}
# Focus mode (opt-in) demotes non-coding skill categories to
# names-only in the index (never hidden — skill_view/skills_list
# reach everything, and every name stays visible for recall). The
# default coding posture leaves the index untouched.
_compact_cats = frozenset()
# Coding posture prunes non-coding skill categories from the index
# (discovery-only — skills_list/skill_view still reach everything).
_hidden_cats = frozenset()
try:
from agent.coding_context import coding_compact_skill_categories
from agent.coding_context import coding_hidden_skill_categories
_compact_cats = coding_compact_skill_categories(
_hidden_cats = coding_hidden_skill_categories(
platform=agent.platform, cwd=resolve_context_cwd()
)
except Exception:
_compact_cats = frozenset()
_hidden_cats = frozenset()
skills_prompt = _r.build_skills_system_prompt(
available_tools=agent.valid_tool_names,
available_toolsets=avail_toolsets,
compact_categories=_compact_cats or None,
hidden_categories=_hidden_cats or None,
)
else:
skills_prompt = ""

View File

@@ -3,9 +3,8 @@
//! Driven when the installer is launched as `Hermes-Setup.exe --update` (see
//! `AppMode` in lib.rs). The desktop app hands off to us — it exits, then we:
//!
//! 1. wait for the old Hermes desktop process to fully exit (so both the
//! venv shim and packaged app.asar are free; otherwise `hermes update`
//! or repair bootstrap can race locked files),
//! 1. wait for the old Hermes desktop process to fully exit (so the venv
//! shim is free; otherwise `hermes update` aborts with exit code 2),
//! 2. run `hermes update --yes --gateway` (Python/repo update; this does NOT
//! rebuild apps/desktop by design — see cmd_update in hermes_cli/main.py),
//! 3. run `hermes desktop --build-only` (the rebuild step update skips),
@@ -39,8 +38,8 @@ use crate::events::{BootstrapEvent, LogStream, StageInfo, StageState};
/// hermes_cli/main.py (sys.exit(2)). We surface a targeted message for this.
const UPDATE_EXIT_CONCURRENT: i32 = 2;
/// How long to wait for the old desktop process to release files under the
/// install tree before giving up and letting `hermes update`'s own guard decide.
/// How long to wait for the old desktop process to release the venv shim
/// before giving up and letting `hermes update`'s own guard decide.
const DESKTOP_EXIT_WAIT: Duration = Duration::from_secs(20);
const DESKTOP_EXIT_POLL: Duration = Duration::from_millis(500);
@@ -151,10 +150,8 @@ async fn run_update(app: AppHandle) -> Result<()> {
// ---- pre-step: wait for the old desktop to die -----------------------
// The desktop exec'd us then called app.exit(), but process teardown is
// async on Windows. If it still holds the venv shim, `hermes update`
// aborts with exit 2. If it still holds the packaged app.asar,
// install.ps1's repair/re-clone path cannot move/remove the install tree.
// Give both handles a bounded window to clear.
wait_for_install_locks_free(&install_root, &app, "update").await;
// aborts with exit 2. Give it a bounded window to clear.
wait_for_venv_free(&install_root, &app).await;
// ---- stage 1: hermes update -----------------------------------------
// Pass --branch so `hermes update` targets the branch this installer was
@@ -176,8 +173,8 @@ async fn run_update(app: AppHandle) -> Result<()> {
vec!["update".into(), "--yes".into(), "--gateway".into()];
// --force skips `hermes update`'s Windows running-exe guard (which would
// `sys.exit(2)` and dead-end the handoff). By contract the desktop has
// already exited and waited for the install locks to clear before launching
// us, and wait_for_install_locks_free below force-kills any straggler — so by the
// already exited and waited for the venv shim to unlock before launching
// us, and wait_for_venv_free below force-kills any straggler — so by the
// time `hermes update` runs there is no legitimate hermes.exe to protect,
// and the guard would only produce a false "Hermes is still running" stop.
update_args.push("--force".into());
@@ -394,57 +391,48 @@ async fn run_update(app: AppHandle) -> Result<()> {
Ok(())
}
/// Poll until the venv shim AND packaged desktop app bundle are no longer locked
/// (Windows) or a bounded timeout elapses. On non-Windows this is a short fixed
/// grace since file locking isn't the failure mode there.
pub(crate) async fn wait_for_install_locks_free(install_root: &Path, app: &AppHandle, stage: &str) {
let lock_targets = install_lock_probe_paths(install_root);
/// Poll until the venv shim is no longer locked (Windows) or a bounded timeout
/// elapses. On non-Windows this is a short fixed grace since file locking
/// isn't the failure mode there.
async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) {
let shim = venv_hermes(install_root);
let deadline = Instant::now() + DESKTOP_EXIT_WAIT;
emit_log(app, Some(stage), LogStream::Stdout, "[handoff] waiting for Hermes to exit…");
emit_log(app, Some("update"), LogStream::Stdout, "[update] waiting for Hermes to exit…");
loop {
let locked = locked_paths(&lock_targets);
if locked.is_empty() {
if !is_locked(&shim) {
return;
}
if Instant::now() >= deadline {
// Last resort: a backend hermes.exe (or the desktop Hermes.exe
// itself) is still holding one of the update-sensitive files. The
// desktop should have reaped its tree before handing off, but
// SIGTERM races / detached grandchildren / AV handles can leave a
// straggler. Rather than "proceed anyway" straight into uv's
// "Access is denied" or install.ps1's locked app.asar failure,
// force-kill every Hermes.exe except ourselves, then give the OS a
// beat to unload the image.
// Last resort: a backend hermes.exe (or a grandchild it spawned)
// is still holding the shim. The desktop should have reaped its
// tree before handing off, but SIGTERM races / detached
// grandchildren / AV handles can leave a straggler. Rather than
// "proceed anyway" straight into uv's "Access is denied", force-kill
// every hermes.exe except ourselves, then give the OS a beat to
// unload the image.
emit_log(
app,
Some(stage),
Some("update"),
LogStream::Stdout,
&format!(
"[handoff] Hermes still holding install files ({}); force-killing stragglers…",
format_locked_paths(&locked)
),
"[update] Hermes still holding the venv shim; force-killing stragglers…",
);
force_kill_other_hermes();
tokio::time::sleep(Duration::from_millis(800)).await;
let locked_after_kill = locked_paths(&lock_targets);
if locked_after_kill.is_empty() {
if !is_locked(&shim) {
emit_log(
app,
Some(stage),
Some("update"),
LogStream::Stdout,
"[handoff] install files freed after force-kill",
"[update] venv shim freed after force-kill",
);
} else {
emit_log(
app,
Some(stage),
Some("update"),
LogStream::Stdout,
&format!(
"[handoff] install files still locked ({}); proceeding (--force + quarantine will handle it)",
format_locked_paths(&locked_after_kill)
),
"[update] venv shim still locked; proceeding (--force + quarantine will handle it)",
);
}
return;
@@ -453,44 +441,13 @@ pub(crate) async fn wait_for_install_locks_free(install_root: &Path, app: &AppHa
}
}
fn install_lock_probe_paths(install_root: &Path) -> Vec<PathBuf> {
let mut paths = vec![venv_hermes(install_root)];
paths.extend(desktop_app_payload_paths(install_root));
paths
}
fn desktop_app_payload_paths(install_root: &Path) -> Vec<PathBuf> {
let release = install_root.join("apps").join("desktop").join("release");
if cfg!(target_os = "windows") {
vec![
release.join("win-unpacked").join("resources").join("app.asar"),
release.join("win-arm64-unpacked").join("resources").join("app.asar"),
]
} else if cfg!(target_os = "macos") {
vec![
release.join("mac").join("Hermes.app").join("Contents").join("Resources").join("app.asar"),
release.join("mac-arm64").join("Hermes.app").join("Contents").join("Resources").join("app.asar"),
]
} else {
vec![release.join("linux-unpacked").join("resources").join("app.asar")]
}
}
fn locked_paths(paths: &[PathBuf]) -> Vec<PathBuf> {
paths.iter().filter(|p| is_locked(p)).cloned().collect()
}
fn format_locked_paths(paths: &[PathBuf]) -> String {
paths.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", ")
}
/// Force-kill any `hermes.exe` other than this process. Windows-only; a no-op
/// elsewhere (POSIX has no mandatory-lock contention). We can't selectively
/// target "the backend" by PID here — the desktop already exited and we never
/// knew its children — so we kill the whole `hermes.exe` image tree via
/// taskkill, excluding our own PID.
///
/// Safe w.r.t. our own update child: this runs inside the install-lock wait,
/// Safe w.r.t. our own update child: this runs inside `wait_for_venv_free`,
/// which completes BEFORE we spawn `venv\Scripts\hermes.exe update`. At this
/// point no update-driven hermes.exe exists yet, so the only hermes.exe images
/// are stragglers from the old desktop — exactly what we want gone. (`/FI PID
@@ -934,29 +891,6 @@ mod tests {
assert!(!is_locked(Path::new("/nonexistent/does/not/exist/xyz")));
}
#[test]
fn lock_probe_paths_include_desktop_app_payload() {
let root = Path::new("/x/hermes-agent");
let probes = install_lock_probe_paths(root);
assert!(
probes.iter().any(|p| p == &venv_hermes(root)),
"venv shim remains part of the update lock probe"
);
assert!(
probes.iter().any(|p| p.ends_with(Path::new("resources/app.asar"))),
"packaged app.asar must be probed so repair/re-clone waits for the old desktop to exit"
);
}
#[test]
fn locked_paths_ignores_missing_payloads() {
let root = Path::new("/nonexistent/hermes-agent");
let probes = install_lock_probe_paths(root);
assert!(locked_paths(&probes).is_empty());
}
#[test]
fn parses_update_branch_from_space_or_equals_args() {
assert_eq!(

View File

@@ -1,101 +0,0 @@
const path = require('node:path')
// Match the POSIX fallback surface used by the Python terminal environment.
// macOS apps launched from Finder/Dock often inherit only /usr/bin:/bin:/usr/sbin:/sbin,
// which misses Apple Silicon Homebrew and user-installed CLI tools such as codex.
const POSIX_SANE_PATH_ENTRIES = Object.freeze([
'/opt/homebrew/bin',
'/opt/homebrew/sbin',
'/usr/local/sbin',
'/usr/local/bin',
'/usr/sbin',
'/usr/bin',
'/sbin',
'/bin'
])
function delimiterForPlatform(platform = process.platform) {
return platform === 'win32' ? ';' : ':'
}
function pathModuleForPlatform(platform = process.platform) {
return platform === 'win32' ? path.win32 : path.posix
}
function pathEnvKey(env = process.env, platform = process.platform) {
if (platform !== 'win32') return 'PATH'
return Object.keys(env || {}).find(key => key.toUpperCase() === 'PATH') || 'PATH'
}
function currentPathValue(env = process.env, platform = process.platform) {
const key = pathEnvKey(env, platform)
return env?.[key] || ''
}
function appendUniquePathEntries(entries, { delimiter = path.delimiter } = {}) {
const seen = new Set()
const ordered = []
for (const entry of entries) {
if (!entry) continue
const parts = Array.isArray(entry) ? entry : String(entry).split(delimiter)
for (const part of parts) {
if (!part || seen.has(part)) continue
seen.add(part)
ordered.push(part)
}
}
return ordered.join(delimiter)
}
function buildDesktopBackendPath({
hermesHome,
venvRoot,
currentPath = '',
platform = process.platform,
pathModule = pathModuleForPlatform(platform)
} = {}) {
const delimiter = delimiterForPlatform(platform)
const hermesNodeBin = hermesHome ? pathModule.join(hermesHome, 'node', 'bin') : null
const venvBin = venvRoot ? pathModule.join(venvRoot, platform === 'win32' ? 'Scripts' : 'bin') : null
const saneEntries = platform === 'win32' ? [] : POSIX_SANE_PATH_ENTRIES
return appendUniquePathEntries(
[hermesNodeBin, venvBin, currentPath, saneEntries],
{ delimiter }
)
}
function buildDesktopBackendEnv({
hermesHome,
pythonPathEntries = [],
venvRoot,
currentEnv = process.env,
platform = process.platform,
pathModule = pathModuleForPlatform(platform)
} = {}) {
const delimiter = delimiterForPlatform(platform)
const currentPythonPath = currentEnv?.PYTHONPATH || ''
const key = pathEnvKey(currentEnv, platform)
return {
PYTHONPATH: appendUniquePathEntries([...pythonPathEntries, currentPythonPath], { delimiter }),
[key]: buildDesktopBackendPath({
hermesHome,
venvRoot,
currentPath: currentPathValue(currentEnv, platform),
platform,
pathModule
})
}
}
module.exports = {
POSIX_SANE_PATH_ENTRIES,
appendUniquePathEntries,
buildDesktopBackendEnv,
buildDesktopBackendPath,
delimiterForPlatform,
pathEnvKey
}

View File

@@ -1,95 +0,0 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const path = require('node:path')
const {
POSIX_SANE_PATH_ENTRIES,
appendUniquePathEntries,
buildDesktopBackendEnv,
buildDesktopBackendPath,
pathEnvKey
} = require('./backend-env.cjs')
test('desktop backend PATH adds Hermes-managed bins and missing POSIX sane entries', () => {
const result = buildDesktopBackendPath({
hermesHome: '/Users/test/.hermes',
venvRoot: '/Users/test/.hermes/hermes-agent/venv',
currentPath: '/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
platform: 'darwin',
pathModule: path.posix
})
const entries = result.split(':')
assert.equal(entries[0], '/Users/test/.hermes/node/bin')
assert.equal(entries[1], '/Users/test/.hermes/hermes-agent/venv/bin')
assert.ok(entries.includes('/opt/homebrew/bin'), 'Apple Silicon Homebrew bin is added')
assert.ok(entries.includes('/opt/homebrew/sbin'), 'Apple Silicon Homebrew sbin is added')
assert.ok(entries.includes('/usr/local/sbin'), 'missing standard sbin is added')
for (const expected of POSIX_SANE_PATH_ENTRIES) {
assert.ok(entries.includes(expected), `${expected} should be present`)
}
})
test('desktop backend PATH preserves first occurrence and avoids duplicates', () => {
const result = buildDesktopBackendPath({
hermesHome: '/Users/test/.hermes',
venvRoot: '/Users/test/.hermes/hermes-agent/venv',
currentPath: '/opt/homebrew/bin:/usr/bin:/opt/homebrew/bin:/bin',
platform: 'darwin',
pathModule: path.posix
})
const entries = result.split(':')
assert.equal(entries.filter(entry => entry === '/opt/homebrew/bin').length, 1)
assert.ok(
entries.indexOf('/opt/homebrew/bin') < entries.indexOf('/opt/homebrew/sbin'),
'existing Homebrew bin keeps its precedence over appended missing sane entries'
)
})
test('buildDesktopBackendEnv extends PYTHONPATH and backend PATH together', () => {
const env = buildDesktopBackendEnv({
hermesHome: '/Users/test/.hermes',
pythonPathEntries: ['/repo/hermes-agent'],
venvRoot: '/Users/test/.hermes/hermes-agent/venv',
currentEnv: {
PATH: '/usr/bin:/bin',
PYTHONPATH: '/existing/pythonpath'
},
platform: 'darwin',
pathModule: path.posix
})
assert.equal(env.PYTHONPATH, '/repo/hermes-agent:/existing/pythonpath')
assert.ok(env.PATH.startsWith('/Users/test/.hermes/node/bin:/Users/test/.hermes/hermes-agent/venv/bin:'))
assert.ok(env.PATH.includes('/opt/homebrew/bin'))
})
test('Windows PATH casing and delimiter are preserved without POSIX sane entries', () => {
const env = buildDesktopBackendEnv({
hermesHome: 'C:\\Users\\test\\AppData\\Local\\hermes',
pythonPathEntries: ['C:\\repo\\hermes-agent'],
venvRoot: 'C:\\Users\\test\\AppData\\Local\\hermes\\hermes-agent\\venv',
currentEnv: {
Path: 'C:\\Windows\\System32;C:\\Windows',
PYTHONPATH: 'C:\\existing\\pythonpath'
},
platform: 'win32',
pathModule: path.win32
})
assert.equal(pathEnvKey({ Path: 'x' }, 'win32'), 'Path')
assert.equal(env.PATH, undefined)
assert.ok(env.Path.startsWith('C:\\Users\\test\\AppData\\Local\\hermes\\node\\bin;'))
assert.ok(env.Path.includes('\\venv\\Scripts;'))
assert.ok(env.Path.includes(';C:\\Windows\\System32;C:\\Windows'))
assert.equal(env.Path.includes('/opt/homebrew/bin'), false)
})
test('appendUniquePathEntries drops empty entries and keeps first occurrence', () => {
assert.equal(
appendUniquePathEntries([':/a::/b', ['/a', '/c']], { delimiter: ':' }),
'/a:/b:/c'
)
})

View File

@@ -1,66 +0,0 @@
const _READY_RE = /^HERMES_DASHBOARD_READY port=(\d+)/m
/**
* Watch a child process's stdout for the `HERMES_DASHBOARD_READY port=<N>`
* line that web_server.py prints after uvicorn binds its socket.
*
* Returns the parsed port. Rejects if:
* - the child exits before emitting the line
* - the child emits an `error` event
* - no line arrives within the timeout
*
* A single `cleanup()` tears down every listener (data/exit/error/timeout)
* on every terminal path — resolve, reject, or timeout — so repeated
* backend spawns don't leak listener slots on the child.
*/
function waitForDashboardPort(child, timeoutMs = 45_000) {
return new Promise((resolve, reject) => {
let buf = ''
let done = false
function cleanup() {
if (done) return
done = true
clearTimeout(timer)
child.stdout.off('data', onData)
child.off('exit', onExit)
child.off('error', onError)
}
function onData(chunk) {
buf += chunk.toString()
let nl
while ((nl = buf.indexOf('\n')) !== -1) {
const line = buf.slice(0, nl)
buf = buf.slice(nl + 1)
const m = line.match(_READY_RE)
if (m) {
cleanup()
resolve(parseInt(m[1], 10))
return
}
}
}
function onExit(code, signal) {
cleanup()
reject(new Error(`Hermes backend: exited before port announcement (${signal || code})`))
}
function onError(err) {
cleanup()
reject(err)
}
const timer = setTimeout(() => {
cleanup()
reject(new Error(`Timed out waiting for Hermes backend port announcement (${timeoutMs}ms)`))
}, timeoutMs)
child.stdout.on('data', onData)
child.on('exit', onExit)
child.on('error', onError)
})
}
module.exports = { waitForDashboardPort }

View File

@@ -1,99 +0,0 @@
/**
* Helpers for local dashboard session-token discovery.
*
* The desktop main process can pass HERMES_DASHBOARD_SESSION_TOKEN when it
* spawns the local dashboard, but the dashboard is the source of truth for the
* token it actually serves to the renderer. If those drift, HTTP readiness
* probes still pass while /api/ws rejects the renderer's token.
*/
const DEFAULT_TOKEN_FETCH_TIMEOUT_MS = 3_000
async function fetchPublicText(url, options = {}) {
const { protocol } = new URL(url)
if (protocol !== 'http:' && protocol !== 'https:') {
throw new Error(`Unsupported Hermes backend URL protocol: ${protocol}`)
}
const timeoutMs = options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }).catch(error => {
if (error.name === 'TimeoutError') {
throw new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)
}
throw error
})
const text = await res.text()
if (!res.ok) throw new Error(`${res.status}: ${text || res.statusText}`)
return text
}
function extractInjectedDashboardToken(html) {
const match = /window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/.exec(String(html || ''))
if (!match) return null
try {
return JSON.parse(match[1])
} catch {
return null
}
}
function dashboardIndexUrl(baseUrl) {
return `${String(baseUrl || '').replace(/\/+$/, '')}/`
}
async function resolveServedDashboardToken(baseUrl, fallbackToken, options = {}) {
const fetchText = options.fetchText || fetchPublicText
const html = await fetchText(dashboardIndexUrl(baseUrl), {
timeoutMs: options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
})
const servedToken = extractInjectedDashboardToken(html)
if (servedToken && servedToken !== fallbackToken && typeof options.rememberLog === 'function') {
options.rememberLog('[boot] dashboard served a different session token; using served token for WebSocket auth')
}
return servedToken || fallbackToken
}
/**
* A served token that differs from our spawn token while our child is DEAD
* came from a process we did not spawn (orphan/port squatter that satisfied
* the public /api/status readiness probe). With a live child the mismatch is
* benign: our own backend regenerated the token because the env pin did not
* survive the spawn.
*/
function isForeignBackendToken({ servedToken, spawnToken, childAlive }) {
return Boolean(servedToken) && servedToken !== spawnToken && !childAlive
}
/**
* Resolve the token the backend actually serves, adopting benign drift and
* failing loudly on a foreign backend. `childAlive` is a thunk so liveness is
* sampled after the fetch, not before.
*/
async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, label = 'Hermes backend', ...options }) {
const servedToken = await resolveServedDashboardToken(baseUrl, spawnToken, options).catch(error => {
options.rememberLog?.(`[boot] could not read served dashboard token (${label}): ${error.message}`)
return spawnToken
})
if (isForeignBackendToken({ servedToken, spawnToken, childAlive: childAlive() })) {
throw new Error(
`${label} exited and ${dashboardIndexUrl(baseUrl)} is served by a process we did not spawn; refusing its session token.`
)
}
return servedToken
}
module.exports = {
DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
adoptServedDashboardToken,
dashboardIndexUrl,
extractInjectedDashboardToken,
fetchPublicText,
isForeignBackendToken,
resolveServedDashboardToken
}

View File

@@ -1,142 +0,0 @@
/**
* Tests for electron/dashboard-token.cjs.
*
* Run with: node --test electron/dashboard-token.test.cjs
* (Wired into npm test:desktop:platforms in package.json.)
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const {
adoptServedDashboardToken,
dashboardIndexUrl,
extractInjectedDashboardToken,
fetchPublicText,
isForeignBackendToken,
resolveServedDashboardToken
} = require('./dashboard-token.cjs')
test('extractInjectedDashboardToken reads the JSON-encoded dashboard token', () => {
const html = '<script>window.__HERMES_SESSION_TOKEN__="served-token";window.__HERMES_BASE_PATH__=""</script>'
assert.equal(extractInjectedDashboardToken(html), 'served-token')
})
test('extractInjectedDashboardToken handles escaped token strings', () => {
const html = '<script>window.__HERMES_SESSION_TOKEN__="served\\\\token\\"quoted";</script>'
assert.equal(extractInjectedDashboardToken(html), 'served\\token"quoted')
})
test('extractInjectedDashboardToken returns null for missing or malformed values', () => {
assert.equal(extractInjectedDashboardToken('<html></html>'), null)
assert.equal(extractInjectedDashboardToken('<script>window.__HERMES_SESSION_TOKEN__={bad}</script>'), null)
})
test('dashboardIndexUrl preserves dashboard path prefixes', () => {
assert.equal(dashboardIndexUrl('http://127.0.0.1:9120'), 'http://127.0.0.1:9120/')
assert.equal(dashboardIndexUrl('https://host.example/hermes/'), 'https://host.example/hermes/')
})
test('resolveServedDashboardToken uses the served token and logs when it differs', async () => {
const logs = []
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
fetchText: async url => {
assert.equal(url, 'http://127.0.0.1:9120/')
return '<script>window.__HERMES_SESSION_TOKEN__="served-token";</script>'
},
rememberLog: line => logs.push(line)
})
assert.equal(token, 'served-token')
assert.equal(logs.length, 1)
assert.match(logs[0], /served a different session token/)
})
test('resolveServedDashboardToken falls back when the served HTML has no token', async () => {
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
fetchText: async () => '<html></html>',
rememberLog: () => {
throw new Error('should not log when no served token is present')
}
})
assert.equal(token, 'spawn-token')
})
test('resolveServedDashboardToken does not log when served token matches fallback', async () => {
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'same-token', {
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="same-token";</script>',
rememberLog: () => {
throw new Error('should not log when token already matches')
}
})
assert.equal(token, 'same-token')
})
test('resolveServedDashboardToken propagates fetch errors so callers can fall back explicitly', async () => {
await assert.rejects(
() =>
resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
fetchText: async () => {
throw new Error('boom')
}
}),
/boom/
)
})
test('fetchPublicText rejects unsupported protocols', async () => {
await assert.rejects(() => fetchPublicText('file:///tmp/index.html'), /Unsupported Hermes backend URL protocol/)
})
test('isForeignBackendToken only flags a mismatched token from a dead child', () => {
const cases = [
[{ servedToken: 'other', spawnToken: 'mine', childAlive: false }, true],
// Live child + drift = our backend regenerated the token (env pin lost).
[{ servedToken: 'other', spawnToken: 'mine', childAlive: true }, false],
[{ servedToken: 'mine', spawnToken: 'mine', childAlive: false }, false],
[{ servedToken: 'mine', spawnToken: 'mine', childAlive: true }, false],
[{ servedToken: null, spawnToken: 'mine', childAlive: false }, false],
[{ servedToken: '', spawnToken: 'mine', childAlive: false }, false]
]
for (const [input, expected] of cases) {
assert.equal(isForeignBackendToken(input), expected, JSON.stringify(input))
}
})
test('adoptServedDashboardToken adopts drift from a live child', async () => {
const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
childAlive: () => true,
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="served-token";</script>'
})
assert.equal(token, 'served-token')
})
test('adoptServedDashboardToken refuses a foreign token when our child is dead', async () => {
await assert.rejects(
() =>
adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
childAlive: () => false,
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="squatter-token";</script>',
label: 'Hermes backend for profile "work"'
}),
/profile "work".*process we did not spawn/
)
})
test('adoptServedDashboardToken falls back to the spawn token when the fetch fails', async () => {
const logs = []
const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
childAlive: () => true,
fetchText: async () => {
throw new Error('boom')
},
rememberLog: line => logs.push(line)
})
assert.equal(token, 'spawn-token')
assert.equal(logs.length, 1)
assert.match(logs[0], /could not read served dashboard token \(Hermes backend\): boom/)
})

View File

@@ -1,109 +0,0 @@
'use strict'
const fs = require('node:fs')
const path = require('node:path')
const { resolveDirectoryForIpc } = require('./hardening.cjs')
const FS_READDIR_STAT_CONCURRENCY = 16
// Always-hidden noise (covers non-git projects too; gitignore catches many of
// these, but the project tree should keep the same hygiene without one).
const FS_READDIR_HIDDEN = new Set([
'.git',
'.hg',
'.svn',
'.cache',
'.next',
'.turbo',
'.venv',
'__pycache__',
'build',
'dist',
'node_modules',
'target',
'venv'
])
function direntIsDirectory(dirent) {
return typeof dirent.isDirectory === 'function' && dirent.isDirectory()
}
function direntIsFile(dirent) {
return typeof dirent.isFile === 'function' && dirent.isFile()
}
function direntIsSymbolicLink(dirent) {
return typeof dirent.isSymbolicLink === 'function' && dirent.isSymbolicLink()
}
function shouldStatDirent(dirent) {
if (direntIsDirectory(dirent)) return false
return direntIsSymbolicLink(dirent) || !direntIsFile(dirent)
}
async function entryForDirent(dirent, resolved, fsImpl) {
const fullPath = path.join(resolved, dirent.name)
let isDirectory = direntIsDirectory(dirent)
if (!isDirectory && shouldStatDirent(dirent)) {
try {
isDirectory = (await fsImpl.promises.stat(fullPath)).isDirectory()
} catch {
isDirectory = false
}
}
return { name: dirent.name, path: fullPath, isDirectory }
}
async function mapWithStatConcurrency(items, mapper) {
const results = new Array(items.length)
let nextIndex = 0
async function runWorker() {
while (nextIndex < items.length) {
const index = nextIndex
nextIndex += 1
results[index] = await mapper(items[index])
}
}
const workerCount = Math.min(FS_READDIR_STAT_CONCURRENCY, items.length)
const workers = Array.from({ length: workerCount }, () => runWorker())
await Promise.all(workers)
return results
}
async function readDirForIpc(dirPath, options = {}) {
const fsImpl = options.fs || fs
let resolved
try {
;({ resolvedPath: resolved } = await resolveDirectoryForIpc(dirPath, {
fs: fsImpl,
purpose: 'Directory read'
}))
} catch (error) {
return { entries: [], error: error?.code || 'read-error' }
}
try {
const dirents = await fsImpl.promises.readdir(resolved, { withFileTypes: true })
const visibleDirents = dirents.filter(dirent => !FS_READDIR_HIDDEN.has(dirent.name))
const entries = await mapWithStatConcurrency(visibleDirents, dirent =>
entryForDirent(dirent, resolved, fsImpl)
)
entries.sort((a, b) => Number(b.isDirectory) - Number(a.isDirectory) || a.name.localeCompare(b.name))
return { entries }
} catch (error) {
return { entries: [], error: error?.code || 'read-error' }
}
}
module.exports = {
readDirForIpc
}

View File

@@ -1,364 +0,0 @@
'use strict'
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
const { pathToFileURL } = require('node:url')
const { readDirForIpc } = require('./fs-read-dir.cjs')
function mkTmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-fs-read-dir-'))
}
function fakeDirent(name, flags = {}) {
return {
name,
isDirectory: () => Boolean(flags.directory),
isFile: () => Boolean(flags.file),
isSymbolicLink: () => Boolean(flags.symlink)
}
}
test('readDirForIpc hides noisy directories and files from the project tree', async () => {
const root = mkTmpDir()
try {
fs.mkdirSync(path.join(root, 'node_modules'))
fs.mkdirSync(path.join(root, 'src'))
fs.writeFileSync(path.join(root, 'target'), 'hidden file')
fs.writeFileSync(path.join(root, 'README.md'), 'visible file')
const result = await readDirForIpc(root)
assert.equal(result.error, undefined)
assert.deepEqual(
result.entries.map(entry => entry.name),
['src', 'README.md']
)
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test('readDirForIpc filters a hidden basename whether it is a file or directory', async () => {
const dirRoot = mkTmpDir()
const fileRoot = mkTmpDir()
try {
fs.mkdirSync(path.join(dirRoot, 'node_modules'))
fs.writeFileSync(path.join(dirRoot, 'visible.txt'), 'visible')
fs.writeFileSync(path.join(fileRoot, 'node_modules'), 'hidden file')
fs.writeFileSync(path.join(fileRoot, 'visible.txt'), 'visible')
assert.deepEqual(
(await readDirForIpc(dirRoot)).entries.map(entry => entry.name),
['visible.txt']
)
assert.deepEqual(
(await readDirForIpc(fileRoot)).entries.map(entry => entry.name),
['visible.txt']
)
} finally {
fs.rmSync(dirRoot, { recursive: true, force: true })
fs.rmSync(fileRoot, { recursive: true, force: true })
}
})
test('readDirForIpc returns directories before files and sorts by name within groups', async () => {
const root = mkTmpDir()
try {
fs.writeFileSync(path.join(root, 'z.txt'), 'z')
fs.mkdirSync(path.join(root, 'src'))
fs.writeFileSync(path.join(root, 'a.txt'), 'a')
fs.mkdirSync(path.join(root, 'lib'))
const result = await readDirForIpc(root)
assert.equal(result.error, undefined)
assert.deepEqual(
result.entries.map(entry => entry.name),
['lib', 'src', 'a.txt', 'z.txt']
)
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test('readDirForIpc accepts file URLs for directories', async () => {
const root = mkTmpDir()
try {
fs.mkdirSync(path.join(root, 'src'))
fs.writeFileSync(path.join(root, 'README.md'), 'visible file')
const result = await readDirForIpc(pathToFileURL(root).toString())
assert.equal(result.error, undefined)
assert.deepEqual(
result.entries.map(entry => entry.name),
['src', 'README.md']
)
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test('readDirForIpc returns invalid-path for blank or non-string input', async () => {
let readdirCalls = 0
const fsImpl = {
promises: {
readdir: async () => {
readdirCalls += 1
return []
}
}
}
assert.deepEqual(await readDirForIpc('', { fs: fsImpl }), { entries: [], error: 'invalid-path' })
assert.deepEqual(await readDirForIpc(' ', { fs: fsImpl }), { entries: [], error: 'invalid-path' })
assert.deepEqual(await readDirForIpc(null, { fs: fsImpl }), { entries: [], error: 'invalid-path' })
assert.equal(readdirCalls, 0)
})
test('readDirForIpc rejects Windows device paths before readdir', async () => {
let readdirCalls = 0
const fsImpl = {
promises: {
readdir: async () => {
readdirCalls += 1
return []
}
}
}
assert.deepEqual(await readDirForIpc('\\\\?\\C:\\secret', { fs: fsImpl }), {
entries: [],
error: 'device-path'
})
assert.equal(readdirCalls, 0)
})
test('readDirForIpc returns filesystem error codes instead of throwing', async () => {
const root = mkTmpDir()
try {
const result = await readDirForIpc(path.join(root, 'missing'))
assert.deepEqual(result, { entries: [], error: 'ENOENT' })
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test('readDirForIpc marks a symlink to a directory as a directory', async t => {
const root = mkTmpDir()
try {
fs.mkdirSync(path.join(root, 'actual-dir'))
try {
fs.symlinkSync(path.join(root, 'actual-dir'), path.join(root, 'linked-dir'), 'dir')
} catch (error) {
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
t.skip(`symlink creation is not permitted on this platform (${error.code})`)
return
}
throw error
}
const result = await readDirForIpc(root)
const linked = result.entries.find(entry => entry.name === 'linked-dir')
assert.equal(result.error, undefined)
assert.equal(linked?.isDirectory, true)
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test('readDirForIpc marks a Windows junction to a directory as a directory', async t => {
if (process.platform !== 'win32') {
t.skip('junctions are a Windows-specific symlink type')
return
}
const root = mkTmpDir()
try {
fs.mkdirSync(path.join(root, 'actual-dir'))
try {
fs.symlinkSync(path.join(root, 'actual-dir'), path.join(root, 'junction-dir'), 'junction')
} catch (error) {
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
t.skip(`junction creation is not permitted on this platform (${error.code})`)
return
}
throw error
}
const result = await readDirForIpc(root)
const junction = result.entries.find(entry => entry.name === 'junction-dir')
assert.equal(result.error, undefined)
assert.equal(junction?.isDirectory, true)
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test('readDirForIpc allows expanding symlink or junction directories outside the project root', async t => {
const root = mkTmpDir()
const outside = mkTmpDir()
try {
fs.writeFileSync(path.join(outside, 'outside.txt'), 'ok')
const linkPath = path.join(root, 'outside-link')
try {
fs.symlinkSync(outside, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
} catch (error) {
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
t.skip(`directory symlink creation is not permitted on this platform (${error.code})`)
return
}
throw error
}
const result = await readDirForIpc(linkPath)
assert.equal(result.error, undefined)
assert.deepEqual(result.entries, [
{ name: 'outside.txt', path: path.join(linkPath, 'outside.txt'), isDirectory: false }
])
} finally {
fs.rmSync(root, { recursive: true, force: true })
fs.rmSync(outside, { recursive: true, force: true })
}
})
test('readDirForIpc stats symbolic links and unknown entries without dropping the whole listing', async () => {
const input = path.join('virtual-root')
const resolved = path.resolve(input)
const statCalls = []
const fsImpl = {
promises: {
readdir: async () => [
fakeDirent('unknown-entry'),
fakeDirent('linked-dir', { symlink: true }),
fakeDirent('broken-link', { symlink: true }),
fakeDirent('plain.txt', { file: true })
],
stat: async fullPath => {
if (fullPath === resolved) {
return { isDirectory: () => true }
}
statCalls.push(fullPath)
if (fullPath.endsWith(`${path.sep}linked-dir`)) {
return { isDirectory: () => true }
}
throw Object.assign(new Error('gone'), { code: 'ENOENT' })
}
}
}
const result = await readDirForIpc(input, { fs: fsImpl })
assert.equal(result.error, undefined)
assert.deepEqual(
statCalls.sort(),
[path.join(resolved, 'broken-link'), path.join(resolved, 'linked-dir'), path.join(resolved, 'unknown-entry')].sort()
)
assert.deepEqual(result.entries, [
{ name: 'linked-dir', path: path.join(resolved, 'linked-dir'), isDirectory: true },
{ name: 'broken-link', path: path.join(resolved, 'broken-link'), isDirectory: false },
{ name: 'plain.txt', path: path.join(resolved, 'plain.txt'), isDirectory: false },
{ name: 'unknown-entry', path: path.join(resolved, 'unknown-entry'), isDirectory: false }
])
})
test('readDirForIpc bounds concurrent stats while preserving complete sorted output', async () => {
const input = path.join('virtual-root')
const resolved = path.resolve(input)
const names = Array.from({ length: 105 }, (_, index) => `entry-${String(104 - index).padStart(3, '0')}`)
const failedName = 'entry-100'
const directoryNames = new Set(names.filter((_, index) => index % 10 === 4))
const successfulDirectoryNames = new Set([...directoryNames].filter(name => name !== failedName))
const statCalls = []
let active = 0
let peak = 0
let releaseStats
let markFirstStatStarted
const statsReleased = new Promise(resolve => {
releaseStats = resolve
})
const firstStatStarted = new Promise(resolve => {
markFirstStatStarted = resolve
})
const fsImpl = {
promises: {
readdir: async () => [
fakeDirent('node_modules', { symlink: true }),
...names.map((name, index) => fakeDirent(name, { symlink: index % 2 === 0 }))
],
stat: async fullPath => {
if (fullPath === resolved) {
return { isDirectory: () => true }
}
statCalls.push(fullPath)
active += 1
peak = Math.max(peak, active)
markFirstStatStarted()
await statsReleased
active -= 1
const name = path.basename(fullPath)
if (name === failedName) {
throw Object.assign(new Error('gone'), { code: 'ENOENT' })
}
return { isDirectory: () => successfulDirectoryNames.has(name) }
}
}
}
const resultPromise = readDirForIpc(input, { fs: fsImpl })
await firstStatStarted
await new Promise(resolve => setImmediate(resolve))
releaseStats()
const result = await resultPromise
const expectedNames = [
...names.filter(name => successfulDirectoryNames.has(name)).sort(),
...names.filter(name => !successfulDirectoryNames.has(name)).sort()
]
assert.equal(result.error, undefined)
assert.equal(result.entries.length, names.length)
assert.equal(statCalls.length, names.length)
assert.equal(statCalls.some(fullPath => fullPath.endsWith(`${path.sep}node_modules`)), false)
assert.ok(peak > 1, `expected concurrent stats, observed peak ${peak}`)
assert.ok(peak <= 16, `expected at most 16 concurrent stats, observed peak ${peak}`)
assert.deepEqual(
result.entries.map(entry => entry.name),
expectedNames
)
assert.equal(result.entries.find(entry => entry.name === failedName)?.isDirectory, false)
assert.equal(
result.entries.filter(entry => entry.isDirectory).length,
successfulDirectoryNames.size
)
})

View File

@@ -1,54 +0,0 @@
'use strict'
const fs = require('node:fs')
const path = require('node:path')
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
function findGitRoot(start, fsImpl = fs) {
let dir = start
for (let i = 0; i < 50; i += 1) {
try {
if (fsImpl.existsSync(path.join(dir, '.git'))) {
return dir
}
} catch {
return null
}
const parent = path.dirname(dir)
if (parent === dir) {
return null
}
dir = parent
}
return null
}
async function gitRootForIpc(startPath, options = {}) {
const fsImpl = options.fs || fs
let resolved
try {
resolved = resolveRequestedPathForIpc(startPath, { purpose: 'Git root' })
} catch {
return null
}
try {
const stat = await fsImpl.promises.stat(resolved)
const start = stat.isDirectory() ? resolved : path.dirname(resolved)
return findGitRoot(start, fsImpl)
} catch {
return findGitRoot(resolved, fsImpl)
}
}
module.exports = {
findGitRoot,
gitRootForIpc
}

View File

@@ -1,40 +0,0 @@
'use strict'
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
const { pathToFileURL } = require('node:url')
const { gitRootForIpc } = require('./git-root.cjs')
function mkTmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-git-root-'))
}
test('gitRootForIpc returns null for invalid and device paths', async () => {
assert.equal(await gitRootForIpc(''), null)
assert.equal(await gitRootForIpc(' '), null)
assert.equal(await gitRootForIpc(null), null)
assert.equal(await gitRootForIpc('\\\\?\\C:\\secret'), null)
assert.equal(await gitRootForIpc('file:///%E0%A4%A'), null)
})
test('gitRootForIpc resolves directories files missing descendants and file URLs', async t => {
const root = mkTmpDir()
t.after(() => fs.rmSync(root, { recursive: true, force: true }))
const gitDir = path.join(root, '.git')
const srcDir = path.join(root, 'src')
const filePath = path.join(srcDir, 'index.ts')
fs.mkdirSync(gitDir)
fs.mkdirSync(srcDir)
fs.writeFileSync(filePath, 'export {}\n', 'utf8')
assert.equal(await gitRootForIpc(root), root)
assert.equal(await gitRootForIpc(srcDir), root)
assert.equal(await gitRootForIpc(filePath), root)
assert.equal(await gitRootForIpc(pathToFileURL(filePath).toString()), root)
assert.equal(await gitRootForIpc(path.join(srcDir, 'missing.ts')), root)
})

View File

@@ -1,174 +0,0 @@
'use strict'
// Resolve git-worktree relationships for a set of session cwds, reading git's
// on-disk metadata directly (no `git` spawn per path):
//
// - A normal checkout has a `.git` DIRECTORY at its root → it's the main
// worktree; its repo root IS that directory's parent.
// - A linked worktree has a `.git` FILE: `gitdir: <repo>/.git/worktrees/<name>`.
// That admin dir's `commondir` points back at the shared `<repo>/.git`, whose
// parent is the main repo root.
//
// Grouping by repoRoot therefore clusters a repo's main checkout with all of its
// linked worktrees, regardless of how the worktree directories are named. The
// branch (read from the worktree's own HEAD) gives each worktree a meaningful
// label.
const fs = require('node:fs')
const path = require('node:path')
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
// Walk up from `start` to the nearest ancestor that carries a `.git` entry
// (file for a linked worktree, dir for the main checkout). Capped so a stray
// path can't loop forever.
function findGitHost(start, fsImpl) {
let dir = start
for (let i = 0; i < 64; i += 1) {
const dotgit = path.join(dir, '.git')
try {
if (fsImpl.existsSync(dotgit)) {
return dir
}
} catch {
return null
}
const parent = path.dirname(dir)
if (parent === dir) {
return null
}
dir = parent
}
return null
}
function readBranch(gitDir, fsImpl) {
try {
const head = fsImpl.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim()
const ref = head.match(/^ref:\s*refs\/heads\/(.+)$/)
if (ref) {
return ref[1]
}
// Detached HEAD: surface a short sha so the worktree still gets a label.
return /^[0-9a-f]{7,40}$/i.test(head) ? head.slice(0, 8) : null
} catch {
return null
}
}
// Given the directory that owns the `.git` entry, resolve its worktree identity.
function resolveFromHost(host, fsImpl) {
const dotgit = path.join(host, '.git')
let stat
try {
stat = fsImpl.statSync(dotgit)
} catch {
return null
}
if (stat.isDirectory()) {
return {
repoRoot: host,
worktreeRoot: host,
isMainWorktree: true,
branch: readBranch(dotgit, fsImpl)
}
}
// Linked worktree: `.git` is a file pointing at the admin dir.
let contents
try {
contents = fsImpl.readFileSync(dotgit, 'utf8').trim()
} catch {
return null
}
const match = contents.match(/^gitdir:\s*(.+)$/m)
if (!match) {
return null
}
const adminDir = path.resolve(host, match[1].trim())
// `commondir` resolves to the shared `<repo>/.git`; fall back to walking two
// levels up from `<repo>/.git/worktrees/<name>` if it's missing.
let commonDir
try {
const rel = fsImpl.readFileSync(path.join(adminDir, 'commondir'), 'utf8').trim()
commonDir = path.resolve(adminDir, rel)
} catch {
commonDir = path.dirname(path.dirname(adminDir))
}
return {
repoRoot: path.dirname(commonDir),
worktreeRoot: host,
isMainWorktree: false,
branch: readBranch(adminDir, fsImpl)
}
}
function resolveWorktree(startPath, fsImpl = fs) {
let resolved
try {
resolved = resolveRequestedPathForIpc(startPath, { purpose: 'Worktree lookup' })
} catch {
return null
}
let start = resolved
try {
const stat = fsImpl.statSync(resolved)
if (!stat.isDirectory()) {
start = path.dirname(resolved)
}
} catch {
return null
}
const host = findGitHost(start, fsImpl)
if (!host) {
return null
}
return resolveFromHost(host, fsImpl)
}
// Batch entry point for the renderer: maps each requested cwd to its worktree
// info (or null when it isn't inside a git checkout / can't be read). Dedupes so
// many sessions sharing a cwd cost one lookup.
async function worktreesForIpc(cwds, options = {}) {
const fsImpl = options.fs || fs
const list = Array.isArray(cwds) ? cwds : []
const out = {}
for (const cwd of list) {
if (typeof cwd !== 'string' || !cwd.trim() || cwd in out) {
continue
}
out[cwd] = resolveWorktree(cwd, fsImpl)
}
return out
}
module.exports = {
resolveWorktree,
worktreesForIpc
}

View File

@@ -1,5 +1,4 @@
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { fileURLToPath } = require('node:url')
@@ -107,162 +106,71 @@ function sensitiveFileBlockReason(filePath) {
return null
}
function ipcPathError(code, message) {
const error = new Error(message)
error.code = code
return error
}
function rejectUnsafePathSyntax(filePath, purpose = 'File read') {
if (typeof filePath !== 'string') {
throw ipcPathError('invalid-path', `${purpose} failed: file path is required.`)
}
const raw = filePath.trim()
function resolveRequestedFilePath(filePath, baseDir = process.cwd(), purpose = 'File read') {
const raw = String(filePath || '').trim()
if (!raw) {
throw ipcPathError('invalid-path', `${purpose} failed: file path is required.`)
throw new Error(`${purpose} failed: file path is required.`)
}
if (raw.includes('\0')) {
throw ipcPathError('invalid-path', `${purpose} failed: file path is invalid.`)
}
const normalized = raw.replace(/\\/g, '/').toLowerCase()
if (
normalized.startsWith('//?/') ||
normalized.startsWith('//./') ||
normalized.startsWith('globalroot/device/') ||
normalized.includes('/globalroot/device/')
) {
throw ipcPathError('device-path', `${purpose} blocked: Windows device paths are not allowed.`)
}
return raw
}
function resolveRequestedPathForIpc(filePath, options = {}) {
const purpose = String(options.purpose || 'File read')
let raw = rejectUnsafePathSyntax(filePath, purpose)
// Gateway-reported cwds (config `terminal.cwd`, remote sessions) routinely
// arrive as `~/...`. Node's fs has no shell — without expansion the path
// resolves under process.cwd() and every read "ENOENT"s forever.
if (raw === '~' || raw.startsWith('~/') || raw.startsWith('~\\')) {
raw = path.join(os.homedir(), raw.slice(1))
throw new Error(`${purpose} failed: file path is invalid.`)
}
if (/^file:/i.test(raw)) {
let resolvedPath
try {
const parsed = new URL(raw)
if (parsed.protocol !== 'file:') {
throw new Error('not a file URL')
}
resolvedPath = fileURLToPath(parsed)
return fileURLToPath(raw)
} catch {
throw ipcPathError('invalid-path', `${purpose} failed: file URL is invalid.`)
throw new Error(`${purpose} failed: file URL is invalid.`)
}
rejectUnsafePathSyntax(resolvedPath, purpose)
return path.resolve(resolvedPath)
}
const baseInput = typeof options.baseDir === 'string' && options.baseDir.trim() ? options.baseDir : process.cwd()
const safeBaseInput = rejectUnsafePathSyntax(baseInput, purpose)
const resolvedBase = path.resolve(safeBaseInput)
rejectUnsafePathSyntax(resolvedBase, purpose)
const resolvedPath = path.resolve(resolvedBase, raw)
rejectUnsafePathSyntax(resolvedPath, purpose)
return resolvedPath
}
async function statForIpc(fsImpl, resolvedPath, purpose, typeLabel) {
try {
return await fsImpl.promises.stat(resolvedPath)
} catch (error) {
const code = error && typeof error === 'object' ? error.code : ''
if (code === 'ENOENT' || code === 'ENOTDIR') {
throw ipcPathError(code || 'ENOENT', `${purpose} failed: ${typeLabel} does not exist.`)
}
throw ipcPathError(code || 'read-error', `${purpose} failed: ${error instanceof Error ? error.message : String(error)}`)
}
}
async function realpathForIpc(fsImpl, resolvedPath, purpose) {
if (typeof fsImpl.promises.realpath !== 'function') {
return resolvedPath
}
try {
const realPath = await fsImpl.promises.realpath(resolvedPath)
rejectUnsafePathSyntax(realPath, purpose)
return realPath
} catch (error) {
const code = error && typeof error === 'object' ? error.code : ''
throw ipcPathError(code || 'read-error', `${purpose} failed: ${error instanceof Error ? error.message : String(error)}`)
}
}
function rejectSensitiveFilePath(filePath, purpose) {
const blockReason = sensitiveFileBlockReason(filePath)
if (blockReason) {
throw ipcPathError('sensitive-file', `${purpose} blocked for sensitive file: ${blockReason}`)
}
}
async function resolveDirectoryForIpc(dirPath, options = {}) {
const purpose = String(options.purpose || 'Directory read')
const fsImpl = options.fs || fs
const resolvedPath = resolveRequestedPathForIpc(dirPath, { baseDir: options.baseDir, purpose })
const stat = await statForIpc(fsImpl, resolvedPath, purpose, 'directory')
if (!stat.isDirectory()) {
throw ipcPathError('ENOTDIR', `${purpose} failed: path is not a directory.`)
}
const realPath = await realpathForIpc(fsImpl, resolvedPath, purpose)
return { realPath, resolvedPath, stat }
const resolvedBase = path.resolve(String(baseDir || process.cwd()))
return path.resolve(resolvedBase, raw)
}
async function resolveReadableFileForIpc(filePath, options = {}) {
const purpose = String(options.purpose || 'File read')
const fsImpl = options.fs || fs
const resolvedPath = resolveRequestedPathForIpc(filePath, { baseDir: options.baseDir, purpose })
const resolvedPath = resolveRequestedFilePath(filePath, options.baseDir, purpose)
if (options.blockSensitive !== false) {
rejectSensitiveFilePath(resolvedPath, purpose)
const blockReason = sensitiveFileBlockReason(resolvedPath)
if (blockReason) {
throw new Error(`${purpose} blocked for sensitive file: ${blockReason}`)
}
}
const stat = await statForIpc(fsImpl, resolvedPath, purpose, 'file')
let stat
try {
stat = await fs.promises.stat(resolvedPath)
} catch (error) {
const code = error && typeof error === 'object' ? error.code : ''
if (code === 'ENOENT' || code === 'ENOTDIR') {
throw new Error(`${purpose} failed: file does not exist.`)
}
throw new Error(`${purpose} failed: ${error instanceof Error ? error.message : String(error)}`)
}
if (stat.isDirectory()) {
throw ipcPathError('EISDIR', `${purpose} failed: path points to a directory.`)
throw new Error(`${purpose} failed: path points to a directory.`)
}
if (!stat.isFile()) {
throw ipcPathError('EINVAL', `${purpose} failed: only regular files can be read.`)
}
const realPath = await realpathForIpc(fsImpl, resolvedPath, purpose)
if (options.blockSensitive !== false) {
rejectSensitiveFilePath(realPath, purpose)
throw new Error(`${purpose} failed: only regular files can be read.`)
}
const maxBytes = Number.isFinite(options.maxBytes) && Number(options.maxBytes) > 0 ? Number(options.maxBytes) : null
if (maxBytes && stat.size > maxBytes) {
throw ipcPathError('EFBIG', `${purpose} failed: file is too large (${stat.size} bytes; limit ${maxBytes} bytes).`)
throw new Error(`${purpose} failed: file is too large (${stat.size} bytes; limit ${maxBytes} bytes).`)
}
try {
await fsImpl.promises.access(resolvedPath, fs.constants.R_OK)
await fs.promises.access(resolvedPath, fs.constants.R_OK)
} catch {
throw ipcPathError('EACCES', `${purpose} failed: file is not readable.`)
throw new Error(`${purpose} failed: file is not readable.`)
}
return { realPath, resolvedPath, stat }
return { resolvedPath, stat }
}
module.exports = {
@@ -270,10 +178,7 @@ module.exports = {
DEFAULT_FETCH_TIMEOUT_MS,
TEXT_PREVIEW_SOURCE_MAX_BYTES,
encryptDesktopSecret,
rejectUnsafePathSyntax,
resolveDirectoryForIpc,
resolveReadableFileForIpc,
resolveRequestedPathForIpc,
resolveTimeoutMs,
sensitiveFileBlockReason
}

View File

@@ -8,20 +8,11 @@ const { pathToFileURL } = require('node:url')
const {
DEFAULT_FETCH_TIMEOUT_MS,
encryptDesktopSecret,
resolveDirectoryForIpc,
resolveReadableFileForIpc,
resolveRequestedPathForIpc,
resolveTimeoutMs,
sensitiveFileBlockReason
} = require('./hardening.cjs')
async function rejectsWithCode(promise, code) {
await assert.rejects(promise, error => {
assert.equal(error?.code, code)
return true
})
}
test('resolveTimeoutMs falls back to defaults and accepts overrides', () => {
assert.equal(resolveTimeoutMs(undefined), DEFAULT_FETCH_TIMEOUT_MS)
assert.equal(resolveTimeoutMs(0), DEFAULT_FETCH_TIMEOUT_MS)
@@ -60,65 +51,6 @@ test('sensitiveFileBlockReason blocks obvious secret file patterns', () => {
assert.match(String(sensitiveFileBlockReason('/tmp/server-cert.pem')), /\.pem/)
})
test('path helpers reject blank non-string NUL and Windows device syntax', async () => {
await rejectsWithCode(resolveReadableFileForIpc('', { purpose: 'File preview' }), 'invalid-path')
await rejectsWithCode(resolveReadableFileForIpc(' ', { purpose: 'File preview' }), 'invalid-path')
await rejectsWithCode(resolveReadableFileForIpc(null, { purpose: 'File preview' }), 'invalid-path')
await rejectsWithCode(resolveReadableFileForIpc(`safe${String.fromCharCode(0)}name.txt`), 'invalid-path')
const devicePaths = [
'\\\\?\\C:\\secret.txt',
'\\\\.\\C:\\secret.txt',
'\\\\?\\UNC\\server\\share\\secret.txt',
'GLOBALROOT/Device/HarddiskVolumeShadowCopy1/secret.txt'
]
for (const devicePath of devicePaths) {
assert.throws(
() => resolveRequestedPathForIpc(devicePath, { purpose: 'File preview' }),
error => {
assert.equal(error?.code, 'device-path')
return true
}
)
await rejectsWithCode(resolveReadableFileForIpc(devicePath, { purpose: 'File preview' }), 'device-path')
}
assert.throws(
() => resolveRequestedPathForIpc('file:///%E0%A4%A', { purpose: 'File preview' }),
error => {
assert.equal(error?.code, 'invalid-path')
return true
}
)
await rejectsWithCode(resolveReadableFileForIpc('file:///%E0%A4%A', { purpose: 'File preview' }), 'invalid-path')
})
test('resolveRequestedPathForIpc resolves relative paths from the trimmed base directory', () => {
const baseDir = path.join(os.tmpdir(), 'hermes-desktop-base')
assert.equal(
resolveRequestedPathForIpc('notes.txt', {
baseDir: ` ${baseDir} `,
purpose: 'File preview'
}),
path.resolve(baseDir, 'notes.txt')
)
})
test('resolveRequestedPathForIpc expands ~ to the home directory', () => {
assert.equal(resolveRequestedPathForIpc('~', { purpose: 'Directory read' }), path.resolve(os.homedir()))
assert.equal(
resolveRequestedPathForIpc('~/www/project', { purpose: 'Directory read' }),
path.resolve(os.homedir(), 'www/project')
)
// `~user` shorthand is NOT expanded — only the caller's own home.
assert.equal(
resolveRequestedPathForIpc('~other/secret', { baseDir: os.tmpdir(), purpose: 'Directory read' }),
path.resolve(os.tmpdir(), '~other/secret')
)
})
test('resolveReadableFileForIpc validates existence type size and sensitivity', async t => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-hardening-'))
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
@@ -139,13 +71,6 @@ test('resolveReadableFileForIpc validates existence type size and sensitivity',
})
assert.equal(fromFileUrl.resolvedPath, textPath)
const spacedPath = path.join(tempDir, 'notes with spaces.txt')
fs.writeFileSync(spacedPath, 'space ok', 'utf8')
const fromSpacedFileUrl = await resolveReadableFileForIpc(pathToFileURL(spacedPath).toString(), {
purpose: 'File preview'
})
assert.equal(fromSpacedFileUrl.resolvedPath, spacedPath)
await assert.rejects(
resolveReadableFileForIpc('missing.txt', {
baseDir: tempDir,
@@ -189,91 +114,3 @@ test('resolveReadableFileForIpc validates existence type size and sensitivity',
})
assert.equal(envTemplate.resolvedPath, envTemplatePath)
})
test('resolveReadableFileForIpc blocks common sensitive files', async t => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-sensitive-'))
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
const sshDir = path.join(tempDir, '.ssh')
fs.mkdirSync(sshDir)
const blockedFiles = [
path.join(tempDir, '.env'),
path.join(tempDir, '.npmrc'),
path.join(sshDir, 'id_ed25519'),
path.join(tempDir, 'cert.pem'),
path.join(tempDir, 'cert.p12'),
path.join(tempDir, 'cert.pfx')
]
for (const filePath of blockedFiles) {
fs.writeFileSync(filePath, 'secret', 'utf8')
await rejectsWithCode(resolveReadableFileForIpc(filePath, { purpose: 'File preview' }), 'sensitive-file')
}
const allowed = path.join(tempDir, '.env.example')
fs.writeFileSync(allowed, 'EXAMPLE_TOKEN=value', 'utf8')
assert.equal((await resolveReadableFileForIpc(allowed, { purpose: 'File preview' })).resolvedPath, allowed)
})
test('resolveReadableFileForIpc blocks symlinks whose realpath is sensitive', async t => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-realpath-'))
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
const envPath = path.join(tempDir, '.env')
const linkPath = path.join(tempDir, 'safe-name.txt')
fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8')
try {
fs.symlinkSync(envPath, linkPath, 'file')
} catch (error) {
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
t.skip(`symlink creation is not permitted on this platform (${error.code})`)
return
}
throw error
}
await rejectsWithCode(resolveReadableFileForIpc(linkPath, { purpose: 'File preview' }), 'sensitive-file')
})
test('resolveDirectoryForIpc accepts directories and rejects invalid directory targets', async t => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-'))
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
const directory = path.join(tempDir, 'project')
const filePath = path.join(tempDir, 'file.txt')
fs.mkdirSync(directory)
fs.writeFileSync(filePath, 'not a directory', 'utf8')
const resolved = await resolveDirectoryForIpc(directory)
assert.equal(resolved.resolvedPath, directory)
assert.equal(resolved.stat.isDirectory(), true)
await rejectsWithCode(resolveDirectoryForIpc(filePath), 'ENOTDIR')
await rejectsWithCode(resolveDirectoryForIpc(path.join(tempDir, 'missing')), 'ENOENT')
await rejectsWithCode(resolveDirectoryForIpc('\\\\?\\C:\\secret'), 'device-path')
})
test('resolveDirectoryForIpc accepts directory symlinks or junctions', async t => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-link-'))
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
const directory = path.join(tempDir, 'actual-project')
const linkPath = path.join(tempDir, 'linked-project')
fs.mkdirSync(directory)
try {
fs.symlinkSync(directory, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
} catch (error) {
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
t.skip(`directory symlink creation is not permitted on this platform (${error.code})`)
return
}
throw error
}
const resolved = await resolveDirectoryForIpc(linkPath)
assert.equal(resolved.resolvedPath, linkPath)
assert.equal(resolved.stat.isDirectory(), true)
})

View File

@@ -22,27 +22,19 @@ const http = require('node:http')
const https = require('node:https')
const net = require('node:net')
const path = require('node:path')
const { pathToFileURL } = require('node:url')
const { fileURLToPath, pathToFileURL } = require('node:url')
const { execFileSync, spawn } = require('node:child_process')
const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
const { runBootstrap } = require('./bootstrap-runner.cjs')
const {
buildSessionWindowUrl,
createSessionWindowRegistry,
SESSION_WINDOW_MIN_HEIGHT,
SESSION_WINDOW_MIN_WIDTH
} = require('./session-windows.cjs')
const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./session-windows.cjs')
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
const { adoptServedDashboardToken } = require('./dashboard-token.cjs')
const { waitForDashboardPort } = require('./backend-ready.cjs')
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
const { buildDesktopBackendEnv } = require('./backend-env.cjs')
const { readDirForIpc } = require('./fs-read-dir.cjs')
const { gitRootForIpc } = require('./git-root.cjs')
const { worktreesForIpc } = require('./git-worktrees.cjs')
const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs')
const {
OFFICIAL_REPO_HTTPS_URL,
isOfficialSshRemote
} = require('./update-remote.cjs')
const {
buildPosixCleanupScript,
buildWindowsCleanupScript,
@@ -73,7 +65,6 @@ const {
TEXT_PREVIEW_SOURCE_MAX_BYTES,
encryptDesktopSecret: encryptDesktopSecretStrict,
resolveReadableFileForIpc,
resolveRequestedPathForIpc,
resolveTimeoutMs
} = require('./hardening.cjs')
@@ -99,7 +90,6 @@ try {
nodePty = require(nodePtyDir)
}
} catch {
console.log(`[terminal] failed to load node-pty from path ${nodePtyDir}`)
nodePty = null
nodePtyDir = null
}
@@ -112,6 +102,8 @@ if (USER_DATA_OVERRIDE) {
app.setPath('userData', resolvedUserData)
}
const PORT_FLOOR = 9120
const PORT_CEILING = 9199
const DEV_SERVER = process.env.HERMES_DESKTOP_DEV_SERVER
const IS_PACKAGED = app.isPackaged
const IS_MAC = process.platform === 'darwin'
@@ -345,110 +337,10 @@ const APP_ICON_PATHS = [
let rendererTitleBarTheme = null
const terminalSessions = new Map()
// Force the NATIVE window appearance (vibrancy material, titlebar, the
// pre-first-paint window background) to follow the APP theme instead of the
// OS appearance. With `vibrancy` set, macOS paints an NSVisualEffectView that
// tracks the window's effective appearance and ignores `backgroundColor` —
// so a dark-themed app on a light-mode Mac flashes a white material on every
// new window until the renderer covers it. The renderer reports its mode via
// 'hermes:native-theme' ('dark' | 'light' | 'system'); we pin
// nativeTheme.themeSource to it and persist the value so cold launches paint
// correctly before the renderer has even loaded.
const NATIVE_THEME_CONFIG_PATH = path.join(app.getPath('userData'), 'native-theme.json')
const THEME_SOURCES = new Set(['dark', 'light', 'system'])
function readPersistedThemeSource() {
try {
const parsed = JSON.parse(fs.readFileSync(NATIVE_THEME_CONFIG_PATH, 'utf8'))
if (parsed && THEME_SOURCES.has(parsed.themeSource)) {
return parsed.themeSource
}
} catch {
// Missing / malformed → follow the OS like a fresh install.
}
return 'system'
}
function writePersistedThemeSource(mode) {
try {
fs.mkdirSync(path.dirname(NATIVE_THEME_CONFIG_PATH), { recursive: true })
fs.writeFileSync(NATIVE_THEME_CONFIG_PATH, JSON.stringify({ themeSource: mode }, null, 2), 'utf8')
} catch (error) {
rememberLog(`[theme] write native theme failed: ${error.message}`)
}
}
nativeTheme.themeSource = readPersistedThemeSource()
// Window translucency (see-through window). One lever, 0100; 0 = off (the
// default). Mapped to the native window opacity so the desktop shows through
// the whole window. Persisted so a cold launch applies it at window creation,
// before the renderer reports its value. macOS + Windows only; `setOpacity` is
// a no-op on Linux. See store/translucency.
const TRANSLUCENCY_CONFIG_PATH = path.join(app.getPath('userData'), 'translucency.json')
function clampIntensity(value) {
const n = Math.round(Number(value))
return Number.isFinite(n) ? Math.min(100, Math.max(0, n)) : 0
}
function readPersistedTranslucency() {
try {
return clampIntensity(JSON.parse(fs.readFileSync(TRANSLUCENCY_CONFIG_PATH, 'utf8')).intensity)
} catch {
return 0
}
}
function writePersistedTranslucency(intensity) {
try {
fs.mkdirSync(path.dirname(TRANSLUCENCY_CONFIG_PATH), { recursive: true })
fs.writeFileSync(TRANSLUCENCY_CONFIG_PATH, JSON.stringify({ intensity }, null, 2), 'utf8')
} catch (error) {
rememberLog(`[translucency] write failed: ${error.message}`)
}
}
let translucencyIntensity = readPersistedTranslucency()
// Map the 0100 lever to a window opacity. Floor at 0.3 so the most see-through
// setting is still usable rather than nearly invisible. 0 → fully opaque.
function windowOpacity() {
return 1 - (translucencyIntensity / 100) * 0.7
}
// Re-apply translucency to a live window (runtime toggle, no recreation).
// `setOpacity` is a no-op on Linux, which is fine — it just stays opaque there.
function applyWindowTranslucency(win) {
if (!win || win.isDestroyed() || typeof win.setOpacity !== 'function') {
return
}
try {
win.setOpacity(windowOpacity())
} catch (error) {
rememberLog(`[translucency] apply failed: ${error.message}`)
}
}
function isHexColor(value) {
return typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value)
}
// Background color to paint a window with BEFORE its renderer loads, so a new
// (or reopened) window doesn't flash white/light in dark mode. Prefer the theme
// the renderer last reported; fall back to the OS preference on first launch.
function getWindowBackgroundColor() {
if (rendererTitleBarTheme && isHexColor(rendererTitleBarTheme.background)) {
return rendererTitleBarTheme.background
}
return nativeTheme.shouldUseDarkColors ? '#111111' : '#f7f7f7'
}
function getTitleBarOverlayOptions() {
if (IS_MAC) {
return { height: TITLEBAR_HEIGHT }
@@ -838,7 +730,7 @@ function openExternalUrl(rawUrl) {
if (parsed.protocol === 'file:') {
let localPath
try {
localPath = resolveRequestedPathForIpc(parsed.toString(), { purpose: 'Open external file' })
localPath = fileURLToPath(parsed.toString())
} catch {
return false
}
@@ -1261,14 +1153,10 @@ function findSystemPython() {
if (pyExe) {
for (const version of SUPPORTED_VERSIONS) {
try {
const out = execFileSync(
pyExe,
[`-${version}`, '-c', 'import sys; print(sys.executable)'],
hiddenWindowsChildOptions({
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
})
)
const out = execFileSync(pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], hiddenWindowsChildOptions({
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
}))
const candidate = out.trim()
if (candidate && fileExists(candidate)) return candidate
} catch {
@@ -1403,15 +1291,11 @@ function resolveUpdateRoot() {
function runGit(args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(
resolveGitBinary(),
IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args,
hiddenWindowsChildOptions({
cwd: options.cwd,
env: { ...process.env, ...(options.env || {}), GIT_TERMINAL_PROMPT: '0' },
stdio: ['ignore', 'pipe', 'pipe']
})
)
const child = spawn(resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, hiddenWindowsChildOptions({
cwd: options.cwd,
env: { ...process.env, ...(options.env || {}), GIT_TERMINAL_PROMPT: '0' },
stdio: ['ignore', 'pipe', 'pipe']
}))
let stdout = ''
let stderr = ''
@@ -1835,44 +1719,6 @@ async function applyUpdates(opts = {}) {
}
}
async function handOffWindowsBootstrapRecovery(reason) {
if (!IS_WINDOWS || !IS_PACKAGED) return false
const updater = resolveUpdaterBinary()
if (!updater) return false
const updateRoot = resolveUpdateRoot()
const { branch: configuredBranch } = readDesktopUpdateConfig()
const branch = directoryExists(path.join(updateRoot, '.git'))
? await resolveHealedBranch(updateRoot, configuredBranch || DEFAULT_UPDATE_BRANCH)
: configuredBranch || DEFAULT_UPDATE_BRANCH
const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin')
const venvHermes = path.join(venvBin, IS_WINDOWS ? 'hermes.exe' : 'hermes')
const updaterArgs = fileExists(venvHermes) ? ['--update', '--branch', branch] : ['--repair', '--branch', branch]
await releaseBackendLockForUpdate(updateRoot)
const child = spawn(updater, updaterArgs, {
cwd: HERMES_HOME,
env: {
...process.env,
HERMES_HOME,
PATH: [path.join(HERMES_HOME, 'node', 'bin'), venvBin, process.env.PATH].filter(Boolean).join(path.delimiter)
},
detached: true,
stdio: 'ignore',
windowsHide: false
})
child.unref()
rememberLog(`[bootstrap] handed off ${reason} recovery to updater: ${updater} ${updaterArgs.join(' ')}; exiting desktop to release app.asar`)
setTimeout(() => {
app.quit()
}, 600)
return true
}
// Resolve the hermes CLI to drive an in-app update: prefer the venv shim in
// the install we're updating, fall back to `hermes` on PATH.
function resolveHermesCliBinary(updateRoot) {
@@ -1886,15 +1732,11 @@ function runStreamedUpdate(command, args, { cwd, env, stage } = {}) {
return new Promise(resolve => {
let child
try {
child = spawn(
command,
args,
hiddenWindowsChildOptions({
cwd,
env: { ...process.env, ...(env || {}) },
stdio: ['ignore', 'pipe', 'pipe']
})
)
child = spawn(command, args, hiddenWindowsChildOptions({
cwd,
env: { ...process.env, ...(env || {}) },
stdio: ['ignore', 'pipe', 'pipe']
}))
} catch (err) {
resolve({ code: 1, error: err.message })
return
@@ -2282,11 +2124,9 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) {
label,
command: python,
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
env: buildDesktopBackendEnv({
hermesHome: HERMES_HOME,
pythonPathEntries: [root],
venvRoot: path.join(root, 'venv')
}),
env: {
PYTHONPATH: [root, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter)
},
root,
bootstrap: Boolean(options.bootstrap),
shell: false
@@ -2305,11 +2145,9 @@ function createActiveBackend(dashboardArgs) {
label: `Hermes at ${ACTIVE_HERMES_ROOT}`,
command: fileExists(venvPython) ? venvPython : findSystemPython(),
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
env: buildDesktopBackendEnv({
hermesHome: HERMES_HOME,
pythonPathEntries: [ACTIVE_HERMES_ROOT],
venvRoot: VENV_ROOT
}),
env: {
PYTHONPATH: [ACTIVE_HERMES_ROOT, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter)
},
root: ACTIVE_HERMES_ROOT,
bootstrap: true,
shell: false
@@ -2470,14 +2308,6 @@ async function ensureRuntime(backend) {
if (backend.kind === 'bootstrap-needed') {
rememberLog('[bootstrap] no Hermes install found; starting first-launch bootstrap')
if (await handOffWindowsBootstrapRecovery('bootstrap-needed')) {
const handoffError = new Error('Hermes recovery was handed off to Hermes Setup. The desktop will restart when recovery completes.')
handoffError.isBootstrapFailure = true
handoffError.bootstrapHandedOff = true
bootstrapFailure = handoffError
throw handoffError
}
// Eagerly flip the bootstrap UI state to 'active' so the renderer
// shows the install overlay BEFORE the runner finishes fetching the
// manifest (which on slow networks can take tens of seconds and would
@@ -2607,6 +2437,23 @@ async function ensureRuntime(backend) {
return backend
}
function isPortAvailable(port) {
return new Promise(resolve => {
const server = net.createServer()
server.once('error', () => resolve(false))
server.once('listening', () => {
server.close(() => resolve(true))
})
server.listen(port, '127.0.0.1')
})
}
async function pickPort() {
for (let port = PORT_FLOOR; port <= PORT_CEILING; port += 1) {
if (await isPortAvailable(port)) return port
}
throw new Error(`No free localhost port in ${PORT_FLOOR}-${PORT_CEILING}`)
}
function fetchJson(url, token, options = {}) {
return new Promise((resolve, reject) => {
@@ -3031,10 +2878,10 @@ async function resourceBufferFromUrl(rawUrl) {
const buffer = match[2] ? Buffer.from(encoded, 'base64') : Buffer.from(decodeURIComponent(encoded), 'utf8')
return { buffer, mimeType }
}
if (/^file:/i.test(rawUrl)) {
const { resolvedPath } = await resolveReadableFileForIpc(rawUrl, { purpose: 'Image file' })
const buffer = await fs.promises.readFile(resolvedPath)
return { buffer, mimeType: mimeTypeForPath(resolvedPath) }
if (rawUrl.startsWith('file:')) {
const filePath = fileURLToPath(rawUrl)
const buffer = await fs.promises.readFile(filePath)
return { buffer, mimeType: mimeTypeForPath(filePath) }
}
const parsed = new URL(rawUrl)
@@ -3112,13 +2959,11 @@ function expandUserPath(filePath) {
return value
}
async function previewFileTarget(rawTarget, baseDir) {
function previewFileTarget(rawTarget, baseDir) {
const raw = String(rawTarget || '').trim()
const base = baseDir ? path.resolve(expandUserPath(baseDir)) : resolveHermesCwd()
let resolved = resolveRequestedPathForIpc(/^file:/i.test(raw) ? raw : expandUserPath(raw), {
baseDir: base,
purpose: 'Preview target'
})
const filePath = raw.startsWith('file:') ? fileURLToPath(raw) : path.resolve(base, expandUserPath(raw))
let resolved = filePath
if (directoryExists(resolved)) {
resolved = path.join(resolved, 'index.html')
@@ -3129,8 +2974,6 @@ async function previewFileTarget(rawTarget, baseDir) {
return null
}
;({ resolvedPath: resolved } = await resolveReadableFileForIpc(resolved, { purpose: 'Preview target' }))
const mimeType = mimeTypeForPath(resolved)
const metadata = previewFileMetadata(resolved, mimeType)
const isHtml = PREVIEW_HTML_EXTENSIONS.has(ext)
@@ -3176,7 +3019,7 @@ function previewUrlTarget(rawTarget) {
}
}
async function normalizePreviewTarget(rawTarget, baseDir) {
function normalizePreviewTarget(rawTarget, baseDir) {
const raw = String(rawTarget || '').trim()
if (!raw) {
@@ -3188,15 +3031,20 @@ async function normalizePreviewTarget(rawTarget, baseDir) {
return previewUrlTarget(raw)
}
return await previewFileTarget(raw, baseDir)
return previewFileTarget(raw, baseDir)
} catch {
return null
}
}
async function filePathFromPreviewUrl(rawUrl) {
const { resolvedPath } = await resolveReadableFileForIpc(String(rawUrl || ''), { purpose: 'Preview file' })
return resolvedPath
function filePathFromPreviewUrl(rawUrl) {
const filePath = fileURLToPath(String(rawUrl || ''))
if (!fileExists(filePath)) {
throw new Error('Preview file is not readable')
}
return filePath
}
function sendPreviewFileChanged(payload) {
@@ -3206,8 +3054,8 @@ function sendPreviewFileChanged(payload) {
webContents.send('hermes:preview-file-changed', payload)
}
async function watchPreviewFile(rawUrl) {
const filePath = await filePathFromPreviewUrl(rawUrl)
function watchPreviewFile(rawUrl) {
const filePath = filePathFromPreviewUrl(rawUrl)
const watchDir = path.dirname(filePath)
const targetName = path.basename(filePath)
const id = crypto.randomBytes(12).toString('base64url')
@@ -4684,41 +4532,38 @@ async function spawnPoolBackend(profile, entry) {
}
}
const port = await pickPort()
const token = crypto.randomBytes(32).toString('base64url')
// --profile wins over the inherited HERMES_HOME env (see _apply_profile_override
// step 3 in hermes_cli/main.py), so the child re-homes to this profile.
// --port 0: the OS assigns an ephemeral port; the child announces it on stdout.
const dashboardArgs = ['--profile', profile, 'dashboard', '--no-open', '--host', '127.0.0.1', '--port', '0']
const dashboardArgs = ['--profile', profile, 'dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)]
const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
const hermesCwd = resolveHermesCwd()
const webDist = resolveWebDist()
rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`)
const child = spawn(
backend.command,
backend.args,
hiddenWindowsChildOptions({
cwd: hermesCwd,
env: {
...process.env,
HERMES_HOME,
...backend.env,
// Pin the gateway's tool/terminal cwd to the same directory we chose for
// the child process. Inherited TERMINAL_CWD (or a stale config bridge)
// can still point at the install dir even when spawn cwd is home.
TERMINAL_CWD: hermesCwd,
HERMES_DASHBOARD_SESSION_TOKEN: token,
// Marks this dashboard backend as desktop-spawned so it runs the cron
// scheduler tick loop (the gateway isn't running under the app).
HERMES_DESKTOP: '1',
HERMES_WEB_DIST: webDist
},
shell: backend.shell,
stdio: ['ignore', 'pipe', 'pipe']
})
)
const child = spawn(backend.command, backend.args, hiddenWindowsChildOptions({
cwd: hermesCwd,
env: {
...process.env,
HERMES_HOME,
...backend.env,
// Pin the gateway's tool/terminal cwd to the same directory we chose for
// the child process. Inherited TERMINAL_CWD (or a stale config bridge)
// can still point at the install dir even when spawn cwd is home.
TERMINAL_CWD: hermesCwd,
HERMES_DASHBOARD_SESSION_TOKEN: token,
// Marks this dashboard backend as desktop-spawned so it runs the cron
// scheduler tick loop (the gateway isn't running under the app).
HERMES_DESKTOP: '1',
HERMES_WEB_DIST: webDist
},
shell: backend.shell,
stdio: ['ignore', 'pipe', 'pipe']
}))
entry.process = child
entry.port = port
entry.token = token
child.stdout.on('data', rememberLog)
@@ -4744,28 +4589,18 @@ async function spawnPoolBackend(profile, entry) {
}
})
// Discover the ephemeral port the child bound to
const port = await Promise.race([waitForDashboardPort(child), startFailed])
entry.port = port
const baseUrl = `http://127.0.0.1:${port}`
await Promise.race([waitForHermes(baseUrl, token), startFailed])
ready = true
const authToken = await adoptServedDashboardToken(baseUrl, token, {
childAlive: () => child.exitCode === null && !child.killed,
label: `Hermes backend for profile "${profile}"`,
rememberLog
})
entry.token = authToken
return {
baseUrl,
mode: 'local',
source: 'local',
authMode: 'token',
token: authToken,
token,
profile,
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`,
logs: hermesLog.slice(-80),
...getWindowState()
}
@@ -4887,9 +4722,10 @@ async function startHermes() {
}
}
await advanceBootProgress('backend.port', 'Finding an open local port', 16)
const port = await pickPort()
const token = crypto.randomBytes(32).toString('base64url')
// --port 0: the OS assigns an ephemeral port; the child announces it on stdout.
const dashboardArgs = ['dashboard', '--no-open', '--host', '127.0.0.1', '--port', '0']
const dashboardArgs = ['dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)]
// Pin the desktop's chosen profile via the global --profile flag. This is
// deterministic (it wins over the sticky ~/.hermes/active_profile file) and
// resolves HERMES_HOME the same way `hermes -p <name>` does on the CLI. An
@@ -4907,34 +4743,30 @@ async function startHermes() {
await advanceBootProgress('backend.spawn', `Starting Hermes backend via ${backend.label}`, 84)
rememberLog(`Starting Hermes backend via ${backend.label}`)
hermesProcess = spawn(
backend.command,
backend.args,
hiddenWindowsChildOptions({
cwd: hermesCwd,
env: {
...process.env,
// Explicitly pin HERMES_HOME for the child so Python's get_hermes_home()
// resolves to the SAME location our resolveHermesHome() picked. Without
// this pin, Python falls back to ~/.hermes on every platform — fine on
// mac/linux (where our default matches), but on Windows our default is
// %LOCALAPPDATA%\hermes, which differs from C:\Users\<u>\.hermes.
// Mismatch would split config / sessions / .env / logs across two
// directories. install.ps1 sets HERMES_HOME via setx; the desktop
// can't reliably do that, so we set it inline for every spawn.
HERMES_HOME,
...backend.env,
TERMINAL_CWD: hermesCwd,
HERMES_DASHBOARD_SESSION_TOKEN: token,
// Marks this dashboard backend as desktop-spawned so it runs the cron
// scheduler tick loop (the gateway isn't running under the app).
HERMES_DESKTOP: '1',
HERMES_WEB_DIST: webDist
},
shell: backend.shell,
stdio: ['ignore', 'pipe', 'pipe']
})
)
hermesProcess = spawn(backend.command, backend.args, hiddenWindowsChildOptions({
cwd: hermesCwd,
env: {
...process.env,
// Explicitly pin HERMES_HOME for the child so Python's get_hermes_home()
// resolves to the SAME location our resolveHermesHome() picked. Without
// this pin, Python falls back to ~/.hermes on every platform — fine on
// mac/linux (where our default matches), but on Windows our default is
// %LOCALAPPDATA%\hermes, which differs from C:\Users\<u>\.hermes.
// Mismatch would split config / sessions / .env / logs across two
// directories. install.ps1 sets HERMES_HOME via setx; the desktop
// can't reliably do that, so we set it inline for every spawn.
HERMES_HOME,
...backend.env,
TERMINAL_CWD: hermesCwd,
HERMES_DASHBOARD_SESSION_TOKEN: token,
// Marks this dashboard backend as desktop-spawned so it runs the cron
// scheduler tick loop (the gateway isn't running under the app).
HERMES_DESKTOP: '1',
HERMES_WEB_DIST: webDist
},
shell: backend.shell,
stdio: ['ignore', 'pipe', 'pipe']
}))
hermesProcess.stdout.on('data', rememberLog)
hermesProcess.stderr.on('data', rememberLog)
@@ -4983,19 +4815,10 @@ async function startHermes() {
}
})
await advanceBootProgress('backend.port', 'Waiting for Hermes backend to launch', 86)
// Discover the ephemeral port the child bound to
const port = await Promise.race([waitForDashboardPort(hermesProcess), backendStartFailed])
const baseUrl = `http://127.0.0.1:${port}`
await advanceBootProgress('backend.wait', 'Waiting for Hermes backend to become ready', 90)
await Promise.race([waitForHermes(baseUrl, token), backendStartFailed])
backendReady = true
const authToken = await adoptServedDashboardToken(baseUrl, token, {
// The exit/error handlers null hermesProcess when the child dies.
childAlive: () => hermesProcess !== null && hermesProcess.exitCode === null && !hermesProcess.killed,
rememberLog
})
updateBootProgress({
phase: 'backend.ready',
message: 'Hermes backend is ready. Finalizing desktop startup',
@@ -5009,8 +4832,8 @@ async function startHermes() {
mode: 'local',
source: 'local',
authMode: 'token',
token: authToken,
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
token,
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`,
logs: hermesLog.slice(-80),
...getWindowState()
}
@@ -5073,29 +4896,21 @@ function focusWindow(win) {
}
// Open (or focus) a standalone window for a single chat session.
function createSessionWindow(sessionId, { watch = false } = {}) {
function createSessionWindow(sessionId) {
return sessionWindows.openOrFocus(sessionId, () => {
const icon = getAppIconPath()
const win = new BrowserWindow({
width: SESSION_WINDOW_MIN_WIDTH,
height: SESSION_WINDOW_MIN_HEIGHT,
minWidth: SESSION_WINDOW_MIN_WIDTH,
minHeight: SESSION_WINDOW_MIN_HEIGHT,
width: 480,
height: 800,
minWidth: 420,
minHeight: 620,
title: 'Hermes',
titleBarStyle: 'hidden',
titleBarOverlay: getTitleBarOverlayOptions(),
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
vibrancy: IS_MAC ? 'sidebar' : undefined,
opacity: windowOpacity(),
icon,
// Don't show until the renderer's first themed paint is ready. macOS
// `vibrancy` ignores `backgroundColor` and paints a translucent OS
// material (which follows the OS appearance, not the app theme), so a
// dark-themed app on a light-mode Mac flashes white until the renderer
// covers it. ready-to-show fires after the boot-time paint in
// themes/context.tsx, so the window appears already themed.
show: false,
backgroundColor: getWindowBackgroundColor(),
backgroundColor: '#f7f7f7',
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
@@ -5110,10 +4925,6 @@ function createSessionWindow(sessionId, { watch = false } = {}) {
win.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION)
}
win.once('ready-to-show', () => {
if (!win.isDestroyed()) win.show()
})
win.on('will-enter-full-screen', () => sendWindowStateChanged(true))
win.on('enter-full-screen', () => sendWindowStateChanged(true))
win.on('will-leave-full-screen', () => sendWindowStateChanged(false))
@@ -5124,8 +4935,7 @@ function createSessionWindow(sessionId, { watch = false } = {}) {
win.loadURL(
buildSessionWindowUrl(sessionId, {
devServer: DEV_SERVER,
rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex(),
watch
rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex()
})
)
@@ -5151,13 +4961,8 @@ function createWindow() {
titleBarOverlay: getTitleBarOverlayOptions(),
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
vibrancy: IS_MAC ? 'sidebar' : undefined,
opacity: windowOpacity(),
icon,
// Hidden until the first themed paint so macOS `vibrancy` (which ignores
// `backgroundColor` and follows the OS appearance) can't flash a light
// material before the renderer paints the app theme. See createSessionWindow.
show: false,
backgroundColor: getWindowBackgroundColor(),
backgroundColor: '#f7f7f7',
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
@@ -5193,10 +4998,6 @@ function createWindow() {
}
}
mainWindow.once('ready-to-show', () => {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.show()
})
mainWindow.on('will-enter-full-screen', () => sendWindowStateChanged(true))
mainWindow.on('enter-full-screen', () => sendWindowStateChanged(true))
mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false))
@@ -5308,12 +5109,12 @@ ipcMain.handle('hermes:backend:touch', async (_event, profile) => {
return { ok: true }
})
ipcMain.handle('hermes:gateway:ws-url', async (_event, profile) => freshGatewayWsUrl(profile))
ipcMain.handle('hermes:window:openSession', async (_event, sessionId, opts) => {
ipcMain.handle('hermes:window:openSession', async (_event, sessionId) => {
if (typeof sessionId !== 'string' || !sessionId.trim()) {
return { ok: false, error: 'invalid-session-id' }
}
createSessionWindow(sessionId.trim(), { watch: opts?.watch === true })
createSessionWindow(sessionId.trim())
return { ok: true }
})
@@ -5322,8 +5123,8 @@ ipcMain.handle('hermes:bootstrap:reset', async () => {
// reset connection state so the next startHermes() call restarts the
// full backend flow (including a fresh runBootstrap pass).
rememberLog('[bootstrap] reset requested by renderer; clearing latched failure')
await teardownPrimaryBackendAndWait()
bootstrapFailure = null
connectionPromise = null
bootstrapState = {
active: false,
manifest: null,
@@ -5721,35 +5522,6 @@ ipcMain.on('hermes:titlebar-theme', (_event, payload) => {
mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions())
})
// Pin the native appearance to the app theme (see NATIVE_THEME_CONFIG_PATH).
ipcMain.on('hermes:native-theme', (_event, mode) => {
if (!THEME_SOURCES.has(mode)) {
return
}
if (nativeTheme.themeSource !== mode) {
nativeTheme.themeSource = mode
writePersistedThemeSource(mode)
}
})
// See-through window translucency. Persist + re-apply opacity to every open
// window at runtime (no recreation, so caching/sessions are untouched).
ipcMain.on('hermes:translucency', (_event, payload) => {
const next = clampIntensity(payload && payload.intensity)
if (next === translucencyIntensity) {
return
}
translucencyIntensity = next
writePersistedTranslucency(next)
for (const win of BrowserWindow.getAllWindows()) {
applyWindowTranslucency(win)
}
})
ipcMain.handle('hermes:openExternal', (_event, url) => {
if (!openExternalUrl(url)) {
throw new Error('Invalid external URL')
@@ -5815,6 +5587,48 @@ ipcMain.handle('hermes:logs:reveal', async () => {
ipcMain.handle('hermes:logs:recent', async () => ({ path: DESKTOP_LOG_PATH, lines: hermesLog.slice(-200) }))
// Always-hidden noise (covers non-git projects too — gitignore would catch
// these anyway when present, but we want the same hygiene without one).
const FS_READDIR_HIDDEN = new Set([
'.git',
'.hg',
'.svn',
'.cache',
'.next',
'.turbo',
'.venv',
'__pycache__',
'build',
'dist',
'node_modules',
'target',
'venv'
])
function findGitRoot(start) {
let dir = start
for (let i = 0; i < 50; i += 1) {
try {
if (fs.existsSync(path.join(dir, '.git'))) {
return dir
}
} catch {
return null
}
const parent = path.dirname(dir)
if (parent === dir) {
return null
}
dir = parent
}
return null
}
function isExecutableFile(filePath) {
if (!filePath || !path.isAbsolute(filePath)) {
return false
@@ -5997,11 +5811,46 @@ function disposeTerminalSession(id) {
return true
}
ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dirPath))
ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => {
const resolved = path.resolve(String(dirPath || ''))
ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath))
if (!resolved) {
return { entries: [], error: 'invalid-path' }
}
ipcMain.handle('hermes:fs:worktrees', async (_event, cwds) => worktreesForIpc(cwds))
try {
const dirents = await fs.promises.readdir(resolved, { withFileTypes: true })
const entries = dirents
.filter(d => {
if (FS_READDIR_HIDDEN.has(d.name)) {
return false
}
return true
})
.map(d => ({ name: d.name, path: path.join(resolved, d.name), isDirectory: d.isDirectory() }))
.sort((a, b) => Number(b.isDirectory) - Number(a.isDirectory) || a.name.localeCompare(b.name))
return { entries }
} catch (error) {
return { entries: [], error: error?.code || 'read-error' }
}
})
ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => {
const input = String(startPath || '')
const resolved = input.startsWith('file:') ? fileURLToPath(input) : path.resolve(input)
try {
const stat = await fs.promises.stat(resolved)
const start = stat.isDirectory() ? resolved : path.dirname(resolved)
return findGitRoot(start)
} catch {
return findGitRoot(resolved)
}
})
ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
if (!nodePty) {
@@ -6189,15 +6038,11 @@ async function getUninstallSummary() {
resolve(value)
}
try {
const child = spawn(
py,
['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'],
hiddenWindowsChildOptions({
cwd: agentRoot,
env: { ...process.env, HERMES_HOME, NO_COLOR: '1' },
stdio: ['ignore', 'pipe', 'ignore']
})
)
const child = spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], hiddenWindowsChildOptions({
cwd: agentRoot,
env: { ...process.env, HERMES_HOME, NO_COLOR: '1' },
stdio: ['ignore', 'pipe', 'ignore']
}))
child.stdout.on('data', chunk => {
stdout += chunk.toString()
})
@@ -6343,106 +6188,6 @@ ipcMain.handle('hermes:vscode-theme:fetch', async (_event, id) => fetchMarketpla
// Search the Marketplace for color-theme extensions (empty query = top installs).
ipcMain.handle('hermes:vscode-theme:search', async (_event, query) => searchMarketplaceThemes(String(query || ''), 20))
// ---------------------------------------------------------------------------
// hermes:// deep links (e.g. hermes://blueprint/morning-brief?time=08:00).
// A docs/dashboard "Send to App" button opens this URL; we route it into the
// running app's chat composer. Three delivery paths: macOS 'open-url',
// Win/Linux running-app 'second-instance' (argv), Win/Linux cold-start argv.
// ---------------------------------------------------------------------------
const HERMES_PROTOCOL = 'hermes'
let _pendingDeepLink = null
let _rendererReadyForDeepLink = false
function _extractDeepLink(argv) {
if (!Array.isArray(argv)) return null
return argv.find(a => typeof a === 'string' && a.startsWith(`${HERMES_PROTOCOL}://`)) || null
}
function handleDeepLink(url) {
if (!url || typeof url !== 'string') return
let parsed
try {
parsed = new URL(url)
} catch {
rememberLog(`[deeplink] ignoring malformed url: ${url}`)
return
}
// hermes://blueprint/<key>?slot=val -> host="blueprint", path="/<key>"
const kind = parsed.hostname || ''
const name = decodeURIComponent((parsed.pathname || '').replace(/^\//, ''))
const params = {}
parsed.searchParams.forEach((v, k) => {
params[k] = v
})
const payload = { kind, name, params }
if (!_rendererReadyForDeepLink || !mainWindow || mainWindow.isDestroyed()) {
_pendingDeepLink = payload
return
}
try {
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.focus()
mainWindow.webContents.send('hermes:deep-link', payload)
rememberLog(`[deeplink] delivered ${kind}/${name}`)
} catch (err) {
rememberLog(`[deeplink] delivery failed: ${err.message}`)
}
}
// Renderer calls this (via IPC) once it has mounted its deep-link listener, so
// a link that arrived during boot/install is flushed exactly once.
ipcMain.handle('hermes:deep-link-ready', () => {
_rendererReadyForDeepLink = true
if (_pendingDeepLink) {
const queued = _pendingDeepLink
_pendingDeepLink = null
handleDeepLink(
`${HERMES_PROTOCOL}://${queued.kind}/${encodeURIComponent(queued.name)}` +
(Object.keys(queued.params).length ? '?' + new URLSearchParams(queued.params).toString() : '')
)
}
return { ok: true }
})
function registerDeepLinkProtocol() {
try {
if (process.defaultApp && process.argv.length >= 2) {
// Dev: register with the electron exec path + entry script so the OS can
// relaunch us with the URL.
app.setAsDefaultProtocolClient(HERMES_PROTOCOL, process.execPath, [path.resolve(process.argv[1])])
} else {
app.setAsDefaultProtocolClient(HERMES_PROTOCOL)
}
} catch (err) {
rememberLog(`[deeplink] protocol registration failed: ${err.message}`)
}
}
// Single-instance lock: deep links on a running app (Win/Linux) arrive as a
// second-instance argv. Without the lock a second `hermes://` launch spawns a
// whole new app instead of routing into the running one.
const _gotSingleInstanceLock = app.requestSingleInstanceLock()
if (!_gotSingleInstanceLock) {
app.quit()
} else {
app.on('second-instance', (_event, argv) => {
const url = _extractDeepLink(argv)
if (url) handleDeepLink(url)
else if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.focus()
}
})
}
// macOS delivers deep links via 'open-url' — register early (can fire before
// whenReady; handleDeepLink queues until the renderer is ready).
app.on('open-url', (event, url) => {
event.preventDefault()
handleDeepLink(url)
})
app.whenReady().then(() => {
if (IS_MAC) {
Menu.setApplicationMenu(buildApplicationMenu())
@@ -6451,16 +6196,11 @@ app.whenReady().then(() => {
}
installMediaPermissions()
registerMediaProtocol()
registerDeepLinkProtocol()
ensureWslWindowsFonts()
configureSpellChecker()
registerPowerResumeListeners()
createWindow()
// Win/Linux cold start: the launching hermes:// URL is in our own argv.
const _coldStartLink = _extractDeepLink(process.argv)
if (_coldStartLink) handleDeepLink(_coldStartLink)
app.on('activate', () => {
// Recreate the primary window if it's gone. Guard on mainWindow directly
// (not just total window count) so a dock click still restores the main

View File

@@ -5,7 +5,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
revalidateConnection: () => ipcRenderer.invoke('hermes:connection:revalidate'),
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts),
openSessionWindow: sessionId => ipcRenderer.invoke('hermes:window:openSession', sessionId),
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile),
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
@@ -39,8 +39,6 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
watchPreviewFile: url => ipcRenderer.invoke('hermes:watchPreviewFile', url),
stopPreviewFileWatch: id => ipcRenderer.invoke('hermes:stopPreviewFileWatch', id),
setTitleBarTheme: payload => ipcRenderer.send('hermes:titlebar-theme', payload),
setNativeTheme: mode => ipcRenderer.send('hermes:native-theme', mode),
setTranslucency: payload => ipcRenderer.send('hermes:translucency', payload),
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url),
@@ -54,7 +52,6 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
getRecentLogs: () => ipcRenderer.invoke('hermes:logs:recent'),
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath),
worktrees: cwds => ipcRenderer.invoke('hermes:fs:worktrees', cwds),
terminal: {
dispose: id => ipcRenderer.invoke('hermes:terminal:dispose', id),
resize: (id, size) => ipcRenderer.invoke('hermes:terminal:resize', id, size),
@@ -83,12 +80,6 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
ipcRenderer.on('hermes:open-updates', listener)
return () => ipcRenderer.removeListener('hermes:open-updates', listener)
},
onDeepLink: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:deep-link', listener)
return () => ipcRenderer.removeListener('hermes:deep-link', listener)
},
signalDeepLinkReady: () => ipcRenderer.invoke('hermes:deep-link-ready'),
onWindowStateChanged: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:window-state-changed', listener)

View File

@@ -5,30 +5,22 @@
const { pathToFileURL } = require('node:url')
// Secondary windows open at the minimum usable size — a compact side panel for
// subagent watch / cmd-click session pop-out, not a second full desktop.
const SESSION_WINDOW_MIN_WIDTH = 420
const SESSION_WINDOW_MIN_HEIGHT = 620
// Build the renderer URL for a secondary window. The renderer uses a
// HashRouter, so the session route lives after the '#'. The `?win=secondary`
// flag MUST sit in the query string BEFORE the '#': anything after the '#' is
// treated as the route by HashRouter and would break routeSessionId(). The
// renderer reads the flag from window.location.search to suppress the install /
// onboarding overlays and the global session sidebar. `watch=1` marks a
// spectator window (e.g. a running subagent's session): the renderer resumes
// it lazily so the gateway never builds an agent just to stream into it.
function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath, watch } = {}) {
const query = `?win=secondary${watch ? '&watch=1' : ''}`
// onboarding overlays and the global session sidebar.
function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath } = {}) {
const route = `#/${encodeURIComponent(sessionId)}`
if (devServer) {
const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer
return `${base}/${query}${route}`
return `${base}/?win=secondary${route}`
}
return `${pathToFileURL(rendererIndexPath).toString()}${query}${route}`
return `${pathToFileURL(rendererIndexPath).toString()}?win=secondary${route}`
}
// A small registry keyed by sessionId that guarantees one window per chat:
@@ -91,9 +83,4 @@ function createSessionWindowRegistry() {
}
}
module.exports = {
buildSessionWindowUrl,
createSessionWindowRegistry,
SESSION_WINDOW_MIN_HEIGHT,
SESSION_WINDOW_MIN_WIDTH
}
module.exports = { buildSessionWindowUrl, createSessionWindowRegistry }

View File

@@ -76,12 +76,6 @@ test('buildSessionWindowUrl builds a packaged file URL with the flag before the
assert.match(url, /^file:\/\/.*index\.html\?win=secondary#\/abc$/)
})
test('buildSessionWindowUrl adds the watch flag for spectator windows, before the hash', () => {
const url = buildSessionWindowUrl('abc', { devServer: 'http://localhost:5173', watch: true })
assert.equal(url, 'http://localhost:5173/?win=secondary&watch=1#/abc')
})
test('registry opens one window per session and focuses on re-open', () => {
const registry = createSessionWindowRegistry()
let built = 0

View File

@@ -8,7 +8,7 @@ const path = require('node:path')
const ELECTRON_DIR = __dirname
function readElectronFile(name) {
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n')
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8')
}
function requireHiddenChildOptions(source, needle) {
@@ -42,9 +42,6 @@ test('intentional or interactive desktop child processes stay documented', () =>
const source = readElectronFile('main.cjs')
assert.match(source, /windowsHide: false/)
assert.match(source, /handOffWindowsBootstrapRecovery/)
assert.match(source, /'--repair', '--branch'/)
assert.match(source, /'--update', '--branch'/)
assert.match(source, /nodePty\.spawn\(command, args/)
assert.match(source, /spawn\('cmd\.exe', \['\/c', 'start'/)
})

View File

@@ -9,28 +9,6 @@
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="shortcut icon" href="/apple-touch-icon.png" />
<title>Hermes</title>
<script>
// Pre-paint the themed background before the app bundle loads. Without
// this, the first frame (which is what `ready-to-show` waits for) is the
// UA-default white page, and the real theme only lands once the whole
// module graph has executed — i.e. the "white flash" on every new
// window. applyTheme() in src/themes/context.tsx keeps these keys fresh.
try {
let bg = localStorage.getItem('hermes-boot-background')
let scheme = localStorage.getItem('hermes-boot-color-scheme')
if (!bg) {
const dark = window.matchMedia('(prefers-color-scheme: dark)').matches
bg = dark ? '#111111' : '#f7f7f7'
scheme = dark ? 'dark' : 'light'
}
document.documentElement.style.backgroundColor = bg
if (scheme === 'dark' || scheme === 'light') {
document.documentElement.style.colorScheme = scheme
}
} catch {
// localStorage unavailable — keep UA defaults.
}
</script>
</head>
<body>
<div id="root" class="scrollbar-dt"></div>

View File

@@ -18,8 +18,7 @@
"profile:main": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .",
"profile:main:cpu": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
"start": "npm run build && electron .",
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && npm run postbuild",
"postbuild": "node scripts/assert-dist-built.cjs",
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && node scripts/assert-dist-built.cjs",
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 electron-builder",
"pack": "npm run build && npm run builder -- --dir",
"dist": "npm run build && npm run builder",
@@ -36,7 +35,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs",
"typecheck": "tsc -p . --noEmit",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
@@ -73,7 +72,6 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dnd-core": "^14.0.1",
"hast-util-from-html-isomorphic": "^2.0.0",
"hast-util-to-text": "^4.0.2",
"ignore": "^7.0.5",
@@ -85,12 +83,10 @@
"radix-ui": "^1.4.3",
"react": "^19.2.5",
"react-arborist": "^3.5.0",
"react-dnd-html5-backend": "^14.0.3",
"react-dom": "^19.2.5",
"react-router-dom": "^7.17.0",
"react-shiki": "^0.9.3",
"remark-math": "^6.0.0",
"remend": "^1.3.0",
"shiki": "^4.0.2",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.5.0",
@@ -99,7 +95,6 @@
"unicode-animations": "^1.0.3",
"unified": "^11.0.5",
"unist-util-visit-parents": "^6.0.2",
"use-stick-to-bottom": "^1.1.6",
"vfile": "^6.0.3",
"web-haptics": "^0.0.6"
},
@@ -108,7 +103,7 @@
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.2",
"@types/hast": "^3.0.4",
"@types/node": "^24.13.2",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.59.1",
@@ -137,14 +132,6 @@
"appId": "com.nousresearch.hermes",
"productName": "Hermes",
"executableName": "Hermes",
"protocols": [
{
"name": "Hermes Protocol",
"schemes": [
"hermes"
]
}
],
"artifactName": "Hermes-${version}-${os}-${arch}.${ext}",
"icon": "assets/icon",
"directories": {

View File

@@ -3,8 +3,8 @@ import { type ReactNode, useEffect, useMemo, useState } from 'react'
import { useElapsedSeconds } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
import { BrailleSpinner } from '@/components/ui/braille-spinner'
import { FadeText } from '@/components/ui/fade-text'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { type Translations, useI18n } from '@/i18n'
import { AlertCircle, CheckCircle2, Sparkles } from '@/lib/icons'
import { useEnterAnimation } from '@/lib/use-enter-animation'
@@ -25,7 +25,7 @@ import { OverlayView } from '../overlays/overlay-view'
function statusGlyph(status: SubagentStatus, a: Translations['agents']): ReactNode {
if (status === 'running' || status === 'queued') {
return (
<GlyphSpinner
<BrailleSpinner
ariaLabel={a.running}
className="size-3.5 shrink-0 text-[0.95rem] text-muted-foreground/80"
spinner="breathe"
@@ -290,7 +290,7 @@ function StreamLine({
<span className={cn('min-w-0 flex-1 wrap-anywhere', tone, isMono && 'font-mono text-[0.69rem]')}>
{entry.text}
{active ? (
<GlyphSpinner
<BrailleSpinner
ariaLabel={t.agents.streaming}
className="ml-1 inline-block size-2.5 align-middle text-muted-foreground/70"
spinner="breathe"
@@ -372,9 +372,7 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
{open && fileLines.length > 0 ? (
<div className="grid min-w-0 gap-0.5 pl-6">
<p className="text-[0.58rem] font-medium tracking-wider text-muted-foreground/60 uppercase">
{t.agents.files}
</p>
<p className="text-[0.58rem] font-medium tracking-wider text-muted-foreground/60 uppercase">{t.agents.files}</p>
{fileLines.slice(0, 8).map(line => (
<p className="wrap-break-word font-mono text-[0.67rem] leading-relaxed text-muted-foreground/80" key={line}>
{line}

View File

@@ -18,7 +18,7 @@ import {
} from '@/components/ui/pagination'
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
import { Tip } from '@/components/ui/tooltip'
import { getSessionMessages, listAllProfileSessions } from '@/hermes'
import { getSessionMessages, listSessions } from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
@@ -388,8 +388,8 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
setRefreshing(true)
try {
const sessions = (await listAllProfileSessions(30, 1)).sessions
const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id, session.profile)))
const sessions = (await listSessions(30, 1)).sessions
const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id)))
const nextArtifacts: ArtifactRecord[] = []
results.forEach((result, index) => {

View File

@@ -2,21 +2,25 @@ import type { Unstable_TriggerAdapter } from '@assistant-ui/core'
import { ComposerPrimitive } from '@assistant-ui/react'
import type { ReactNode } from 'react'
import { composerFusedDockCard } from '@/components/chat/composer-dock'
import { cn } from '@/lib/utils'
export const COMPLETION_DRAWER_CLASS = [
'absolute bottom-[calc(100%+0.375rem)] left-0 z-50',
'w-80 max-w-[calc(100vw-2rem)]',
'max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
'rounded-xl border border-(--ui-stroke-secondary)',
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_97%,transparent)]',
'p-1 text-xs text-popover-foreground shadow-lg',
'backdrop-blur-md'
].join(' ')
// Same docked chrome as the queue/status stack, but its own thing: a narrow,
// left-aligned card (not full width) that fuses to the composer's edge instead
// of floating above it. `left-1` matches the stack's `mx-1` inset; the negative
// margin overlaps the seam so the composer's (now-transparent) edge border reads
// as shared. Fused (opaque) fill — the composer surface swaps to the same fill
// while a drawer is open, so the two paint as one panel.
const DRAWER_SHELL =
'absolute left-1 z-50 w-80 max-w-[calc(100%-0.5rem)] max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain p-1 text-xs text-popover-foreground'
export const COMPLETION_DRAWER_CLASS = cn(DRAWER_SHELL, 'bottom-full -mb-[9px]', composerFusedDockCard('top'))
export const COMPLETION_DRAWER_BELOW_CLASS = cn(DRAWER_SHELL, 'top-full -mt-[9px]', composerFusedDockCard('bottom'))
export const COMPLETION_DRAWER_BELOW_CLASS = [
'absolute left-0 top-[calc(100%+0.375rem)] z-50',
'w-80 max-w-[calc(100vw-2rem)]',
'max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
'rounded-xl border border-(--ui-stroke-secondary)',
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_97%,transparent)]',
'p-1 text-xs text-popover-foreground shadow-lg',
'backdrop-blur-md'
].join(' ')
export function ComposerCompletionDrawer({
adapter,

View File

@@ -11,7 +11,6 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Kbd } from '@/components/ui/kbd'
import { useI18n } from '@/i18n'
import { Clipboard, FileText, FolderOpen, type IconComponent, ImageIcon, Link, MessageSquareText } from '@/lib/icons'
import { cn } from '@/lib/utils'
@@ -87,7 +86,7 @@ export function ContextMenu({
<div className="px-2 py-1 text-[0.7rem] text-muted-foreground/80">
{c.tipPre}
<Kbd size="sm">@</Kbd>
<kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd>
{c.tipPost}
</div>
</DropdownMenuContent>

View File

@@ -1,6 +1,5 @@
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { KbdCombo } from '@/components/ui/kbd'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
@@ -64,14 +63,7 @@ export function ComposerControls({
}) {
const { t } = useI18n()
const c = t.composer
const steerCombo = formatCombo('mod+enter')
const steerLabel = `${c.steer} (${steerCombo})`
const steerTip = (
<span className="inline-flex items-center gap-1.5">
{c.steer}
<KbdCombo combo="mod+enter" size="sm" variant="inverted" />
</span>
)
const steerLabel = `${c.steer} (${formatCombo('mod+enter')})`
if (conversation.active) {
return <ConversationPill {...conversation} disabled={disabled} />
@@ -83,7 +75,7 @@ export function ComposerControls({
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
{canSteer && (
<Tip label={steerTip}>
<Tip label={steerLabel}>
<Button
aria-label={steerLabel}
className={GHOST_ICON_BTN}

View File

@@ -24,7 +24,6 @@ afterEach(cleanup)
// state stays stale while the DOM already holds the text.
function Harness({
busy = false,
disabled = false,
queued = [],
onSubmit,
onQueue,
@@ -32,7 +31,6 @@ function Harness({
onDrain
}: {
busy?: boolean
disabled?: boolean
queued?: readonly string[]
onSubmit: (text: string) => void
onQueue: (text: string) => void
@@ -54,10 +52,6 @@ function Harness({
}
const submitDraft = () => {
if (disabled) {
return
}
const editor = editorRef.current
if (editor) {
const domText = composerPlainText(editor)
@@ -90,10 +84,6 @@ function Harness({
const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0
if (disabled) {
return
}
if (!busy && !hasLivePayload && queued.length > 0) {
onDrain()
@@ -196,23 +186,4 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)',
expect(onDrain).toHaveBeenCalledTimes(1)
expect(onSubmit).not.toHaveBeenCalled()
})
it('keeps reconnect drafts editable but blocks Enter submit until the gateway returns', async () => {
const onSubmit = vi.fn()
const onDrain = vi.fn()
const { getByTestId } = render(
<Harness disabled onCancel={vi.fn()} onDrain={onDrain} onQueue={vi.fn()} onSubmit={onSubmit} queued={['queued-1']} />
)
const editor = getByTestId('editor')
await act(async () => {
editor.textContent = 'draft while reconnecting'
fireEvent.input(editor)
fireEvent.keyDown(editor, { key: 'Enter' })
})
expect(editor.textContent).toBe('draft while reconnecting')
expect(onDrain).not.toHaveBeenCalled()
expect(onSubmit).not.toHaveBeenCalled()
})
})

View File

@@ -10,7 +10,6 @@
* steal focus from the composer effect.
*/
import { RICH_INPUT_SLOT } from './rich-editor'
import type { InlineRefInput } from './inline-refs'
export type ComposerTarget = 'edit' | 'main'
@@ -124,12 +123,3 @@ export const focusComposerInput = (el: HTMLElement | null) => {
window.requestAnimationFrame(focus)
window.setTimeout(focus, 0)
}
/** Drop focus from the main composer input (status-stack chrome, sidebar, etc.). */
export const blurComposerInput = () => {
const el = document.querySelector(`[data-slot="${RICH_INPUT_SLOT}"]`) as HTMLElement | null
if (el && document.activeElement === el) {
el.blur()
}
}

View File

@@ -1,23 +1,11 @@
import type { ReactNode } from 'react'
import { KbdCombo } from '@/components/ui/kbd'
import { useI18n } from '@/i18n'
import { COMPLETION_DRAWER_CLASS } from './completion-drawer'
const COMMON_COMMAND_KEYS = ['/help', '/clear', '/resume', '/details', '/copy', '/quit']
/** Stable ids → i18n `hotkeyDescs` keys. Combos resolve mod labels per OS. */
const COMPOSER_HOTKEY_ROWS = [
{ id: 'composer.mention', combos: ['@'] },
{ id: 'composer.slash', combos: ['/'] },
{ id: 'composer.help', combos: ['?'] },
{ id: 'composer.sendNewline', combos: ['enter', 'shift+enter'] },
{ id: 'composer.sendQueued', combos: ['mod+shift+k'] },
{ id: 'keybinds.openPanel', combos: ['mod+/'] },
{ id: 'composer.cancel', combos: ['escape'] },
{ id: 'composer.history', combos: ['up', 'down'] }
] as const
const HOTKEY_KEYS = ['@', '/', '?', 'Enter', 'Cmd/Ctrl+Shift+K', 'Cmd/Ctrl+/', 'Esc', '↑ / ↓']
export function HelpHint() {
const { t } = useI18n()
@@ -32,8 +20,8 @@ export function HelpHint() {
</Section>
<Section title={c.hotkeys}>
{COMPOSER_HOTKEY_ROWS.map(row => (
<HotkeyRow description={c.hotkeyDescs[row.id] ?? ''} combos={[...row.combos]} key={row.id} />
{HOTKEY_KEYS.map(key => (
<Row description={c.hotkeyDescs[key] ?? ''} key={key} keyLabel={key} />
))}
</Section>
@@ -69,16 +57,3 @@ function Row({ description, keyLabel, mono = false }: { description: string; key
</div>
)
}
function HotkeyRow({ combos, description }: { combos: string[]; description: string }) {
return (
<div className="flex min-w-0 items-center gap-2 rounded-md px-2.5 py-1 text-xs">
<span className="flex shrink-0 items-center gap-1">
{combos.map(combo => (
<KbdCombo combo={combo} key={combo} size="sm" />
))}
</span>
<span className="min-w-0 truncate text-muted-foreground/80">{description}</span>
</div>
)
}

View File

@@ -14,7 +14,6 @@ import {
} from 'react'
import { hermesDirectiveFormatter, type SlashChipKind } from '@/components/assistant-ui/directive-text'
import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-dock'
import { Button } from '@/components/ui/button'
import { useMediaQuery } from '@/hooks/use-media-query'
import { useResizeObserver } from '@/hooks/use-resize-observer'
@@ -43,16 +42,12 @@ import {
import {
$queuedPromptsBySession,
enqueueQueuedPrompt,
MAX_AUTO_DRAIN_ATTEMPTS,
migrateQueuedPrompts,
promoteQueuedPrompt,
type QueuedPromptEntry,
removeQueuedPrompt,
shouldAutoDrain,
shouldAutoDrainOnSettle,
updateQueuedPrompt
} from '@/store/composer-queue'
import { $statusItemsBySession } from '@/store/composer-status'
import { notify } from '@/store/notifications'
import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session'
import { $threadScrolledUp } from '@/store/thread-scroll'
import { useTheme } from '@/themes'
@@ -85,14 +80,12 @@ import {
import { QueuePanel } from './queue-panel'
import {
composerPlainText,
normalizeComposerEditorDom,
placeCaretEnd,
refChipElement,
renderComposerContents,
RICH_INPUT_SLOT,
slashChipElement
} from './rich-editor'
import { ComposerStatusStack } from './status-stack'
import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils'
import { ComposerTriggerPopover } from './trigger-popover'
import type { ChatBarProps } from './types'
@@ -175,8 +168,8 @@ export function ChatBar({
const draft = useAuiState(s => s.composer.text)
const attachments = useStore($composerAttachments)
const queuedPromptsBySession = useStore($queuedPromptsBySession)
const statusItemsBySession = useStore($statusItemsBySession)
const scrolledUp = useStore($threadScrolledUp)
const sessionMessages = useStore($messages)
const activeQueueSessionKey = queueSessionKey || sessionId || null
const queuedPrompts = useMemo(
@@ -184,29 +177,15 @@ export function ChatBar({
[activeQueueSessionKey, queuedPromptsBySession]
)
// Status items (subagents, background processes) are keyed by the RUNTIME
// session id — gateway events and process.list both speak that id. Only the
// queue uses the stored-session fallback key (prompts can queue pre-resume).
const statusSessionId = sessionId ?? null
const statusStackVisible = useMemo(
() =>
queuedPrompts.length > 0 || (statusSessionId ? (statusItemsBySession[statusSessionId]?.length ?? 0) > 0 : false),
[queuedPrompts.length, statusItemsBySession, statusSessionId]
)
const composerRef = useRef<HTMLFormElement | null>(null)
const composerSurfaceRef = useRef<HTMLDivElement | null>(null)
const editorRef = useRef<HTMLDivElement | null>(null)
const draftRef = useRef(draft)
const previousBusyRef = useRef(busy)
const pendingDraftPersistRef = useRef<{ scope: string | null; text: string } | null>(null)
const activeQueueSessionKeyRef = useRef(activeQueueSessionKey)
activeQueueSessionKeyRef.current = activeQueueSessionKey
const prevQueueKeyRef = useRef(activeQueueSessionKey)
const drainingQueueRef = useRef(false)
// Per-entry auto-drain failure counts; bounds retries so a persistent 404
// can't spin-loop. Cleared on success; reset naturally on remount/reconnect.
const drainFailuresRef = useRef(new Map<string, number>())
const urlInputRef = useRef<HTMLInputElement | null>(null)
const [urlOpen, setUrlOpen] = useState(false)
@@ -247,8 +226,6 @@ export function ChatBar({
const gatewayState = useStore($gatewayState)
const newSessionPlaceholders = t.composer.newSessionPlaceholders
const followUpPlaceholders = t.composer.followUpPlaceholders
const reconnecting = gatewayState === 'closed' || gatewayState === 'error'
const inputDisabled = disabled && !reconnecting
// Resting placeholder: a starter for brand-new sessions, a continuation for
// existing ones. Picked once and only re-rolled when we genuinely move to a
@@ -279,13 +256,11 @@ export function ChatBar({
setRestingPlaceholder(pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders))
}, [followUpPlaceholders, newSessionPlaceholders, sessionId])
// When the transport is disabled it's because the gateway isn't open.
// Distinguish a cold start ("Starting Hermes...") from a dropped connection
// we're trying to restore. During reconnect, keep the textbox editable so a
// flaky network doesn't block drafting; only submit/backend actions stay
// disabled until the gateway is open again.
// When the bar is disabled it's because the gateway isn't open. Distinguish a
// cold start ("Starting Hermes...") from a dropped connection we're trying to
// restore (e.g. after the Mac slept) so the stuck state reads as recoverable.
const placeholder = disabled
? reconnecting
? gatewayState === 'closed' || gatewayState === 'error'
? t.composer.placeholderReconnecting
: t.composer.placeholderStarting
: restingPlaceholder
@@ -327,13 +302,13 @@ export function ChatBar({
)
useEffect(() => {
if (!inputDisabled) {
if (!disabled) {
focusInput()
}
}, [focusInput, focusKey, focusRequestId, inputDisabled])
}, [disabled, focusInput, focusKey, focusRequestId])
useEffect(() => {
if (inputDisabled) {
if (disabled) {
return undefined
}
@@ -353,7 +328,7 @@ export function ChatBar({
offFocus()
offInsert()
}
}, [appendExternalText, inputDisabled])
}, [appendExternalText, disabled])
// Keep draftRef in sync with the assistant-ui composer state for callers
// that read the latest text outside the React render cycle. We don't push
@@ -627,7 +602,9 @@ export function ChatBar({
// (which drives `hasComposerPayload` → the send button). Shared by the input
// and compositionend paths so committed IME text reaches state through either.
const flushEditorToDraft = (editor: HTMLDivElement) => {
normalizeComposerEditorDom(editor)
if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') {
editor.replaceChildren()
}
const nextDraft = composerPlainText(editor)
@@ -711,7 +688,8 @@ export function ChatBar({
// already an arg pick (`/personality alice`), so it commits normally.
const command = (item.metadata as { command?: string } | undefined)?.command ?? ''
const expandsToArgs = trigger.kind === '/' && !serialized.includes(' ') && desktopSlashCommandTakesArgs(command)
const expandsToArgs =
trigger.kind === '/' && !serialized.includes(' ') && desktopSlashCommandTakesArgs(command)
const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} `
const directive = !starter && serialized.match(/^@([^:]+):(.+)$/)
@@ -875,9 +853,7 @@ export function ChatBar({
event.preventDefault()
triggerKeyConsumedRef.current = true
// $messages is read imperatively (not subscribed) so the composer
// doesn't re-render on every streaming delta flush.
const history = deriveUserHistory($messages.get(), chatMessageText)
const history = deriveUserHistory(sessionMessages, chatMessageText)
const entry = browseBackward(sessionId, currentDraft, history)
if (entry !== null) {
@@ -902,7 +878,7 @@ export function ChatBar({
event.preventDefault()
triggerKeyConsumedRef.current = true
const history = deriveUserHistory($messages.get(), chatMessageText)
const history = deriveUserHistory(sessionMessages, chatMessageText)
const result = browseForward(sessionId, history)
if (result !== null) {
@@ -938,10 +914,6 @@ export function ChatBar({
const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0
if (disabled) {
return
}
if (!busy && !hasLivePayload && queuedPrompts.length > 0) {
void drainNextQueued()
@@ -1141,8 +1113,11 @@ export function ChatBar({
}
}
const stashAt = (scope: string | null, text = draftRef.current, attachments = $composerAttachments.get()) =>
stashSessionDraft(scope, text, attachments)
const stashAt = (
scope: string | null,
text = draftRef.current,
attachments = $composerAttachments.get()
) => stashSessionDraft(scope, text, attachments)
// Per-thread draft swap — the composer's only session coupling. Lifecycle
// never clears composer state; this effect alone stashes on leave, restores
@@ -1340,7 +1315,6 @@ export function ChatBar({
return false
}
drainFailuresRef.current.delete(entry.id)
removeQueuedPrompt(activeQueueSessionKey, entry.id)
resetBrowseState(sessionId)
@@ -1352,17 +1326,16 @@ export function ChatBar({
[activeQueueSessionKey, onSubmit, queuedPrompts, sessionId]
)
const pickDrainHead = useCallback(
(entries: QueuedPromptEntry[]) => {
const skip = queueEditRef.current?.entryId
const drainNextQueued = useCallback(
() =>
runDrain(entries => {
const skip = queueEdit?.entryId
return skip ? entries.find(e => e.id !== skip) : entries[0]
},
[] // reads the edit id off a ref so the lock-holder always sees the latest
return skip ? entries.find(e => e.id !== skip) : entries[0]
}),
[queueEdit, runDrain]
)
const drainNextQueued = useCallback(() => runDrain(pickDrainHead), [pickDrainHead, runDrain])
const sendQueuedNow = useCallback(
(id: string) => {
if (!activeQueueSessionKey || id === queueEdit?.entryId) {
@@ -1380,76 +1353,30 @@ export function ChatBar({
return true
}
// A manual send clears the auto-drain backoff so a stuck entry the user
// taps gets a fresh attempt (and re-enables auto-retry on success).
drainFailuresRef.current.delete(id)
return runDrain(entries => entries.find(e => e.id === id))
},
[activeQueueSessionKey, busy, onCancel, queueEdit, runDrain]
)
// Edge-independent auto-drain: send the head whenever the session is idle and
// the queue is non-empty, bounding retries so a thrown/rejected onSubmit (e.g.
// a stale-session 404) can't strand the entry permanently nor spin-loop. The
// drain lock serializes sends; a remount/reconnect resets the failure counts.
const autoDrainNext = useCallback(() => {
if (busy || drainingQueueRef.current || !activeQueueSessionKey) {
return
}
// Auto-drain on busy → false (turn settled). Queued turns always flow once
// the session is idle again — whether the turn finished naturally or the
// user interrupted it. Interrupting to reach a queued message is the whole
// point of the queue, so we never suppress the drain. To cancel queued
// turns, the user deletes them from the panel.
useEffect(() => {
const wasBusy = previousBusyRef.current
previousBusyRef.current = busy
const entry = pickDrainHead(queuedPrompts)
if (!entry || (drainFailuresRef.current.get(entry.id) ?? 0) >= MAX_AUTO_DRAIN_ATTEMPTS) {
return
}
const onFail = () => {
const fails = (drainFailuresRef.current.get(entry.id) ?? 0) + 1
drainFailuresRef.current.set(entry.id, fails)
if (fails >= MAX_AUTO_DRAIN_ATTEMPTS) {
notify({
id: 'composer-queue-stuck',
kind: 'error',
title: t.composer.queueStuckTitle,
message: t.composer.queueStuckBody
})
}
}
void runDrain(() => entry)
.then(sent => {
if (!sent) {
onFail()
}
if (
shouldAutoDrainOnSettle({
isBusy: busy,
queueLength: queuedPrompts.length,
wasBusy
})
.catch(onFail)
}, [activeQueueSessionKey, busy, pickDrainHead, queuedPrompts, runDrain, t])
// Re-key on a runtime session-id change. A stable stored id (queueSessionKey)
// never churns, so a change there is a real session switch and must NOT
// migrate; only the runtime-derived key (queueSessionKey falsy → key is
// sessionId) churns on a backend bounce/resume of the same conversation.
useEffect(() => {
const prev = prevQueueKeyRef.current
prevQueueKeyRef.current = activeQueueSessionKey
if (queueSessionKey || !prev || !activeQueueSessionKey || prev === activeQueueSessionKey) {
return
) {
void drainNextQueued()
}
migrateQueuedPrompts(prev, activeQueueSessionKey)
}, [activeQueueSessionKey, queueSessionKey])
// Queued turns flow whenever the session is idle — on the busy→false settle
// edge, on mount/reconnect, and after a re-key — so a swallowed edge can't
// strand them. To cancel queued turns, the user deletes them from the panel.
useEffect(() => {
if (shouldAutoDrain({ isBusy: busy, queueLength: queuedPrompts.length })) {
autoDrainNext()
}
}, [autoDrainNext, busy, queuedPrompts.length])
}, [busy, drainNextQueued, queuedPrompts.length])
// Queue-edit cleanup: on session swap the scope effect already stashed the
// edit snapshot; only restore into the composer when still on the same scope.
@@ -1484,10 +1411,6 @@ export function ChatBar({
}
const submitDraft = () => {
if (disabled) {
return
}
// Source the text from the DOM editor, not React state. The AUI composer
// state (`draft`) and the derived `hasComposerPayload` lag the DOM by a
// render, so on fast typing or IME composition the final keystroke(s) may
@@ -1668,7 +1591,6 @@ export function ChatBar({
const input = (
<div className={cn('relative', stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1')}>
<div
aria-disabled={inputDisabled ? true : undefined}
aria-label={t.composer.message}
autoCapitalize="off"
autoCorrect="off"
@@ -1679,7 +1601,7 @@ export function ChatBar({
stacked && 'pl-3',
stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1'
)}
contentEditable={!inputDisabled}
contentEditable={!disabled}
data-placeholder={placeholder}
data-slot={RICH_INPUT_SLOT}
onBlur={() => window.setTimeout(closeTrigger, 80)}
@@ -1708,7 +1630,7 @@ export function ChatBar({
onPaste={handlePaste}
ref={editorRef}
role="textbox"
spellCheck={false}
spellCheck="true"
suppressContentEditableWarning
/>
{/* assistant-ui requires ComposerPrimitive.Input somewhere in the tree
@@ -1727,15 +1649,7 @@ export function ChatBar({
`asChild` swaps TextareaAutosize for a Radix Slot wrapping our
plain <textarea>, which carries the binding but skips autosize. */}
<ComposerPrimitive.Input asChild submitMode="ctrlEnter" tabIndex={-1} unstable_focusOnScrollToBottom={false}>
<textarea
aria-hidden
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className="sr-only"
spellCheck={false}
tabIndex={-1}
/>
<textarea aria-hidden className="sr-only" tabIndex={-1} />
</ComposerPrimitive.Input>
</div>
)
@@ -1747,7 +1661,6 @@ export function ChatBar({
className="group/composer absolute bottom-0 left-1/2 z-30 w-[min(var(--composer-width),calc(100%-2rem))] max-w-full -translate-x-1/2 rounded-2xl pt-2 pb-[var(--composer-shell-pad-block-end)]"
data-drag-active={dragActive ? '' : undefined}
data-slot="composer-root"
data-status-stack={statusStackVisible ? '' : undefined}
data-thread-scrolled-up={scrolledUp ? '' : undefined}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
@@ -1775,30 +1688,26 @@ export function ChatBar({
onPick={replaceTriggerWithChip}
/>
)}
{/* Session-scoped status stack (todos, subagents, background tasks,
queue). Out of flow so it never inflates the composer's measured
height; it overlays the chat instead of pushing it, and publishes
its own --status-stack-measured-height so the thread's clearance
accounts for it. Collapses to nothing when every status is empty. */}
<ComposerStatusStack
queue={
activeQueueSessionKey && queuedPrompts.length > 0 ? (
<QueuePanel
busy={busy}
editingId={queueEdit?.entryId ?? null}
entries={queuedPrompts}
onDelete={id => {
if (removeQueuedPrompt(activeQueueSessionKey, id) && queueEdit?.entryId === id) {
exitQueuedEdit('cancel')
}
}}
onEdit={beginQueuedEdit}
onSendNow={id => void sendQueuedNow(id)}
/>
) : null
}
sessionId={statusSessionId}
/>
{activeQueueSessionKey && queuedPrompts.length > 0 && (
// Out of flow so the queue never inflates the composer's measured
// height (that drives thread bottom padding → chat resizes on
// queue). Overlaps -mb-2 onto the surface's top border for a shared
// edge; capped + scrollable. Overlays the chat instead of pushing it.
<div className="absolute inset-x-0 bottom-full z-6 -mb-2 max-h-[40vh] overflow-y-auto">
<QueuePanel
busy={busy}
editingId={queueEdit?.entryId ?? null}
entries={queuedPrompts}
onDelete={id => {
if (removeQueuedPrompt(activeQueueSessionKey, id) && queueEdit?.entryId === id) {
exitQueuedEdit('cancel')
}
}}
onEdit={beginQueuedEdit}
onSendNow={id => void sendQueuedNow(id)}
/>
</div>
)}
<div
className="pointer-events-none absolute inset-0 rounded-[inherit]"
style={{ background: COMPOSER_FADE_BACKGROUND }}
@@ -1806,8 +1715,9 @@ export function ChatBar({
<div className="relative w-full rounded-[inherit]">
<div
className={cn(
'group/composer-surface relative z-4 isolate rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))] transition-[border-color] duration-200 ease-out focus-within:border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(45%*var(--composer-ring-strength)),transparent)]',
'relative z-4 isolate rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))] transition-[border-color] duration-200 ease-out',
COMPOSER_DROP_FADE_CLASS,
'group-focus-within/composer:border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(45%*var(--composer-ring-strength)),transparent)]',
'group-has-data-[state=open]/composer:border-t-transparent',
dragActive && COMPOSER_DROP_ACTIVE_CLASS
)}
@@ -1818,14 +1728,20 @@ export function ChatBar({
aria-hidden
className={cn(
'pointer-events-none absolute inset-0 -z-10 rounded-[inherit]',
composerFill,
composerSurfaceGlass
'bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)]',
'backdrop-blur-[0.75rem] backdrop-saturate-[1.12]',
'[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]',
'transition-[background-color] duration-150 ease-out',
'group-data-[thread-scrolled-up]/composer:bg-[color-mix(in_srgb,var(--dt-card)_48%,transparent)]',
'group-focus-within/composer:bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)]'
)}
/>
<div
className={cn(
'relative z-1 flex min-h-0 w-full flex-col gap-(--composer-row-gap) overflow-hidden rounded-[inherit] px-(--composer-surface-pad-x) py-(--composer-surface-pad-y) transition-opacity duration-200 ease-out',
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer-surface:opacity-100' : 'opacity-100'
scrolledUp
? 'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer:opacity-100'
: 'opacity-100'
)}
data-slot="composer-fade"
>
@@ -1900,8 +1816,12 @@ export function ChatBarFallback() {
aria-hidden
className={cn(
'pointer-events-none absolute inset-0 -z-10 rounded-[inherit]',
composerFill,
composerSurfaceGlass
'bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)]',
'backdrop-blur-[0.75rem] backdrop-saturate-[1.12]',
'[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]',
'transition-[background-color] duration-150 ease-out',
'group-data-[thread-scrolled-up]/composer:bg-[color-mix(in_srgb,var(--dt-card)_48%,transparent)]',
'group-focus-within/composer:bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)]'
)}
/>
</div>

View File

@@ -3,12 +3,7 @@ import { contextPath } from '@/lib/chat-runtime'
import type { DroppedFile } from '../hooks/use-composer-actions'
import {
composerPlainText,
normalizeComposerEditorDom,
placeCaretEnd,
refChipElement
} from './rich-editor'
import { composerPlainText, escapeHtml, placeCaretEnd, refChipHtml } from './rich-editor'
/** A chip to insert: a raw `@kind:value` string, or a typed value + display label. */
export type InlineRefInput = string | { kind: string; label?: string; value: string }
@@ -94,102 +89,56 @@ export function droppedFileInlineRefs(candidates: DroppedFile[], cwd: string | n
return candidates.map(candidate => droppedFileInlineRef(candidate, cwd)).filter((ref): ref is string => Boolean(ref))
}
function parseInlineRef(ref: InlineRefInput): { kind: string; label?: string; rawValue: string } | null {
if (typeof ref !== 'string') {
return { kind: ref.kind, label: ref.label, rawValue: ref.value }
}
const match = ref.match(/^@([^:]+):(.+)$/)
if (!match) {
return null
}
return { kind: match[1] || 'file', rawValue: match[2] || '' }
}
function plainTextInRange(editor: HTMLDivElement, range: Range, edge: 'after' | 'before') {
const slice = range.cloneRange()
slice.selectNodeContents(editor)
if (edge === 'before') {
slice.setEnd(range.startContainer, range.startOffset)
} else {
slice.setStart(range.endContainer, range.endOffset)
}
const container = document.createElement('div')
container.appendChild(slice.cloneContents())
return composerPlainText(container)
}
function buildRefFragment(
refs: readonly { kind: string; label?: string; rawValue: string }[],
{ needsBeforeSpace, needsAfterSpace }: { needsAfterSpace: boolean; needsBeforeSpace: boolean }
) {
const fragment = document.createDocumentFragment()
if (needsBeforeSpace) {
fragment.append(document.createTextNode(' '))
}
refs.forEach((ref, index) => {
if (index > 0) {
fragment.append(document.createTextNode(' '))
}
fragment.append(refChipElement(ref.kind, ref.rawValue, ref.label))
})
if (needsAfterSpace) {
fragment.append(document.createTextNode(' '))
}
return fragment
}
export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly InlineRefInput[]) {
const parsed = refs.map(parseInlineRef).filter((ref): ref is NonNullable<typeof ref> => ref !== null)
if (!parsed.length) {
if (!refs.length) {
return null
}
editor.focus({ preventScroll: true })
const refsHtml = refs
.map(ref => {
if (typeof ref !== 'string') {
return refChipHtml(ref.kind, ref.value, ref.label)
}
const match = ref.match(/^@([^:]+):(.+)$/)
return match ? refChipHtml(match[1], match[2]) : escapeHtml(ref)
})
.join(' ')
const selection = window.getSelection()
const range =
selection?.rangeCount && editor.contains(selection.getRangeAt(0).commonAncestorContainer)
? selection.getRangeAt(0)
: null
if (range && selection) {
const beforeText = plainTextInRange(editor, range, 'before')
const afterText = plainTextInRange(editor, range, 'after')
editor.focus({ preventScroll: true })
range.insertNode(
buildRefFragment(parsed, {
needsAfterSpace: afterText.length === 0 || !/^\s/.test(afterText),
needsBeforeSpace: beforeText.length > 0 && !/\s$/.test(beforeText)
})
)
range.collapse(false)
selection.removeAllRanges()
selection.addRange(range)
if (range) {
const beforeRange = range.cloneRange()
beforeRange.selectNodeContents(editor)
beforeRange.setEnd(range.startContainer, range.startOffset)
const beforeContainer = document.createElement('div')
beforeContainer.appendChild(beforeRange.cloneContents())
const afterRange = range.cloneRange()
afterRange.selectNodeContents(editor)
afterRange.setStart(range.endContainer, range.endOffset)
const afterContainer = document.createElement('div')
afterContainer.appendChild(afterRange.cloneContents())
const beforeText = composerPlainText(beforeContainer)
const afterText = composerPlainText(afterContainer)
const needsBeforeSpace = beforeText.length > 0 && !/\s$/.test(beforeText)
const needsAfterSpace = afterText.length === 0 || !/^\s/.test(afterText)
document.execCommand('insertHTML', false, `${needsBeforeSpace ? ' ' : ''}${refsHtml}${needsAfterSpace ? ' ' : ''}`)
} else {
const current = composerPlainText(editor)
editor.append(
buildRefFragment(parsed, {
needsAfterSpace: true,
needsBeforeSpace: current.length > 0 && !/\s$/.test(current)
})
)
placeCaretEnd(editor)
document.execCommand('insertHTML', false, `${current && !/\s$/.test(current) ? ' ' : ''}${refsHtml} `)
}
normalizeComposerEditorDom(editor)
return composerPlainText(editor)
}

View File

@@ -1,6 +1,7 @@
import { StatusRow } from '@/components/chat/status-row'
import { StatusSection } from '@/components/chat/status-section'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { Tip } from '@/components/ui/tooltip'
import { type Translations, useI18n } from '@/i18n'
import { ArrowUp, Pencil, Trash2 } from '@/lib/icons'
@@ -22,84 +23,108 @@ const entryPreview = (entry: QueuedPromptEntry, c: Translations['composer']) =>
export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendNow }: QueuePanelProps) {
const { t } = useI18n()
const c = t.composer
const [collapsed, setCollapsed] = useState(true)
if (entries.length === 0) {
return null
}
return (
<StatusSection label={c.queued(entries.length)}>
{entries.map(entry => {
const isEditing = editingId === entry.id
const attachmentsCount = entry.attachments.length
<div className="rounded-t-2xl border border-b-0 border-border/65 bg-[color-mix(in_srgb,var(--dt-card)_70%,transparent)] pt-0.5 pb-1 mx-1">
<button
className="flex w-full items-center gap-1.5 px-2 text-left text-[0.6rem] font-medium text-muted-foreground/92 transition-colors hover:text-foreground/90"
onClick={() => setCollapsed(open => !open)}
type="button"
>
<DisclosureCaret className="shrink-0" open={!collapsed} size="1em" />
<span className="truncate">{c.queued(entries.length)}</span>
</button>
return (
<StatusRow
className={cn(
'border border-transparent',
isEditing && 'border-[color-mix(in_srgb,var(--dt-composer-ring)_40%,transparent)] bg-accent/25'
)}
key={entry.id}
trailing={
<>
<Tip label={c.queueEdit}>
<Button
aria-label={c.queueEdit}
className="size-5 rounded-md"
disabled={Boolean(editingId) && !isEditing}
onClick={() => onEdit(entry)}
size="icon-xs"
type="button"
variant="ghost"
>
<Pencil size={11} />
</Button>
</Tip>
<Tip label={busy ? c.queueSendNext : c.queueSend}>
<Button
aria-label={busy ? c.queueSendNext : c.queueSend}
className="size-5 rounded-md"
disabled={isEditing}
onClick={() => onSendNow(entry.id)}
size="icon-xs"
type="button"
variant="ghost"
>
<ArrowUp size={11} />
</Button>
</Tip>
<Tip label={c.queueDelete}>
<Button
aria-label={c.queueDelete}
className="size-5 rounded-md"
onClick={() => onDelete(entry.id)}
size="icon-xs"
type="button"
variant="ghost"
>
<Trash2 size={11} />
</Button>
</Tip>
</>
}
trailingVisible={isEditing}
>
<div className="min-w-0 flex-1">
<p className="truncate text-[0.73rem] leading-4 text-foreground/92">{entryPreview(entry, c)}</p>
{(attachmentsCount > 0 || isEditing) && (
<div className="mt-0.5 flex items-center gap-1.5 text-[0.64rem] text-muted-foreground/75">
{attachmentsCount > 0 && <span>{c.attachments(attachmentsCount)}</span>}
{isEditing && (
<span className="text-[color-mix(in_srgb,var(--dt-composer-ring)_78%,var(--muted-foreground))]">
{c.editingInComposer}
</span>
{!collapsed && (
<div className="space-y-0.5 px-1 pb-0.5">
{entries.map(entry => {
const isEditing = editingId === entry.id
const attachmentsCount = entry.attachments.length
const sendLabel = busy ? c.sendQueuedNext : c.sendQueuedNow
return (
<div
className={cn(
'group/queue-row flex items-center gap-1.5 rounded-lg border border-transparent px-1.5 py-0.5',
'transition-colors duration-300 ease-out hover:bg-(--chrome-action-hover) hover:transition-none',
isEditing && 'border-[color-mix(in_srgb,var(--dt-composer-ring)_40%,transparent)] bg-accent/25'
)}
key={entry.id}
>
<span
aria-hidden
className="h-3.5 w-3.5 shrink-0 rounded-full border border-foreground/35 bg-transparent"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-[0.73rem] leading-4 text-foreground/92">{entryPreview(entry, c)}</p>
{(attachmentsCount > 0 || isEditing) && (
<div className="mt-0.5 flex items-center gap-1.5 text-[0.64rem] text-muted-foreground/75">
{attachmentsCount > 0 && <span>{c.attachments(attachmentsCount)}</span>}
{isEditing && (
<span className="text-[color-mix(in_srgb,var(--dt-composer-ring)_78%,var(--muted-foreground))]">
{c.editingInComposer}
</span>
)}
</div>
)}
</div>
)}
</div>
</StatusRow>
)
})}
</StatusSection>
<div
className={cn(
'flex shrink-0 items-center gap-0 transition-opacity',
isEditing
? 'opacity-100'
: 'opacity-0 group-hover/queue-row:opacity-100 group-focus-within/queue-row:opacity-100'
)}
>
<Tip label={c.editQueued}>
<Button
aria-label={c.editQueued}
className="h-5 w-5 rounded-md"
disabled={Boolean(editingId) && !isEditing}
onClick={() => onEdit(entry)}
size="icon-xs"
type="button"
variant="ghost"
>
<Pencil size={11} />
</Button>
</Tip>
<Tip label={sendLabel}>
<Button
aria-label={sendLabel}
className="h-5 w-5 rounded-md"
disabled={isEditing}
onClick={() => onSendNow(entry.id)}
size="icon-xs"
type="button"
variant="ghost"
>
<ArrowUp size={11} />
</Button>
</Tip>
<Tip label={c.deleteQueued}>
<Button
aria-label={c.deleteQueued}
className="h-5 w-5 rounded-md"
onClick={() => onDelete(entry.id)}
size="icon-xs"
type="button"
variant="ghost"
>
<Trash2 size={11} />
</Button>
</Tip>
</div>
</div>
)
})}
</div>
)}
</div>
)
}

View File

@@ -1,13 +1,6 @@
import { describe, expect, it } from 'vitest'
import { insertInlineRefsIntoEditor } from './inline-refs'
import {
composerPlainText,
normalizeComposerEditorDom,
refChipElement,
renderComposerContents,
RICH_INPUT_SLOT
} from './rich-editor'
import { composerPlainText, renderComposerContents, RICH_INPUT_SLOT } from './rich-editor'
describe('renderComposerContents', () => {
it('renders refs and raw text without interpreting user text as HTML', () => {
@@ -23,39 +16,3 @@ describe('renderComposerContents', () => {
expect(composerPlainText(editor)).toBe('@file:`<img src=x onerror=alert(1)>` <b>raw</b>')
})
})
describe('normalizeComposerEditorDom', () => {
it('unwraps a single insertHTML wrapper div so plain text stays one line', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.innerHTML = '<div><span data-ref-text="@file:`src/foo.ts`" contenteditable="false">foo.ts</span> </div>'
normalizeComposerEditorDom(editor)
expect(composerPlainText(editor)).toBe('@file:`src/foo.ts` ')
expect(editor.querySelector(':scope > div')).toBeNull()
})
it('removes a trailing br after a ref chip', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.append(refChipElement('file', '`src/foo.ts`'), document.createElement('br'))
normalizeComposerEditorDom(editor)
expect(composerPlainText(editor)).toBe('@file:`src/foo.ts`')
expect(editor.querySelector('br')).toBeNull()
})
})
describe('insertInlineRefsIntoEditor', () => {
it('inserts chips without wrapper divs or spurious newlines', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
insertInlineRefsIntoEditor(editor, ['@file:`src/foo.ts`'])
expect(editor.querySelector(':scope > div')).toBeNull()
expect(composerPlainText(editor)).toBe('@file:`src/foo.ts` ')
})
})

View File

@@ -184,36 +184,3 @@ export function placeCaretEnd(element: HTMLElement) {
selection?.removeAllRanges()
selection?.addRange(range)
}
/** Drop contenteditable junk that serializes as `\n` and falsely expands the composer. */
export function normalizeComposerEditorDom(editor: HTMLElement) {
if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') {
editor.replaceChildren()
return
}
if (editor.childNodes.length === 1 && editor.firstChild?.nodeType === Node.ELEMENT_NODE) {
const wrapper = editor.firstChild as HTMLElement
if (wrapper.tagName === 'DIV' && wrapper.dataset.slot !== RICH_INPUT_SLOT) {
editor.replaceChildren(...Array.from(wrapper.childNodes))
}
}
const last = editor.lastChild
if (last?.nodeName !== 'BR') {
return
}
let prev: ChildNode | null = last.previousSibling
while (prev?.nodeType === Node.TEXT_NODE && !(prev.textContent || '').trim()) {
prev = prev.previousSibling
}
if ((prev as HTMLElement | null)?.dataset.refText) {
editor.removeChild(last)
}
}

View File

@@ -1,202 +0,0 @@
import { useStore } from '@nanostores/react'
import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { blurComposerInput } from '@/app/chat/composer/focus'
import { AGENTS_ROUTE } from '@/app/routes'
import { composerDockCard } from '@/components/chat/composer-dock'
import { StatusSection } from '@/components/chat/status-section'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { type Translations, useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import {
$statusItemsBySession,
type ComposerStatusItem,
dismissBackgroundProcess,
groupStatusItems,
refreshBackgroundProcesses,
type StatusGroup,
stopBackgroundProcess
} from '@/store/composer-status'
import { $threadScrolledUp } from '@/store/thread-scroll'
import { openSessionInNewWindow } from '@/store/windows'
import { StatusItemRow } from './status-row'
// Slow safety-net poll for silent exits (processes without notify_on_complete
// emit no event when they die). Only armed while a running row is on screen.
const BACKGROUND_POLL_MS = 5_000
const groupLabel = (group: StatusGroup, s: Translations['statusStack']) => {
if (group.type === 'todo') {
return s.todos(group.items.filter(i => i.todoStatus === 'completed').length, group.items.length)
}
return group.type === 'subagent' ? s.subagents(group.items.length) : s.background(group.items.length)
}
interface ComposerStatusStackProps {
/** The queue, built by the composer (it owns the queue's callbacks). Rendered
* as the last group so it stays fused to the composer like before. */
queue: ReactNode
sessionId: null | string
}
/**
* The status "sink" above the composer: one card (the queue's chrome) holding
* every session-scoped status — subagents, background tasks, queue — grouped by
* type and separated by light dividers. Collapses to nothing when empty.
*/
export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackProps) {
const { t } = useI18n()
const navigate = useNavigate()
const itemsBySession = useStore($statusItemsBySession)
const scrolledUp = useStore($threadScrolledUp)
const groups = useMemo(
() => groupStatusItems(sessionId ? (itemsBySession[sessionId] ?? []) : []),
[itemsBySession, sessionId]
)
// Seed from the registry on session open; event-driven refreshes (terminal /
// process tool completions) live in use-message-stream.
useEffect(() => {
if (sessionId) {
void refreshBackgroundProcesses(sessionId)
}
}, [sessionId])
const hasRunningBackground = groups.some(g => g.type === 'background' && g.items.some(i => i.state === 'running'))
useEffect(() => {
if (!sessionId || !hasRunningBackground) {
return
}
const timer = setInterval(() => void refreshBackgroundProcesses(sessionId), BACKGROUND_POLL_MS)
return () => clearInterval(timer)
}, [hasRunningBackground, sessionId])
const openAgents = () => navigate(AGENTS_ROUTE)
const openSubagent = (item: ComposerStatusItem) =>
item.sessionId ? void openSessionInNewWindow(item.sessionId, { watch: true }) : openAgents()
const sections: { key: string; node: ReactNode }[] = groups.map(group => ({
key: group.type,
node: (
<StatusSection
accessory={
group.type === 'subagent' ? (
<Button
className="text-muted-foreground/75 hover:text-foreground/90"
onClick={openAgents}
size="micro"
type="button"
variant="text"
>
{t.statusStack.agents}
</Button>
) : undefined
}
defaultCollapsed={group.type !== 'todo'}
icon={
group.type === 'todo' ? (
<Codicon className="text-muted-foreground/70" name="checklist" size="0.8rem" />
) : undefined
}
label={groupLabel(group, t.statusStack)}
>
{group.items.map(item => (
<StatusItemRow
item={item}
key={item.id}
onDismiss={sessionId ? id => dismissBackgroundProcess(sessionId, id) : undefined}
onOpen={() => openSubagent(item)}
onStop={sessionId ? id => stopBackgroundProcess(sessionId, id) : undefined}
/>
))}
</StatusSection>
)
}))
if (queue) {
sections.push({ key: 'queue', node: queue })
}
const visible = sections.length > 0
const stackRef = useRef<HTMLDivElement | null>(null)
// The stack is out of flow (overlays the thread), so the composer's measured
// height never sees it. Publish our own measured height — bucketed like the
// composer's, to avoid style invalidation churn — so the thread's
// last-message clearance can add it and the stack never hides messages.
useLayoutEffect(() => {
const root = document.documentElement
const el = stackRef.current
if (!visible || !el) {
root.style.removeProperty('--status-stack-measured-height')
return
}
let last = -1
const sync = () => {
const bucket = Math.round(el.getBoundingClientRect().height / 8) * 8
if (bucket !== last) {
last = bucket
root.style.setProperty('--status-stack-measured-height', `${bucket}px`)
}
}
const observer = new ResizeObserver(sync)
observer.observe(el)
sync()
return () => {
observer.disconnect()
root.style.removeProperty('--status-stack-measured-height')
}
}, [visible])
if (!visible) {
return null
}
return (
<div
// Sits above the composer (bottom-full), nudged down by the shell's 0.5rem
// top pad (pt-2 on composer-root) plus 1px so its bottom edge overlaps the
// composer surface's top border. z BELOW the surface (z-4) so the surface's
// top border paints over our transparent bottom border — one seam, no
// double line.
className="absolute inset-x-0 bottom-full z-3 max-h-[40vh] translate-y-[calc(0.5rem+1px)] overflow-y-auto"
onPointerDownCapture={() => blurComposerInput()}
ref={stackRef}
>
{/* The card paints the shared --composer-fill (rest / scrolled / focused
all match the composer surface by construction); on scroll we only
ghost the CONTENT — element opacity on the card would kill the blur.
Rounded top, square bottom; the bottom border is TRANSPARENT — the
composer surface's visible top border (which sits at a higher z) is the
single shared seam, so the two read as one fused capsule. */}
<div className={cn(composerDockCard('top'), 'mx-2 rounded-b-none border-b border-b-transparent pt-0.5 pb-1')}>
<div
className={cn(
'transition-opacity duration-200 ease-out',
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100' : 'opacity-100'
)}
>
{sections.map(section => (
<div key={section.key}>{section.node}</div>
))}
</div>
</div>
</div>
)
}

View File

@@ -1,155 +0,0 @@
import { Fragment, memo, type ReactNode, useState } from 'react'
import { StatusRow } from '@/components/chat/status-row'
import { TerminalOutput } from '@/components/chat/terminal-output'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { Tip } from '@/components/ui/tooltip'
import { type Translations, useI18n } from '@/i18n'
import { ArrowUpRight, X } from '@/lib/icons'
import type { TodoStatus } from '@/lib/todos'
import { cn } from '@/lib/utils'
import type { ComposerStatusItem } from '@/store/composer-status'
const toolLabel = (name: string) =>
name
.split('_')
.filter(Boolean)
.map(part => part[0]!.toUpperCase() + part.slice(1))
.join(' ') || name
// Todo rows speak checkbox, not spinner-and-dot: a dashed ring while the item
// is still open (pending), codicons once it resolves, a live spinner only on
// the in-progress item.
const TODO_GLYPHS: Record<Exclude<TodoStatus, 'in_progress' | 'pending'>, { icon: string; tone: string }> = {
cancelled: { icon: 'circle-slash', tone: 'text-muted-foreground/45' },
completed: { icon: 'pass-filled', tone: 'text-emerald-500/80' }
}
// Left slot: braille spinner while running, otherwise a small status dot
// (green = done, red = failed) so the slot is always filled and rows align.
function leadingGlyph(item: ComposerStatusItem, s: Translations['statusStack']): ReactNode {
if (item.todoStatus === 'pending') {
return (
<span
aria-hidden
className="box-border size-[0.7rem] rounded-full border border-dashed border-muted-foreground/60"
/>
)
}
if (item.todoStatus && item.todoStatus !== 'in_progress') {
const glyph = TODO_GLYPHS[item.todoStatus]
return <Codicon className={glyph.tone} name={glyph.icon} size="0.8rem" />
}
if (item.state === 'running') {
return (
<GlyphSpinner
ariaLabel={s.running}
className="text-[0.9rem] leading-none text-muted-foreground/80"
spinner="braille"
/>
)
}
return (
<span
aria-hidden
className={cn('size-1.5 rounded-full', item.state === 'failed' ? 'bg-destructive/80' : 'bg-emerald-500/70')}
/>
)
}
interface StatusItemRowProps {
item: ComposerStatusItem
/** Clear a finished background task from the stack. */
onDismiss?: (id: string) => void
/** Open the subagent's own session window, livestreamed by the gateway's
* child-session mirror (Agents view fallback for older gateways). */
onOpen?: () => void
/** Cancel a running background task. */
onStop?: (id: string) => void
}
/**
* Renders one {@link ComposerStatusItem} into the shared {@link StatusRow}.
* Memoised + keyed by id so parent re-renders never remount it (the spinner
* keeps ticking instead of resetting).
*/
export const StatusItemRow = memo(function StatusItemRow({ item, onDismiss, onOpen, onStop }: StatusItemRowProps) {
const { t } = useI18n()
const s = t.statusStack
const [outputOpen, setOutputOpen] = useState(false)
const failed = item.state === 'failed'
const running = item.state === 'running'
const action =
item.type === 'background'
? running
? onStop && { label: s.stop, onClick: () => onStop(item.id) }
: onDismiss && { label: s.dismiss, onClick: () => onDismiss(item.id) }
: null
const canOpen = item.type === 'subagent' && !!onOpen
const hasOutput = item.type === 'background' && !!item.output
const onActivate = canOpen ? onOpen : hasOutput ? () => setOutputOpen(open => !open) : undefined
return (
<Fragment>
<StatusRow
leading={leadingGlyph(item, s)}
onActivate={onActivate}
trailing={
action ? (
<Tip label={action.label}>
<Button
aria-label={action.label}
className="-my-1 size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
onClick={event => {
event.stopPropagation()
action.onClick()
}}
size="icon-xs"
type="button"
variant="ghost"
>
<X size={12} />
</Button>
</Tip>
) : canOpen ? (
<ArrowUpRight aria-hidden className="size-3.5 text-muted-foreground/55" />
) : undefined
}
>
<span
className={cn(
'min-w-0 max-w-[18rem] truncate text-[0.73rem] leading-4',
failed
? 'text-destructive/90'
: item.todoStatus && item.todoStatus !== 'in_progress'
? 'text-muted-foreground/75'
: 'text-foreground/92'
)}
>
{item.title}
</span>
{item.type === 'subagent' && item.currentTool && (
<span className="shrink-0 truncate text-[0.62rem] leading-4 text-muted-foreground/70">
{toolLabel(item.currentTool)}
</span>
)}
{failed && typeof item.exitCode === 'number' && item.exitCode !== 0 && (
<span className="shrink-0 rounded bg-destructive/15 px-1 text-[0.58rem] font-semibold text-destructive tabular-nums">
{s.exit(item.exitCode)}
</span>
)}
{hasOutput && <DisclosureCaret className="shrink-0 text-muted-foreground/45" open={outputOpen} size="0.8em" />}
</StatusRow>
{hasOutput && outputOpen && <TerminalOutput className="mx-auto mb-1 max-w-[90%]" text={item.output!} />}
</Fragment>
)
})

View File

@@ -1,12 +1,16 @@
import type { Unstable_TriggerItem } from '@assistant-ui/core'
import { Fragment } from 'react'
import { BrailleSpinner } from '@/components/ui/braille-spinner'
import { Codicon } from '@/components/ui/codicon'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import { COMPLETION_DRAWER_BELOW_CLASS, COMPLETION_DRAWER_CLASS, CompletionDrawerEmpty } from './completion-drawer'
import {
COMPLETION_DRAWER_BELOW_CLASS,
COMPLETION_DRAWER_CLASS,
CompletionDrawerEmpty
} from './completion-drawer'
const AT_ICON_BY_TYPE: Record<string, string> = {
diff: 'diff',
@@ -83,7 +87,7 @@ export function ComposerTriggerPopover({
{items.length === 0 ? (
loading ? (
<div className="flex items-center gap-2 px-2 py-1.5 text-(--ui-text-tertiary)">
<GlyphSpinner ariaLabel={copy.lookupLoading} className="text-foreground/70" spinner="braille" />
<BrailleSpinner ariaLabel={copy.lookupLoading} className="text-foreground/70" spinner="braille" />
<span>{copy.lookupLoading}</span>
</div>
) : (

View File

@@ -1,7 +1,6 @@
import { useCallback } from 'react'
import { requestComposerFocus, requestComposerInsert, requestComposerInsertRefs } from '@/app/chat/composer/focus'
import { droppedFileInlineRef } from '@/app/chat/composer/inline-refs'
import { requestComposerFocus, requestComposerInsert } from '@/app/chat/composer/focus'
import { formatRefValue } from '@/components/assistant-ui/directive-text'
import { useI18n } from '@/i18n'
import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime'
@@ -287,26 +286,6 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
[currentCwd]
)
const insertContextPathInlineRef = useCallback(
(path: string, isDirectory = false) => {
if (!path) {
return false
}
const ref = droppedFileInlineRef({ isDirectory, path }, currentCwd)
if (!ref) {
return false
}
requestComposerInsertRefs([ref])
requestComposerFocus('main')
return true
},
[currentCwd]
)
const attachContextFilePath = useCallback(
(filePath: string) => {
if (!filePath) {
@@ -567,7 +546,6 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
attachDroppedItems,
attachImageBlob,
attachImagePath,
insertContextPathInlineRef,
pasteClipboardImage,
pickContextPaths,
pickImages,

View File

@@ -35,9 +35,7 @@ import {
$gatewayState,
$introPersonality,
$introSeed,
$lastVisibleMessageIsUser,
$messages,
$messagesEmpty,
$selectedStoredSessionId,
$sessions,
sessionPinId
@@ -45,7 +43,7 @@ import {
import type { ModelOptionsResponse } from '@/types/hermes'
import { routeSessionId } from '../routes'
import { titlebarHeaderBaseClass, titlebarHeaderShadowClass, titlebarHeaderTitleClass } from '../shell/titlebar'
import { titlebarHeaderBaseClass, titlebarHeaderShadowClass } from '../shell/titlebar'
import { ChatDropOverlay } from './chat-drop-overlay'
import { ChatSwapOverlay } from './chat-swap-overlay'
@@ -55,9 +53,8 @@ import { droppedFileInlineRefs, type SessionDragPayload, sessionInlineRef } from
import type { ChatBarState } from './composer/types'
import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions'
import { useFileDropZone } from './hooks/use-file-drop-zone'
import { ScrollToBottomButton } from './scroll-to-bottom-button'
import { SessionActionsMenu } from './sidebar/session-actions-menu'
import { threadLoadingState } from './thread-loading'
import { lastVisibleMessageIsUser, threadLoadingState } from './thread-loading'
interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
gateway: HermesGateway | null
@@ -83,7 +80,6 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
onEdit: (message: AppendMessage) => Promise<void>
onReload: (parentId: string | null) => Promise<void>
onRestoreToMessage?: (messageId: string) => Promise<void>
onTranscribeAudio?: (audio: Blob) => Promise<string>
}
@@ -129,7 +125,7 @@ function ChatHeader({
return (
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
<div
className={titlebarHeaderTitleClass}
className="min-w-0 flex-1"
style={{
maxWidth:
'calc(100vw - var(--titlebar-content-inset,0px) - var(--titlebar-tools-right) - var(--titlebar-tools-width) - 1.5rem)'
@@ -145,7 +141,7 @@ function ChatHeader({
title={title}
>
<Button
className="pointer-events-auto flex h-6 min-w-0 max-w-full gap-1 overflow-hidden border border-transparent bg-transparent px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-control-active-background) [-webkit-app-region:no-drag]"
className="pointer-events-auto flex h-6 min-w-0 max-w-full gap-1 border border-transparent bg-transparent px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-control-active-background) [-webkit-app-region:no-drag]"
type="button"
variant="ghost"
>
@@ -158,42 +154,104 @@ function ChatHeader({
)
}
interface ChatRuntimeBoundaryProps {
busy: boolean
children: React.ReactNode
onCancel: () => Promise<void> | void
onEdit: (message: AppendMessage) => Promise<void>
onReload: (parentId: string | null) => Promise<void>
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
/** Route points at an unloaded session — render empty until resume swaps in
* the new transcript, so the previous session's messages don't linger. */
suppressMessages: boolean
}
const NO_MESSAGES: ChatMessage[] = []
/**
* Owns the $messages subscription and the assistant-ui external-store runtime.
*
* Isolated from ChatView so the per-token delta flush (which replaces the
* $messages atom ~30×/s during streaming) only re-renders this component and
* the runtime provider. The children (Thread, ChatBar) are created by
* ChatView, whose render output is stable across flushes — so React bails out
* of re-rendering them by element identity and the stream's render cost stays
* confined to the streaming message's own subtree.
*/
function ChatRuntimeBoundary({
busy,
children,
export function ChatView({
className,
gateway,
onToggleSelectedPin,
onDeleteSelectedSession,
onCancel,
onAddContextRef,
onAddUrl,
onAttachImageBlob,
onAttachDroppedItems,
onBranchInNewChat,
maxVoiceRecordingSeconds,
onPasteClipboardImage,
onPickFiles,
onPickFolders,
onPickImages,
onRemoveAttachment,
onSteer,
onSubmit,
onThreadMessagesChange,
onEdit,
onReload,
onThreadMessagesChange,
suppressMessages
}: ChatRuntimeBoundaryProps) {
const storeMessages = useStore($messages)
const messages = suppressMessages ? NO_MESSAGES : storeMessages
onTranscribeAudio
}: ChatViewProps) {
const location = useLocation()
const activeSessionId = useStore($activeSessionId)
const awaitingResponse = useStore($awaitingResponse)
const busy = useStore($busy)
const contextSuggestions = useStore($contextSuggestions)
const currentCwd = useStore($currentCwd)
const currentModel = useStore($currentModel)
const currentProvider = useStore($currentProvider)
const freshDraftReady = useStore($freshDraftReady)
const gatewayState = useStore($gatewayState)
const gatewaySwapTarget = useStore($gatewaySwapTarget)
const gatewayOpen = gatewayState === 'open'
const introPersonality = useStore($introPersonality)
const introSeed = useStore($introSeed)
const messages = useStore($messages)
const selectedSessionId = useStore($selectedStoredSessionId)
const runtimeMessageCacheRef = useRef(new WeakMap<ChatMessage, ThreadMessage>())
const isRoutedSessionView = Boolean(routeSessionId(location.pathname))
const showIntro =
freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messages.length === 0
// Session is still loading if the route references a session we haven't
// resumed yet. Once `activeSessionId` is set (runtime has resumed), the
// session exists — even if it has zero messages (a brand-new routed
// session). The flicker where `busy` flips true briefly during hydrate
// is handled by `threadLoadingState`'s last-visible-user gate.
const loadingSession = isRoutedSessionView && messages.length === 0 && !activeSessionId
const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleMessageIsUser(messages))
const showChatBar = !loadingSession
const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new')
const modelOptionsQuery = useQuery<ModelOptionsResponse>({
queryKey: ['model-options', activeSessionId || 'global'],
queryFn: () => {
if (!activeSessionId) {
return getGlobalModelOptions()
}
if (!gateway) {
throw new Error('Hermes gateway unavailable')
}
return gateway.request<ModelOptionsResponse>('model.options', { session_id: activeSessionId })
},
enabled: gatewayOpen
})
const quickModels = useMemo(
() => quickModelOptions(modelOptionsQuery.data, currentProvider, currentModel),
[currentModel, currentProvider, modelOptionsQuery.data]
)
const chatBarState = useMemo<ChatBarState>(
() => ({
model: {
model: currentModel,
provider: currentProvider,
canSwitch: gatewayOpen,
loading: !gatewayOpen || (!currentModel && !currentProvider),
quickModels
},
tools: {
enabled: true,
label: 'Add context',
suggestions: contextSuggestions
},
voice: {
enabled: true,
active: false
}
}),
[contextSuggestions, currentModel, currentProvider, gatewayOpen, quickModels]
)
const runtimeMessageRepository = useMemo(() => {
const items: { message: ThreadMessage; parentId: string | null }[] = []
@@ -243,120 +301,6 @@ function ChatRuntimeBoundary({
onReload
})
return <AssistantRuntimeProvider runtime={runtime}>{children}</AssistantRuntimeProvider>
}
export function ChatView({
className,
gateway,
onToggleSelectedPin,
onDeleteSelectedSession,
onCancel,
onAddContextRef,
onAddUrl,
onAttachImageBlob,
onAttachDroppedItems,
onBranchInNewChat,
maxVoiceRecordingSeconds,
onPasteClipboardImage,
onPickFiles,
onPickFolders,
onPickImages,
onRemoveAttachment,
onSteer,
onSubmit,
onThreadMessagesChange,
onEdit,
onReload,
onRestoreToMessage,
onTranscribeAudio
}: ChatViewProps) {
const location = useLocation()
const activeSessionId = useStore($activeSessionId)
const awaitingResponse = useStore($awaitingResponse)
const busy = useStore($busy)
const contextSuggestions = useStore($contextSuggestions)
const currentCwd = useStore($currentCwd)
const currentModel = useStore($currentModel)
const currentProvider = useStore($currentProvider)
const freshDraftReady = useStore($freshDraftReady)
const gatewayState = useStore($gatewayState)
const gatewaySwapTarget = useStore($gatewaySwapTarget)
const gatewayOpen = gatewayState === 'open'
const introPersonality = useStore($introPersonality)
const introSeed = useStore($introSeed)
// PERF: ChatView must not subscribe to $messages — the atom is replaced on
// every streaming delta flush (~30×/s) and a subscription here re-renders
// the entire chat shell (header, chat bar, thread wrapper) per token. The
// runtime that DOES need the messages lives in ChatRuntimeBoundary below;
// this component only needs streaming-stable derivations.
const messagesEmpty = useStore($messagesEmpty)
const lastVisibleIsUser = useStore($lastVisibleMessageIsUser)
const selectedSessionId = useStore($selectedStoredSessionId)
const routedSessionId = routeSessionId(location.pathname)
const isRoutedSessionView = Boolean(routedSessionId)
// The URL points at a session the store hasn't loaded yet (sidebar / cmd-K /
// direct nav). Derived in render so the swap reads instantly: the same frame
// the id changes we drop the old transcript and show the loader, instead of
// waiting for the resume effect (which paints a frame later) to clear them.
const routeSessionMismatch = isRoutedSessionView && routedSessionId !== selectedSessionId
const showIntro = freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messagesEmpty
// Session is still loading if the route references a session we haven't
// resumed yet. Once `activeSessionId` is set (runtime has resumed), the
// session exists — even if it has zero messages (a brand-new routed
// session). The flicker where `busy` flips true briefly during hydrate
// is handled by `threadLoadingState`'s last-visible-user gate.
const loadingSession = isRoutedSessionView && (routeSessionMismatch || (messagesEmpty && !activeSessionId))
const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleIsUser)
const showChatBar = !loadingSession
const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new')
const modelOptionsQuery = useQuery<ModelOptionsResponse>({
queryKey: ['model-options', activeSessionId || 'global'],
queryFn: () => {
if (!activeSessionId) {
return getGlobalModelOptions()
}
if (!gateway) {
throw new Error('Hermes gateway unavailable')
}
return gateway.request<ModelOptionsResponse>('model.options', { session_id: activeSessionId })
},
enabled: gatewayOpen
})
const quickModels = useMemo(
() => quickModelOptions(modelOptionsQuery.data, currentProvider, currentModel),
[currentModel, currentProvider, modelOptionsQuery.data]
)
const chatBarState = useMemo<ChatBarState>(
() => ({
model: {
model: currentModel,
provider: currentProvider,
canSwitch: gatewayOpen,
loading: !gatewayOpen || (!currentModel && !currentProvider),
quickModels
},
tools: {
enabled: true,
label: 'Add context',
suggestions: contextSuggestions
},
voice: {
enabled: true,
active: false
}
}),
[contextSuggestions, currentModel, currentProvider, gatewayOpen, quickModels]
)
// Drop files anywhere in the conversation area, not just on the composer
// input. In-app drags (project tree / gutter) carry workspace-relative paths
// the gateway resolves directly, so they stay inline `@file:` refs. OS/Finder
@@ -409,14 +353,7 @@ export function ChatView({
className="relative min-h-0 max-w-full flex-1 overflow-hidden bg-(--ui-chat-surface-background) contain-[layout_paint]"
{...dropHandlers}
>
<ChatRuntimeBoundary
busy={busy}
onCancel={onCancel}
onEdit={onEdit}
onReload={onReload}
onThreadMessagesChange={onThreadMessagesChange}
suppressMessages={routeSessionMismatch}
>
<AssistantRuntimeProvider runtime={runtime}>
<Thread
clampToComposer={showChatBar}
cwd={currentCwd}
@@ -425,7 +362,6 @@ export function ChatView({
loading={threadLoading}
onBranchInNewChat={onBranchInNewChat}
onCancel={onCancel}
onRestoreToMessage={onRestoreToMessage}
sessionId={activeSessionId}
sessionKey={threadKey}
/>
@@ -451,14 +387,13 @@ export function ChatView({
onSteer={onSteer}
onSubmit={onSubmit}
onTranscribeAudio={onTranscribeAudio}
queueSessionKey={selectedSessionId}
queueSessionKey={selectedSessionId || activeSessionId}
sessionId={activeSessionId}
state={chatBarState}
/>
</Suspense>
)}
</ChatRuntimeBoundary>
{showChatBar && <ScrollToBottomButton />}
</AssistantRuntimeProvider>
<ChatDropOverlay kind={dragKind} />
<ChatSwapOverlay profile={gatewaySwapTarget} />
</div>

View File

@@ -10,16 +10,11 @@ import { useEffect, useMemo, useState } from 'react'
import ShikiHighlighter from 'react-shiki'
import { Streamdown } from 'streamdown'
import { requestComposerFocus, requestComposerInsertRefs } from '@/app/chat/composer/focus'
import { droppedFileInlineRef } from '@/app/chat/composer/inline-refs'
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
import { isAddSelectionShortcut } from '@/app/right-sidebar/terminal/selection'
import { PageLoader } from '@/components/page-loader'
import { translateNow, useI18n } from '@/i18n'
import { readDesktopFileDataUrl, readDesktopFileText } from '@/lib/desktop-fs'
import { cn } from '@/lib/utils'
import type { PreviewTarget } from '@/store/preview'
import { $currentCwd } from '@/store/session'
const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const
const TEXT_PREVIEW_MAX_BYTES = 512 * 1024
@@ -185,13 +180,15 @@ function looksBinaryBytes(bytes: Uint8Array) {
}
async function readTextPreview(filePath: string) {
try {
return await readDesktopFileText(filePath)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (window.hermesDesktop.readFileText) {
try {
return await window.hermesDesktop.readFileText(filePath)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (!message.includes("No handler registered for 'hermes:readFileText'")) {
throw error
if (!message.includes("No handler registered for 'hermes:readFileText'")) {
throw error
}
}
}
@@ -291,7 +288,7 @@ const MARKDOWN_COMPONENTS = {
function MarkdownPreview({ text }: { text: string }) {
return (
<div className="preview-markdown mx-auto max-w-3xl px-4 py-3 text-sm text-foreground" data-selectable-text="true">
<div className="preview-markdown mx-auto max-w-3xl px-4 py-3 text-sm text-foreground">
<Streamdown components={MARKDOWN_COMPONENTS} controls={false} mode="static" parseIncompleteMarkdown={false}>
{text}
</Streamdown>
@@ -361,38 +358,6 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
startLineDrag(event, filePath, inSelection(line) && selection ? selection : { end: line, start: line })
}
// ⌘/Ctrl+L with a line selection drops the same `@line:path:start-end` ref the
// gutter drag produces — so the keyboard path mirrors dragging the lines into
// the composer. Capture-phase + stopPropagation so it beats the terminal's
// global ⌘L handler (which would otherwise grab the native text selection).
useEffect(() => {
if (!selection) {
return
}
const onKeyDown = (event: KeyboardEvent) => {
if (!isAddSelectionShortcut(event)) {
return
}
const lineEnd = selection.end > selection.start ? selection.end : undefined
const ref = droppedFileInlineRef({ line: selection.start, lineEnd, path: filePath }, $currentCwd.get())
if (!ref) {
return
}
event.preventDefault()
event.stopPropagation()
requestComposerInsertRefs([ref])
requestComposerFocus('main')
}
window.addEventListener('keydown', onKeyDown, { capture: true })
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [filePath, selection])
return (
<div className="grid min-w-max grid-cols-[auto_minmax(0,1fr)] font-mono text-xs leading-relaxed">
<div className="select-none py-3 text-right text-muted-foreground/55">
@@ -419,10 +384,7 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
)
})}
</div>
<div
className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3 [&_pre]:bg-transparent!"
data-selectable-text="true"
>
<div className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3 [&_pre]:bg-transparent!">
{selection && (
<div
aria-hidden
@@ -486,7 +448,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
if (isImage) {
// Prefer bytes the caller already handed us (a pasted/dropped
// screenshot) over re-reading a path that may be transient/unreadable.
const dataUrl = target.dataUrl || (await readDesktopFileDataUrl(filePath))
const dataUrl = target.dataUrl || (await window.hermesDesktop.readFileDataUrl(filePath))
if (active) {
setState({ dataUrl, loading: false })

View File

@@ -1,50 +1,11 @@
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $connection } from '@/store/session'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PreviewPane } from './preview-pane'
describe('PreviewPane console state', () => {
beforeEach(() => {
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => window.setTimeout(() => callback(Date.now()), 0))
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
})
afterEach(() => {
cleanup()
$connection.set(null)
vi.unstubAllGlobals()
})
it('does not watch backend-only remote filesystem previews locally', () => {
const watchPreviewFile = vi.fn(async () => ({ id: 'watch-1', path: '/remote/file.txt' }))
const onPreviewFileChanged = vi.fn(() => vi.fn())
$connection.set({ mode: 'remote' } as never)
vi.stubGlobal('window', {
...window,
hermesDesktop: {
onPreviewFileChanged,
watchPreviewFile
}
})
render(
<PreviewPane
setTitlebarToolGroup={vi.fn()}
target={{
kind: 'file',
label: 'file.txt',
path: '/remote/file.txt',
previewKind: 'text',
source: '/remote/file.txt',
url: 'file:///remote/file.txt'
}}
/>
)
expect(watchPreviewFile).not.toHaveBeenCalled()
expect(onPreviewFileChanged).not.toHaveBeenCalled()
})
it('does not rebuild the pane titlebar group for streamed console logs', () => {

View File

@@ -5,7 +5,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls'
import { Tip } from '@/components/ui/tooltip'
import { type Translations, useI18n } from '@/i18n'
import { isDesktopFsRemoteMode } from '@/lib/desktop-fs'
import { Bug } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
@@ -407,7 +406,6 @@ export function PreviewPane({
useEffect(() => {
if (
target.kind !== 'file' ||
isDesktopFsRemoteMode() ||
!window.hermesDesktop?.watchPreviewFile ||
!window.hermesDesktop?.onPreviewFileChanged
) {

View File

@@ -1,58 +0,0 @@
import { useStore } from '@nanostores/react'
import { useRef } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $threadJumpButtonVisible, requestScrollToBottom } from '@/store/thread-scroll'
/**
* Floating "jump to bottom" control. Sits centered just above the composer,
* clearing the out-of-flow status stack via the same measured-height CSS vars
* the thread's bottom clearance uses (`--composer-measured-height` +
* `--status-stack-measured-height`), so it never overlaps the queue / subagent
* / background cards. Visible only while the user has scrolled meaningfully
* away from the bottom; clicking re-arms sticky-bottom and pins the viewport.
*
* Enter/exit motion lives in styles.css under `.thread-jump-button` — a
* directional scale (contract in from 1.1, contract out to 0.9) keyed off
* `data-state`. `idle` (never-shown) stays silent so it can't flash on mount;
* `in`/`out` only swap once it has actually appeared.
*/
export function ScrollToBottomButton() {
const { t } = useI18n()
const visible = useStore($threadJumpButtonVisible)
const hasShownRef = useRef(false)
if (visible) {
hasShownRef.current = true
}
const state = visible ? 'in' : hasShownRef.current ? 'out' : 'idle'
return (
<button
aria-hidden={!visible}
aria-label={t.assistant.thread.scrollToBottom}
className={cn(
'thread-jump-button absolute left-1/2 z-20 grid size-8 place-items-center rounded-full',
'border border-border/65 bg-(--composer-fill) text-muted-foreground hover:text-foreground',
'backdrop-blur-[0.75rem] [-webkit-backdrop-filter:blur(0.75rem)]',
!visible && 'pointer-events-none'
)}
data-state={state}
onClick={() => {
triggerHaptic('selection')
requestScrollToBottom()
}}
style={{
bottom: 'calc(var(--composer-measured-height) + var(--status-stack-measured-height) + 0.625rem)'
}}
tabIndex={visible ? 0 : -1}
type="button"
>
<Codicon name="arrow-down" size="1rem" />
</button>
)
}

View File

@@ -168,7 +168,7 @@ export function SidebarCronJobsSection({
</button>
</div>
{open && (
<SidebarGroupContent className="flex max-h-72 flex-col gap-px overflow-x-hidden overflow-y-auto overscroll-contain pb-1.75 compact:max-h-none compact:overflow-visible">
<SidebarGroupContent className="flex max-h-72 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75 compact:max-h-none compact:overflow-visible">
{shown.map(job => (
<CronJobSidebarRow
expanded={peekJobId === job.id}

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +0,0 @@
import { describe, expect, it } from 'vitest'
import { resolveManualSessionOrderIds } from './order'
describe('resolveManualSessionOrderIds', () => {
it('clears legacy auto-seeded order until the user manually reorders sessions', () => {
expect(resolveManualSessionOrderIds(['newest', 'older'], ['older', 'newest'], false)).toEqual([])
})
it('keeps a manual order and surfaces newly seen sessions first', () => {
expect(resolveManualSessionOrderIds(['newest', 'older', 'oldest'], ['oldest', 'older'], true)).toEqual([
'newest',
'oldest',
'older'
])
})
it('clears manual order when none of the saved ids still exist', () => {
expect(resolveManualSessionOrderIds(['newest'], ['gone'], true)).toEqual([])
})
})

View File

@@ -1,17 +0,0 @@
export function resolveManualSessionOrderIds(currentIds: string[], orderIds: string[], manual: boolean): string[] {
if (!manual || !currentIds.length || !orderIds.length) {
return []
}
const current = new Set(currentIds)
const retained = orderIds.filter(id => current.has(id))
if (!retained.length) {
return []
}
const retainedSet = new Set(retained)
const fresh = currentIds.filter(id => !retainedSet.has(id))
return [...fresh, ...retained]
}

View File

@@ -467,10 +467,6 @@ function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, on
aria-label={p.actionsFor(label)}
className="w-40"
collisionPadding={{ bottom: 44, left: 8, right: 8, top: 8 }}
// Menu close refocuses the trigger — which doubles as the popover
// anchor — so the picker reads it as focus-outside and dies on open.
// Suppress the refocus and the picker survives.
onCloseAutoFocus={event => event.preventDefault()}
>
<ContextMenuItem onSelect={() => setPickerOpen(true)}>
<Codicon name="symbol-color" size="0.875rem" />

View File

@@ -88,7 +88,7 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
label: r.export,
onSelect: () => {
triggerHaptic('selection')
void exportSession(sessionId, { profile, title })
void exportSession(sessionId, { title })
}
},
{

View File

@@ -96,9 +96,7 @@ export function SidebarSessionRow({
'group relative grid min-h-[1.625rem] cursor-pointer grid-cols-[minmax(0,1fr)_1.375rem] items-center rounded-md transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:transition-none',
isSelected && 'bg-(--ui-row-active-background)',
isWorking && 'text-foreground',
// Opaque surface while lifted so the dragged row erases what's under
// it (translucency let the rows below bleed through).
dragging && 'z-10 cursor-grabbing bg-(--ui-sidebar-surface-background)',
dragging && 'z-10 cursor-grabbing opacity-60 shadow-sm',
className
)}
data-working={isWorking ? 'true' : undefined}

View File

@@ -1,7 +1,7 @@
import { useSortable } from '@dnd-kit/sortable'
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { useVirtualizer } from '@tanstack/react-virtual'
import { type FC, useCallback, useRef } from 'react'
import { type FC, useCallback, useMemo, useRef } from 'react'
import type { SessionInfo } from '@/hermes'
import { cn } from '@/lib/utils'
@@ -48,6 +48,7 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
workingSessionIdSet
}) => {
const scrollerRef = useRef<HTMLDivElement | null>(null)
const ids = useMemo(() => sessions.map(s => s.id), [sessions])
const virtualizer = useVirtualizer({
count: sessions.length,
@@ -100,16 +101,21 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
)
})
// When sortable, the caller wraps this in a ReorderableList that owns the
// DndContext + SortableContext (keyed on the same ids); the virtualized rows
// just consume that context via useSortable.
return (
<div className={cn('relative min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain', className)} ref={scrollerRef}>
const list = (
<div className={cn('relative min-h-0 flex-1 overflow-y-auto overscroll-contain', className)} ref={scrollerRef}>
<div className="grid gap-px" style={{ paddingBottom: `${paddingBottom}px`, paddingTop: `${paddingTop}px` }}>
{rows}
</div>
</div>
)
return sortable ? (
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
{list}
</SortableContext>
) : (
list
)
}
interface VirtualSortableRowProps {

View File

@@ -1,149 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { HermesWorktreeInfo } from '@/global'
import type { SessionInfo } from '@/types/hermes'
import { uniqueCwds, workspaceGroupsFor, workspaceTreeFor, type WorktreeResolver } from './workspace-groups'
let nextId = 0
function makeSession(cwd: null | string, overrides: Partial<SessionInfo> = {}): SessionInfo {
return {
archived: false,
cwd,
ended_at: null,
id: `s${nextId++}`,
input_tokens: 0,
is_active: false,
last_active: 1_000,
message_count: 1,
model: 'claude',
output_tokens: 0,
preview: null,
source: 'cli',
started_at: 1_000,
title: null,
tool_call_count: 0,
...overrides
}
}
const labels = (sessions: SessionInfo[]) => workspaceGroupsFor(sessions, 'No workspace').map(g => g.label)
describe('workspaceGroupsFor', () => {
it('groups by full cwd, not by basename — same-named folders are separate groups', () => {
const groups = workspaceGroupsFor(
[makeSession('/a/hermes-agent/apps/desktop'), makeSession('/a/hermes-agent-wt-rtl/apps/desktop')],
'No workspace'
)
expect(groups).toHaveLength(2)
})
it('disambiguates colliding basenames by walking up the path', () => {
expect(
labels([makeSession('/a/hermes-agent/apps/desktop'), makeSession('/a/hermes-agent-wt-rtl/apps/desktop')])
).toEqual(['hermes-agent/apps/desktop', 'hermes-agent-wt-rtl/apps/desktop'])
})
it('leaves a unique basename as its short label', () => {
expect(labels([makeSession('/a/hermes-agent/apps/desktop'), makeSession('/b/heval-py')])).toEqual([
'desktop',
'heval-py'
])
})
it('grows the prefix past one segment when the parent also collides', () => {
expect(labels([makeSession('/x/proj/apps/desktop'), makeSession('/y/proj/apps/desktop')])).toEqual([
'x/proj/apps/desktop',
'y/proj/apps/desktop'
])
})
it('keeps the synthetic no-workspace group untouched even if a real group shares its label', () => {
const groups = workspaceGroupsFor([makeSession(null), makeSession('/a/No workspace')], 'No workspace')
const noWorkspace = groups.find(g => g.path === null)
expect(noWorkspace?.label).toBe('No workspace')
})
})
const info = (over: Partial<HermesWorktreeInfo> & Pick<HermesWorktreeInfo, 'repoRoot' | 'worktreeRoot'>): HermesWorktreeInfo => ({
branch: null,
isMainWorktree: false,
...over
})
describe('workspaceTreeFor', () => {
it('heuristic nests `<repo>-wt-<branch>` under its sibling repo', () => {
const tree = workspaceTreeFor(
[makeSession('/www/hermes-agent'), makeSession('/www/hermes-agent-wt-rtl')],
'No workspace'
)
expect(tree).toHaveLength(1)
expect(tree[0].label).toBe('hermes-agent')
expect(tree[0].groups.map(g => g.label).sort()).toEqual(['hermes-agent', 'rtl'])
})
it('git metadata is authoritative — worktrees group by repoRoot regardless of directory naming', () => {
const resolver: WorktreeResolver = cwd => {
if (cwd === '/www/hermes-agent') {
return info({ repoRoot: '/www/hermes-agent', worktreeRoot: '/www/hermes-agent', isMainWorktree: true, branch: 'main' })
}
if (cwd === '/elsewhere/ha-rtl') {
return info({ repoRoot: '/www/hermes-agent', worktreeRoot: '/elsewhere/ha-rtl', branch: 'rtl' })
}
return null
}
const tree = workspaceTreeFor(
[makeSession('/www/hermes-agent'), makeSession('/elsewhere/ha-rtl')],
'No workspace',
resolver
)
expect(tree).toHaveLength(1)
expect(tree[0].label).toBe('hermes-agent')
// The main checkout labels by directory (its branch is transient — using it
// would misattribute old sessions to the currently checked-out branch);
// linked worktrees label by branch.
expect(tree[0].groups.map(g => g.label)).toEqual(['hermes-agent', 'rtl'])
})
it('a standalone directory is its own parent (always parent → worktree → sessions)', () => {
const tree = workspaceTreeFor([makeSession('/www/heval-node')], 'No workspace')
expect(tree).toHaveLength(1)
expect(tree[0].label).toBe('heval-node')
expect(tree[0].groups).toHaveLength(1)
expect(tree[0].groups[0].label).toBe('heval-node')
})
it('aggregates session counts across a repos worktrees', () => {
const tree = workspaceTreeFor(
[makeSession('/www/ha'), makeSession('/www/ha-wt-x'), makeSession('/www/ha-wt-x')],
'No workspace'
)
const parent = tree.find(p => p.label === 'ha')
expect(parent?.sessionCount).toBe(3)
})
it('no-workspace sessions form their own parent', () => {
const tree = workspaceTreeFor([makeSession(null)], 'No workspace')
expect(tree).toHaveLength(1)
expect(tree[0].label).toBe('No workspace')
expect(tree[0].path).toBeNull()
})
})
describe('uniqueCwds', () => {
it('dedupes and drops empty/whitespace cwds', () => {
expect(uniqueCwds([makeSession('/a'), makeSession('/a'), makeSession(null), makeSession(' ')])).toEqual(['/a'])
})
})

View File

@@ -1,326 +0,0 @@
import type { HermesWorktreeInfo } from '@/global'
import type { SessionInfo } from '@/hermes'
export interface SidebarSessionGroup {
id: string
label: string
path: null | string
sessions: SessionInfo[]
// Profile color for the ALL-profiles view; absent for workspace groups.
color?: null | string
loadingMore?: boolean
mode?: 'profile' | 'source' | 'workspace'
onLoadMore?: () => void
sourceId?: string
totalCount?: number
}
const NO_WORKSPACE_ID = '__no_workspace__'
/** Path split into segments, ignoring trailing slashes and mixed separators. */
const segments = (path: string): string[] => path.replace(/[/\\]+$/, '').split(/[/\\]/).filter(Boolean)
/** Last path segment. */
export const baseName = (path: string): string | undefined => segments(path).pop()
/** The segments above the basename. */
const parentSegments = (path: string): string[] => segments(path).slice(0, -1)
interface Labelable {
id: string
label: string
path: null | string
}
/**
* Disambiguate groups whose basename collides (worktrees all end in the same
* `apps/desktop`, sibling repos share a folder name, etc.) by walking up the
* path and prepending parent segments until each colliding label is unique —
* e.g. `hermes-agent/desktop` vs `hermes-agent-wt-rtl/desktop`. Groups with a
* unique basename keep their short label untouched.
*/
function disambiguateLabels(groups: Labelable[]): void {
const byLabel = new Map<string, Labelable[]>()
for (const group of groups) {
const bucket = byLabel.get(group.label)
if (bucket) {
bucket.push(group)
} else {
byLabel.set(group.label, [group])
}
}
for (const bucket of byLabel.values()) {
if (bucket.length < 2) {
continue
}
// Only groups backed by a real path can grow a prefix; the synthetic
// "No workspace" group has no path and stays as-is.
const pathed = bucket.filter(group => group.path)
if (pathed.length < 2) {
continue
}
const parents = new Map(pathed.map(group => [group.id, parentSegments(group.path!)]))
let depth = 1
// Grow the prefix one parent segment at a time until every label in the
// bucket is distinct, or we run out of parent segments to add.
while (depth <= Math.max(...pathed.map(g => parents.get(g.id)!.length))) {
const labels = new Map<string, number>()
for (const group of pathed) {
const segs = parents.get(group.id)!
const prefix = segs.slice(-depth).join('/')
const base = baseName(group.path!) ?? group.path!
group.label = prefix ? `${prefix}/${base}` : base
labels.set(group.label, (labels.get(group.label) ?? 0) + 1)
}
if ([...labels.values()].every(count => count === 1)) {
break
}
depth += 1
}
}
}
export function workspaceGroupsFor(
sessions: SessionInfo[],
noWorkspaceLabel: string,
options: { preserveSessionOrder?: boolean } = {}
): SidebarSessionGroup[] {
const groups = new Map<string, SidebarSessionGroup>()
for (const session of sessions) {
const path = session.cwd?.trim() || ''
const id = path || NO_WORKSPACE_ID
const label = baseName(path) || path || noWorkspaceLabel
const group = groups.get(id) ?? { id, label, path: path || null, sessions: [] }
group.sessions.push(session)
groups.set(id, group)
}
if (!options.preserveSessionOrder) {
// Groups keep recency order (Map insertion = first-seen in the recency-sorted
// input, so an active project floats up), but rows *within* a group sort by
// creation time so they don't reshuffle every time a message lands — keeps
// muscle memory intact.
for (const group of groups.values()) {
group.sessions.sort((a, b) => b.started_at - a.started_at)
}
}
const result = [...groups.values()]
disambiguateLabels(result)
return result
}
/**
* A worktree's main repo and all its linked worktrees collapse into ONE parent
* (keyed by the repo root); each worktree is a child group; sessions hang off
* the worktree they ran in. `parent → worktree → sessions`.
*/
export interface SidebarWorkspaceTree {
id: string
label: string
path: null | string
groups: SidebarSessionGroup[]
sessionCount: number
}
/** Resolves a session cwd to git-worktree identity (from the local fs probe). */
export type WorktreeResolver = (cwd: string) => HermesWorktreeInfo | null | undefined
interface WorkspacePlacement {
parentKey: string
parentLabel: string
parentPath: string
worktreeKey: string
worktreeLabel: string
worktreePath: string
}
/** Replace a path's final segment, preserving its prefix + separators. */
const withBaseName = (path: string, name: string): string =>
path.replace(/[/\\]+$/, '').replace(/[^/\\]+$/, name)
/**
* Path-only fallback for when git metadata is unavailable (remote backends,
* unreadable paths). Mirrors the git layout: a `<repo>-wt-<branch>` directory
* nests under its sibling `<repo>`; any other directory is its own repo root.
*/
function placeByHeuristic(path: string): WorkspacePlacement | null {
const base = baseName(path)
if (!base) {
return null
}
const worktreeMatch = base.match(/^(.+)-wt-(.+)$/)
if (worktreeMatch) {
const repo = worktreeMatch[1]
const repoPath = withBaseName(path, repo)
return {
parentKey: repoPath,
parentLabel: repo,
parentPath: repoPath,
worktreeKey: path,
worktreeLabel: worktreeMatch[2],
worktreePath: path
}
}
return {
parentKey: path,
parentLabel: base,
parentPath: path,
worktreeKey: path,
worktreeLabel: base,
worktreePath: path
}
}
function placeWorkspace(path: string, resolver?: WorktreeResolver): WorkspacePlacement | null {
const info = resolver?.(path)
if (info?.repoRoot && info.worktreeRoot) {
const dirLabel = baseName(info.worktreeRoot) || info.worktreeRoot
return {
parentKey: info.repoRoot,
parentLabel: baseName(info.repoRoot) ?? info.repoRoot,
parentPath: info.repoRoot,
worktreeKey: info.worktreeRoot,
// The main checkout's branch is transient — it changes as you work, so a
// branch label would misattribute every past session to whatever branch
// is checked out *now*. Label it by directory. Linked worktrees are
// per-branch by construction, so branch is the clearest label there.
worktreeLabel: info.isMainWorktree ? dirLabel : info.branch || dirLabel,
worktreePath: info.worktreeRoot
}
}
return placeByHeuristic(path)
}
/** Unique, non-empty session cwds — the batch to probe for worktree info. */
export function uniqueCwds(sessions: SessionInfo[]): string[] {
const seen = new Set<string>()
for (const session of sessions) {
const path = session.cwd?.trim()
if (path) {
seen.add(path)
}
}
return [...seen]
}
/**
* Build the `parent → worktree → sessions` tree. Parents keep recency order
* (first-seen in the recency-sorted input); worktree groups within a parent do
* too, while rows inside a worktree sort by creation time (stable muscle memory,
* matching `workspaceGroupsFor`).
*/
export function workspaceTreeFor(
sessions: SessionInfo[],
noWorkspaceLabel: string,
resolver?: WorktreeResolver,
options: { preserveSessionOrder?: boolean } = {}
): SidebarWorkspaceTree[] {
interface WorktreeEntry {
group: SidebarSessionGroup
parentKey: string
parentLabel: string
parentPath: string
}
const worktrees = new Map<string, WorktreeEntry>()
const noWorkspace: SessionInfo[] = []
for (const session of sessions) {
const path = session.cwd?.trim() || ''
if (!path) {
noWorkspace.push(session)
continue
}
const placement = placeWorkspace(path, resolver)
if (!placement) {
noWorkspace.push(session)
continue
}
let entry = worktrees.get(placement.worktreeKey)
if (!entry) {
entry = {
group: { id: placement.worktreeKey, label: placement.worktreeLabel, path: placement.worktreePath, sessions: [] },
parentKey: placement.parentKey,
parentLabel: placement.parentLabel,
parentPath: placement.parentPath
}
worktrees.set(placement.worktreeKey, entry)
}
entry.group.sessions.push(session)
}
if (!options.preserveSessionOrder) {
for (const entry of worktrees.values()) {
entry.group.sessions.sort((a, b) => b.started_at - a.started_at)
}
}
const parents = new Map<string, SidebarWorkspaceTree>()
for (const entry of worktrees.values()) {
let parent = parents.get(entry.parentKey)
if (!parent) {
parent = { id: entry.parentKey, label: entry.parentLabel, path: entry.parentPath, groups: [], sessionCount: 0 }
parents.set(entry.parentKey, parent)
}
parent.groups.push(entry.group)
parent.sessionCount += entry.group.sessions.length
}
const result = [...parents.values()]
if (noWorkspace.length) {
result.push({
id: NO_WORKSPACE_ID,
label: noWorkspaceLabel,
path: null,
groups: [{ id: NO_WORKSPACE_ID, label: noWorkspaceLabel, path: null, sessions: noWorkspace }],
sessionCount: noWorkspace.length
})
}
// Parents that collide on basename grow a path prefix; worktree labels that
// collide inside a parent do the same.
disambiguateLabels(result)
for (const parent of result) {
disambiguateLabels(parent.groups)
}
return result
}

View File

@@ -3,14 +3,9 @@ import type { ChatMessage } from '@/lib/chat-messages'
export type ThreadLoadingState = 'response' | 'session'
export function lastVisibleMessageIsUser(messages: ChatMessage[]): boolean {
// Allocation-free reverse scan — runs in a hot $messages computed.
for (let i = messages.length - 1; i >= 0; i -= 1) {
if (!messages[i].hidden) {
return messages[i].role === 'user'
}
}
const lastVisible = [...messages].reverse().find(message => !message.hidden)
return false
return lastVisible?.role === 'user'
}
export function threadLoadingState(

View File

@@ -7,8 +7,8 @@ import { useNavigate } from 'react-router-dom'
import { HUD_HEADING, HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from '@/app/floating-hud'
import { setTerminalTakeover } from '@/app/right-sidebar/store'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { KbdCombo } from '@/components/ui/kbd'
import { getHermesConfigRecord, listAllProfileSessions } from '@/hermes'
import { KbdGroup } from '@/components/ui/kbd'
import { getHermesConfigRecord, listSessions } from '@/hermes'
import { useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import {
@@ -38,6 +38,7 @@ import {
Wrench,
Zap
} from '@/lib/icons'
import { comboTokens } from '@/lib/keybinds/combo'
import { cn } from '@/lib/utils'
import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
import { $bindings } from '@/store/keybinds'
@@ -118,11 +119,7 @@ const paletteFilter = (value: string, search: string, keywords?: string[]): numb
return needle.split(/\s+/).every(term => haystack.includes(term)) ? 1 : 0
}
// Hermes session ids: <YYYYMMDD>_<HHMMSS>_<6 hex>. Used to offer a direct
// "Go to session id" jump for ids that aren't in the recent-200 list.
const SESSION_ID_RE = /^\d{8}_\d{6}_[a-f0-9]{6}$/
type SessionRow = Awaited<ReturnType<typeof listAllProfileSessions>>['sessions'][number]
type SessionRow = Awaited<ReturnType<typeof listSessions>>['sessions'][number]
const toSessionEntry = (session: SessionRow): SessionEntry => ({
id: session.id,
@@ -221,13 +218,13 @@ export function CommandPalette() {
const sessionsQuery = useQuery({
queryKey: ['command-palette', 'sessions'],
queryFn: () => listAllProfileSessions(200, 1, 'exclude'),
queryFn: () => listSessions(200, 1, 'exclude'),
enabled: open
})
const archivedQuery = useQuery({
queryKey: ['command-palette', 'archived'],
queryFn: () => listAllProfileSessions(200, 0, 'only'),
queryFn: () => listSessions(200, 0, 'only'),
enabled: open
})
@@ -417,24 +414,6 @@ export function CommandPalette() {
const result: PaletteGroup[] = []
// Paste a raw session id → jump straight to it, even if it predates the
// recent-200 window the lists below are built from.
const directId = search.trim()
if (SESSION_ID_RE.test(directId)) {
result.push({
items: [
{
icon: MessageCircle,
id: `goto-${directId}`,
keywords: ['session', 'id', 'go to', directId],
label: `${t.commandCenter.goToSession} ${directId}`,
run: go(sessionRoute(directId))
}
]
})
}
if (sessions.length > 0) {
result.push({
heading: t.commandCenter.sections.sessions,
@@ -641,6 +620,7 @@ export function CommandPalette() {
{group.items.map(item => {
const Icon = item.icon
const combo = item.action ? bindings[item.action]?.[0] : undefined
const keys = combo ? comboTokens(combo) : null
return (
<CommandItem
@@ -652,10 +632,10 @@ export function CommandPalette() {
>
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{item.label}</span>
{combo && <KbdCombo className="ml-auto opacity-55" combo={combo} size="sm" />}
{keys && <KbdGroup className="ml-auto" keys={keys} />}
{item.to && (
<ChevronRight
className={cn('size-3.5 shrink-0 text-muted-foreground/70', !combo && 'ml-auto')}
className={cn('size-3.5 shrink-0 text-muted-foreground/70', !keys && 'ml-auto')}
/>
)}
</CommandItem>

View File

@@ -20,7 +20,6 @@ import {
MESSAGING_SESSION_SOURCE_IDS,
normalizeSessionSource
} from '../lib/session-source'
import { latestSessionTodos } from '../lib/todos'
import { setCronFocusJobId, setCronJobs } from '../store/cron'
import {
$panesFlipped,
@@ -76,12 +75,10 @@ import {
setSessionsLoading,
setSessionsTotal
} from '../store/session'
import { clearSessionTodos, setSessionTodos, todoListActive } from '../store/todos'
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '../store/updates'
import { isSecondaryWindow } from '../store/windows'
import { ChatView } from './chat'
import { requestComposerFocus, requestComposerInsert } from './chat/composer/focus'
import { useComposerActions } from './chat/hooks/use-composer-actions'
import {
ChatPreviewRail,
@@ -143,7 +140,7 @@ const CRON_POLL_INTERVAL_MS = 30_000
// self-managed sidebar section (refreshMessagingSessions). Excluding both here
// keeps "Load more" paging through interactive local chats instead of
// interleaving gateway threads that bury them.
const SIDEBAR_EXCLUDED_SOURCES = ['cron', 'subagent', 'tool', ...MESSAGING_SESSION_SOURCE_IDS]
const SIDEBAR_EXCLUDED_SOURCES = ['cron', ...MESSAGING_SESSION_SOURCE_IDS]
// The messaging slice is the inverse: drop cron + every local source so only
// external-platform conversations remain, then split per platform in the UI.
const MESSAGING_EXCLUDED_SOURCES = ['cron', ...LOCAL_SESSION_SOURCE_IDS]
@@ -269,36 +266,6 @@ export function DesktopController() {
}
}, [])
// hermes:// deep links (e.g. a docs "Send to App" button for an automation blueprint).
// Build the equivalent /blueprint slash command from the payload and drop
// it into the composer — the user reviews/edits, then sends; the agent (or
// the shared command handler) creates the job. Signal readiness so a link
// that arrived during boot is flushed exactly once.
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onDeepLink?.(payload => {
if (!payload || payload.kind !== 'blueprint' || !payload.name) {
return
}
const slots = Object.entries(payload.params || {})
.map(([k, v]) => {
const sval = /\s/.test(v) ? `"${v.replace(/"/g, '\\"')}"` : v
return `${k}=${sval}`
})
.join(' ')
const command = `/blueprint ${payload.name}${slots ? ' ' + slots : ''}`
requestComposerInsert(command, { mode: 'block', target: 'main' })
requestComposerFocus('main')
})
// Tell the main process the renderer is ready to receive deep links.
void window.hermesDesktop?.signalDeepLinkReady?.()
return () => unsubscribe?.()
}, [])
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (!$filePreviewTarget.get() && !$previewTarget.get()) {
@@ -554,34 +521,20 @@ export function DesktopController() {
return
}
const storedProfile = $sessions
.get()
.find(session => session.id === storedSessionId || session._lineage_root_id === storedSessionId)?.profile
const storedProfile = $sessions.get().find(session => session.id === storedSessionId)?.profile
for (let index = 0; index < Math.max(1, attempts); index += 1) {
try {
const latest = await getSessionMessages(storedSessionId, storedProfile)
const messages = toChatMessages(latest.messages)
updateSessionState(
runtimeSessionId,
state => ({
...state,
messages: preserveLocalAssistantErrors(messages, state.messages)
messages: preserveLocalAssistantErrors(toChatMessages(latest.messages), state.messages)
}),
storedSessionId
)
// Seed the status stack's todo group from history — but only while
// the plan is still in flight, so reopening an old chat doesn't pin
// its finished todo list above the composer forever.
const todos = latestSessionTodos(messages)
if (todos && todoListActive(todos)) {
setSessionTodos(runtimeSessionId, todos)
} else {
clearSessionTodos(runtimeSessionId)
}
return
} catch {
// Best-effort fallback when live stream payloads are empty.
@@ -601,7 +554,6 @@ export function DesktopController() {
queryClient,
refreshHermesConfig,
refreshSessions,
sessionStateByRuntimeIdRef,
updateSessionState
})
@@ -731,7 +683,6 @@ export function DesktopController() {
editMessage,
handleThreadMessagesChange,
reloadFromMessage,
restoreToMessage,
steerPrompt,
submitText,
transcribeVoiceAudio
@@ -966,7 +917,6 @@ export function DesktopController() {
onPickImages={() => void composer.pickImages()}
onReload={reloadFromMessage}
onRemoveAttachment={id => void composer.removeAttachment(id)}
onRestoreToMessage={restoreToMessage}
onSteer={steerPrompt}
onSubmit={submitText}
onThreadMessagesChange={handleThreadMessagesChange}
@@ -1012,8 +962,8 @@ export function DesktopController() {
width={FILE_BROWSER_DEFAULT_WIDTH}
>
<RightSidebarPane
onActivateFile={path => composer.insertContextPathInlineRef(path)}
onActivateFolder={path => composer.insertContextPathInlineRef(path, true)}
onActivateFile={composer.attachContextFilePath}
onActivateFolder={composer.attachContextFolderPath}
onChangeCwd={changeSessionCwd}
/>
</Pane>

View File

@@ -3,7 +3,6 @@ import { useEffect, useRef } from 'react'
import type { HermesConnection } from '@/global'
import { HermesGateway } from '@/hermes'
import { translateNow } from '@/i18n'
import { desktopDefaultCwd } from '@/lib/desktop-fs'
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
import {
$desktopBoot,
@@ -26,16 +25,12 @@ import {
import { notify, notifyError } from '@/store/notifications'
import { $activeGatewayProfile, normalizeProfileKey, touchActiveGatewayBackend } from '@/store/profile'
import {
$activeSessionId,
$attentionSessionIds,
$connection,
$currentCwd,
$sessions,
$workingSessionIds,
ensureDefaultWorkspaceCwd,
setConnection,
setCurrentBranch,
setCurrentCwd,
setSessionsLoading
} from '@/store/session'
import type { RpcEvent } from '@/types/hermes'
@@ -358,11 +353,6 @@ export function useGatewayBoot({
progress: 97
})
await ensureDefaultWorkspaceCwd()
const remoteDefault = await desktopDefaultCwd().catch(() => null)
if (remoteDefault?.cwd && !$activeSessionId.get() && !$currentCwd.get()) {
setCurrentCwd(remoteDefault.cwd)
setCurrentBranch(remoteDefault.branch || '')
}
await callbacksRef.current.refreshHermesConfig()
if (cancelled) {

View File

@@ -1,27 +0,0 @@
import { createDragDropManager, type DragDropManager } from 'dnd-core'
import { HTML5Backend } from 'react-dnd-html5-backend'
let manager: DragDropManager | null = null
/**
* A single, app-lifetime react-dnd manager for the file tree.
*
* react-arborist mounts its own react-dnd `DndProvider` with `HTML5Backend`
* inside every `<Tree>`. react-dnd v14 stores that provider's manager on a
* global, ref-counted singleton context and nulls it when the count hits 0.
* On a keyed remount (cwd / collapse changes force a fresh `<Tree>`), the
* singleton can be torn down and recreated while the previous `HTML5Backend`
* still owns the `window.__isReactDndHtml5Backend` setup flag — so the new
* backend's `setup()` throws "Cannot have two HTML5 backends at the same
* time." and trips the file-tree error boundary (it never recovers, because
* "Try again" just remounts into the same race).
*
* Passing arborist a stable `dndManager` makes it skip the global-singleton
* path entirely and reuse one backend for the lifetime of the app, so the
* window flag is never double-claimed.
*/
export function getFileTreeDndManager(): DragDropManager {
manager ??= createDragDropManager(HTML5Backend)
return manager
}

View File

@@ -1,100 +0,0 @@
/// <reference types="node" />
import { Buffer } from 'node:buffer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { HermesReadDirEntry, HermesReadDirResult } from '@/global'
import { clearProjectDirCache, readProjectDir } from './ipc'
const readDir = vi.fn<(path: string) => Promise<HermesReadDirResult>>()
const readFileDataUrl = vi.fn<(path: string) => Promise<string>>()
const gitRoot = vi.fn<(path: string) => Promise<string | null>>()
function ok(entries: HermesReadDirEntry[]): HermesReadDirResult {
return { entries }
}
function dataUrl(text: string) {
return `data:text/plain;base64,${Buffer.from(text, 'utf8').toString('base64')}`
}
function installBridge() {
;(
window as unknown as {
hermesDesktop: {
gitRoot: typeof gitRoot
readDir: typeof readDir
readFileDataUrl: typeof readFileDataUrl
}
}
).hermesDesktop = { gitRoot, readDir, readFileDataUrl }
}
describe('readProjectDir', () => {
beforeEach(() => {
clearProjectDirCache()
readDir.mockReset()
readFileDataUrl.mockReset()
gitRoot.mockReset()
installBridge()
})
afterEach(() => {
clearProjectDirCache()
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
it('returns no-bridge when the desktop bridge is unavailable', async () => {
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
await expect(readProjectDir('/repo')).resolves.toEqual({ entries: [], error: 'no-bridge' })
})
it('filters gitignored entries when readDir returns Windows-style paths', async () => {
gitRoot.mockResolvedValue('C:\\repo')
readDir.mockImplementation(async path => {
if (path === 'C:\\repo\\src') {
return ok([
{ name: 'debug.log', path: 'C:\\repo\\src\\debug.log', isDirectory: false },
{ name: '临时.txt', path: 'C:\\repo\\src\\临时.txt', isDirectory: false },
{ name: 'keep.ts', path: 'C:\\repo\\src\\keep.ts', isDirectory: false }
])
}
if (path === 'C:/repo') {
return ok([{ name: '.gitignore', path: 'C:/repo/.gitignore', isDirectory: false }])
}
if (path === 'C:/repo/src') {
return ok([])
}
return ok([])
})
readFileDataUrl.mockResolvedValue(dataUrl('# Unicode 路径规则\nsrc/*.log\nsrc/临时.txt\n'))
const result = await readProjectDir('C:\\repo\\src', 'C:\\repo')
expect(result.entries.map(entry => entry.name)).toEqual(['keep.ts'])
expect(gitRoot).toHaveBeenCalledWith('C:/repo')
expect(readFileDataUrl).toHaveBeenCalledWith('C:/repo/.gitignore')
})
it('does not fetch .gitignore contents when listings do not contain .gitignore', async () => {
gitRoot.mockResolvedValue('/repo')
readDir.mockImplementation(async path => {
if (path === '/repo/src') {
return ok([{ name: 'debug.log', path: '/repo/src/debug.log', isDirectory: false }])
}
return ok([])
})
const result = await readProjectDir('/repo/src', '/repo')
expect(result.entries.map(entry => entry.name)).toEqual(['debug.log'])
expect(readFileDataUrl).not.toHaveBeenCalled()
})
})

View File

@@ -1,6 +1,5 @@
import ignore from 'ignore'
import { desktopFsCacheKey, desktopGitRoot, readDesktopDir, readDesktopFileDataUrl } from '@/lib/desktop-fs'
import type { HermesReadDirEntry, HermesReadDirResult } from '@/global'
export type ProjectTreeEntry = HermesReadDirEntry
@@ -28,7 +27,7 @@ function decodeDataUrl(dataUrl: string) {
}
function clean(path: string) {
return path.replace(/\\/g, '/').replace(/\/+$/, '') || '/'
return path.replace(/\/+$/, '') || '/'
}
/** Strict POSIX-style relative path; null if `child` is not inside `root`. */
@@ -64,11 +63,15 @@ function ancestorDirs(root: string, dir: string) {
}
async function gitRootFor(start: string) {
const key = `${desktopFsCacheKey()}:${clean(start)}`
if (!window.hermesDesktop?.gitRoot) {
return null
}
const key = clean(start)
let cached = gitRootCache.get(key)
if (!cached) {
cached = desktopGitRoot(start)
cached = window.hermesDesktop.gitRoot(key)
gitRootCache.set(key, cached)
}
@@ -77,14 +80,18 @@ async function gitRootFor(start: string) {
/** Read .gitignore at `dir` if it actually exists — never probe missing files. */
async function readGitignore(dir: string): Promise<GitignoreRule | null> {
if (!window.hermesDesktop?.readDir || !window.hermesDesktop.readFileDataUrl) {
return null
}
try {
const listing = await readDesktopDir(dir)
const listing = await window.hermesDesktop.readDir(dir)
if (!listing.entries.some(e => e.name === '.gitignore' && !e.isDirectory)) {
return null
}
const text = decodeDataUrl(await readDesktopFileDataUrl(`${dir}/.gitignore`))
const text = decodeDataUrl(await window.hermesDesktop.readFileDataUrl(`${dir}/.gitignore`))
return { base: dir, ig: ignore().add(text) }
} catch {
@@ -93,11 +100,11 @@ async function readGitignore(dir: string): Promise<GitignoreRule | null> {
}
async function gitignoreFor(dir: string) {
const key = `${desktopFsCacheKey()}:${clean(dir)}`
const key = clean(dir)
let cached = gitignoreCache.get(key)
if (!cached) {
cached = readGitignore(clean(dir))
cached = readGitignore(key)
gitignoreCache.set(key, cached)
}
@@ -135,10 +142,9 @@ export async function readProjectDir(dirPath: string, rootPath = dirPath): Promi
return { entries: [], error: 'no-bridge' }
}
const result = await readDesktopDir(dirPath)
const entries = result?.entries ?? []
const result = await window.hermesDesktop.readDir(dirPath)
return { ...result, entries: await filterIgnored(entries, rootPath, dirPath) }
return { ...result, entries: await filterIgnored(result.entries, rootPath, dirPath) }
}
export function clearProjectDirCache(rootPath?: string) {
@@ -149,7 +155,7 @@ export function clearProjectDirCache(rootPath?: string) {
return
}
const key = `${desktopFsCacheKey()}:${clean(rootPath)}`
const key = clean(rootPath)
gitRootCache.delete(key)
gitignoreCache.delete(key)
}

View File

@@ -1,177 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'
import { useI18n } from '@/i18n'
import { readDesktopDir, setDesktopFsRemotePicker } from '@/lib/desktop-fs'
import { cn } from '@/lib/utils'
function clean(path: string) {
return path.replace(/\/+$/, '') || '/'
}
function parentDir(path: string) {
const value = clean(path)
if (value === '/') {
return '/'
}
const parent = value.slice(0, value.lastIndexOf('/'))
return parent || '/'
}
function pathName(path: string) {
return path.split('/').filter(Boolean).pop() || path
}
interface PendingSelection {
defaultPath: string
resolve: (paths: string[]) => void
title: string
}
export function RemoteFolderPicker() {
const { t } = useI18n()
const r = t.rightSidebar
const [pending, setPending] = useState<PendingSelection | null>(null)
const [currentPath, setCurrentPath] = useState('/')
const [entries, setEntries] = useState<Array<{ name: string; path: string }>>([])
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
useEffect(() => {
setDesktopFsRemotePicker({
selectPaths: options =>
new Promise(resolve => {
const defaultPath = clean(options?.defaultPath || '/')
setCurrentPath(defaultPath)
setPending({ defaultPath, resolve, title: options?.title || r.remotePickerTitle })
})
})
return () => setDesktopFsRemotePicker(null)
}, [r.remotePickerTitle])
useEffect(() => {
if (!pending) {
return
}
let active = true
setLoading(true)
setError(null)
void readDesktopDir(currentPath)
.then(result => {
if (!active) {
return
}
if (result.error) {
setError(result.error)
setEntries([])
return
}
setEntries(result.entries.filter(entry => entry.isDirectory).map(entry => ({ name: entry.name, path: entry.path })))
})
.catch(err => {
if (active) {
setError(err instanceof Error ? err.message : String(err))
setEntries([])
}
})
.finally(() => {
if (active) {
setLoading(false)
}
})
return () => {
active = false
}
}, [currentPath, pending])
const crumbs = useMemo(() => {
const parts = clean(currentPath).split('/').filter(Boolean)
const out = [{ label: '/', path: '/' }]
let acc = ''
for (const part of parts) {
acc += `/${part}`
out.push({ label: part, path: acc })
}
return out
}, [currentPath])
const close = (paths: string[] = []) => {
pending?.resolve(paths)
setPending(null)
setEntries([])
setError(null)
}
return (
<Dialog onOpenChange={open => !open && close()} open={Boolean(pending)}>
<DialogContent className="max-w-lg gap-0 overflow-hidden p-0">
<div className="border-b border-border/70 px-4 py-3">
<DialogTitle className="text-sm">{pending?.title || r.remotePickerTitle}</DialogTitle>
<DialogDescription className="mt-1 text-xs">{r.remotePickerDescription}</DialogDescription>
</div>
<div className="flex min-h-[22rem] flex-col">
<div className="flex flex-wrap items-center gap-1 border-b border-border/50 px-3 py-2 text-xs text-muted-foreground">
{crumbs.map((crumb, index) => (
<button
className={cn('rounded px-1.5 py-0.5 hover:bg-muted hover:text-foreground', index === crumbs.length - 1 && 'text-foreground')}
key={crumb.path}
onClick={() => setCurrentPath(crumb.path)}
type="button"
>
{crumb.label}
</button>
))}
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
<FolderRow disabled={currentPath === '/'} name=".." onClick={() => setCurrentPath(parentDir(currentPath))} />
{loading ? (
<div className="flex items-center gap-2 px-2 py-3 text-xs text-muted-foreground">
<Codicon name="loading" size="0.8rem" spinning />
{r.loadingFiles}
</div>
) : error ? (
<div className="px-2 py-3 text-xs text-destructive">{r.unreadableBody(error)}</div>
) : entries.length === 0 ? (
<div className="px-2 py-3 text-xs text-muted-foreground">{r.emptyBody}</div>
) : (
entries.map(entry => <FolderRow key={entry.path} name={pathName(entry.path)} onClick={() => setCurrentPath(entry.path)} />)
)}
</div>
</div>
<div className="flex items-center justify-between gap-2 border-t border-border/70 px-4 py-3">
<div className="min-w-0 truncate text-xs text-muted-foreground">{currentPath}</div>
<div className="flex shrink-0 items-center gap-2">
<Button onClick={() => close()} size="sm" variant="ghost">
{t.common.cancel}
</Button>
<Button onClick={() => close([currentPath])} size="sm">
{r.remotePickerSelect}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
function FolderRow({ disabled = false, name, onClick }: { disabled?: boolean; name: string; onClick: () => void }) {
return (
<button
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background) hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
disabled={disabled}
onClick={onClick}
type="button"
>
<Codicon name="folder" size="0.875rem" />
<span className="min-w-0 truncate">{name}</span>
</button>
)
}

View File

@@ -7,13 +7,10 @@ import { useResizeObserver } from '@/hooks/use-resize-observer'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import { getFileTreeDndManager } from './dnd-manager'
import type { TreeNode } from './use-project-tree'
const ROW_HEIGHT = 22
const INDENT = 10
/** Base inset for every row; react-arborist owns paddingLeft for depth indent. */
const TREE_ROW_INSET = 12
interface ProjectTreeProps {
collapseNonce: number
@@ -97,7 +94,6 @@ export function ProjectTree({
disableDrag
disableDrop
disableEdit
dndManager={getFileTreeDndManager()}
height={size.height}
indent={INDENT}
initialOpenState={openState}
@@ -149,8 +145,7 @@ function ProjectTreeRow({
}
const isFolder = node.data.isDirectory
const isPlaceholder = Boolean(node.data.placeholder)
const isErrorPlaceholder = node.data.placeholder === 'error'
const isPlaceholder = node.data.id.endsWith('::__loading__')
return (
<div
@@ -202,21 +197,21 @@ function ProjectTreeRow({
event.dataTransfer.setData('text/plain', node.data.id)
}}
ref={dragHandle}
style={{
...style,
paddingLeft:
(typeof style.paddingLeft === 'number'
? style.paddingLeft
: Number.parseFloat(String(style.paddingLeft ?? 0)) || 0) + TREE_ROW_INSET
}}
style={style}
>
{/* No chevron column — the folder icon (open/closed) already carries the
expand state, so the extra glyph was pure noise. */}
{isFolder && !isPlaceholder && (
<span aria-hidden className="flex w-3 items-center justify-center">
<Codicon
className="text-(--ui-text-tertiary)"
name={node.isOpen ? 'chevron-down' : 'chevron-right'}
size="0.75rem"
/>
</span>
)}
{!isFolder && <span aria-hidden className="w-3 shrink-0" />}
<span aria-hidden className="flex w-3.5 items-center justify-center text-(--ui-text-tertiary)">
{isPlaceholder && !isErrorPlaceholder ? (
{isPlaceholder ? (
<Codicon name="loading" size="0.75rem" spinning />
) : isErrorPlaceholder ? (
<Codicon name="warning" size="0.75rem" />
) : isFolder ? (
<Codicon name={node.isOpen ? 'folder-opened' : 'folder'} size="0.875rem" />
) : (

View File

@@ -1,24 +1,19 @@
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { act, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $connection } from '@/store/session'
import type { HermesReadDirResult } from '@/global'
import { clearProjectDirCache, readProjectDir } from './ipc'
import { resetProjectTreeState, useProjectTree } from './use-project-tree'
const readDir = vi.fn<(path: string) => Promise<HermesReadDirResult>>()
beforeEach(() => {
$connection.set(null)
resetProjectTreeState()
readDir.mockReset()
;(window as unknown as { hermesDesktop: { readDir: typeof readDir } }).hermesDesktop = { readDir }
})
afterEach(() => {
cleanup()
$connection.set(null)
resetProjectTreeState()
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
@@ -111,37 +106,7 @@ describe('useProjectTree', () => {
expect(readDir).toHaveBeenCalledTimes(1)
})
it('reads gitignore from the real path while caching per connection', async () => {
const readFileDataUrl = vi.fn(async () => `data:text/plain;base64,${btoa('ignored.log\n')}`)
const gitRoot = vi.fn(async () => '/repo')
readDir.mockImplementation(async path => {
if (path === '/repo') return ok([{ name: '.gitignore', path: '/repo/.gitignore', isDirectory: false }])
if (path === '/repo/src') {
return ok([
{ name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false },
{ name: 'ignored.log', path: '/repo/src/ignored.log', isDirectory: false }
])
}
throw new Error(`unexpected path ${path}`)
})
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { gitRoot, readDir, readFileDataUrl }
$connection.set({ baseUrl: 'local-a', mode: 'local' } as never)
await expect(readProjectDir('/repo/src', '/repo')).resolves.toMatchObject({
entries: [{ name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false }]
})
expect(readDir).toHaveBeenCalledWith('/repo')
expect(readDir).not.toHaveBeenCalledWith(expect.stringContaining('local-a'))
$connection.set({ baseUrl: 'local-b', mode: 'local' } as never)
clearProjectDirCache()
await expect(readProjectDir('/repo/src', '/repo')).resolves.toMatchObject({
entries: [{ name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false }]
})
expect(readDir.mock.calls.filter(([path]) => path === '/repo')).toHaveLength(2)
})
it('captures per-folder error code and shows an error placeholder child', async () => {
it('captures per-folder error code and leaves the folder expandable but empty', async () => {
readDir.mockResolvedValueOnce(ok([{ name: 'priv', path: '/p/priv', isDirectory: true }]))
readDir.mockResolvedValueOnce({ entries: [], error: 'EACCES' })
@@ -154,14 +119,7 @@ describe('useProjectTree', () => {
})
expect(result.current.data[0].error).toBe('EACCES')
expect(result.current.data[0].children).toEqual([
{
id: '/p/priv::__error__',
isDirectory: false,
name: 'Unable to read (EACCES)',
placeholder: 'error'
}
])
expect(result.current.data[0].children).toEqual([])
})
it('dedupes concurrent loadChildren calls for the same id', async () => {
@@ -221,36 +179,6 @@ describe('useProjectTree', () => {
expect(readDir).toHaveBeenLastCalledWith('/b')
})
it('falls back to the sanitized workspace dir when the session cwd is gone', async () => {
const sanitizeWorkspaceCwd = vi.fn(async () => ({ cwd: '/home/me/projects', sanitized: true }))
readDir.mockImplementation(async path => {
if (path === '/deleted/worktree') return { entries: [], error: 'ENOENT' }
if (path === '/home/me/projects') return ok([{ name: 'repo', path: '/home/me/projects/repo', isDirectory: true }])
throw new Error(`unexpected path ${path}`)
})
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { readDir, sanitizeWorkspaceCwd }
const { result } = renderHook(() => useProjectTree('/deleted/worktree'))
await waitFor(() => expect(result.current.data.length).toBe(1))
expect(sanitizeWorkspaceCwd).toHaveBeenCalledWith('/deleted/worktree')
expect(result.current.rootError).toBeNull()
expect(result.current.effectiveCwd).toBe('/home/me/projects')
expect(result.current.data[0]?.name).toBe('repo')
})
it('keeps the root error when sanitize offers no usable fallback', async () => {
const sanitizeWorkspaceCwd = vi.fn(async () => ({ cwd: '/deleted/worktree', sanitized: false }))
readDir.mockResolvedValue({ entries: [], error: 'ENOENT' })
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { readDir, sanitizeWorkspaceCwd }
const { result } = renderHook(() => useProjectTree('/deleted/worktree'))
await waitFor(() => expect(result.current.rootError).toBe('ENOENT'))
expect(result.current.effectiveCwd).toBe('/deleted/worktree')
})
it('returns no-bridge gracefully when window.hermesDesktop is missing', async () => {
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop

View File

@@ -2,8 +2,6 @@ import { useStore } from '@nanostores/react'
import { atom } from 'nanostores'
import { useCallback, useEffect, useMemo } from 'react'
import { $connection } from '@/store/session'
import { clearProjectDirCache, readProjectDir } from './ipc'
export interface TreeNode {
@@ -16,14 +14,11 @@ export interface TreeNode {
children?: TreeNode[]
/** True while a readDir for this folder is in flight. */
loading?: boolean
/** Synthetic loading/error rows are not real filesystem entries. */
placeholder?: 'error' | 'loading'
/** Last error code from readDir (e.g. EACCES). Cleared on next successful load. */
error?: string
}
const PLACEHOLDER_ID = '__loading__'
const ERROR_PLACEHOLDER_ID = '__error__'
function makeNode(path: string, name: string, isDirectory: boolean): TreeNode {
return { id: path, isDirectory, name }
@@ -48,26 +43,13 @@ function patchNode(nodes: TreeNode[] | undefined | null, id: string, patch: (n:
}
function placeholderChild(parentId: string): TreeNode {
return { id: `${parentId}::${PLACEHOLDER_ID}`, isDirectory: false, name: 'Loading…', placeholder: 'loading' }
}
function errorChild(parentId: string, error: string | undefined): TreeNode {
return {
id: `${parentId}::${ERROR_PLACEHOLDER_ID}`,
isDirectory: false,
name: `Unable to read (${error || 'read-error'})`,
placeholder: 'error'
}
return { id: `${parentId}::${PLACEHOLDER_ID}`, isDirectory: false, name: 'Loading…' }
}
export interface UseProjectTreeResult {
/** Bumped by collapseAll so callers can remount the tree fully collapsed. */
collapseNonce: number
data: TreeNode[]
/** Directory actually displayed — differs from the requested cwd when the
* session's recorded cwd no longer exists and we fell back to the default
* workspace dir. */
effectiveCwd: string
openState: Record<string, boolean>
rootError: string | null
rootLoading: boolean
@@ -84,8 +66,6 @@ interface ProjectTreeState {
loaded: boolean
openState: Record<string, boolean>
requestId: number
/** Directory the displayed entries were read from ('' until first load). */
resolvedCwd: string
rootError: string | null
rootLoading: boolean
}
@@ -97,7 +77,6 @@ const initialState: ProjectTreeState = {
loaded: false,
openState: {},
requestId: 0,
resolvedCwd: '',
rootError: null,
rootLoading: false
}
@@ -105,12 +84,6 @@ const initialState: ProjectTreeState = {
const inflight = new Set<string>()
const $projectTree = atom<ProjectTreeState>(initialState)
let nextRootRequestId = 0
let lastConnectionKey = ''
// While the root is errored (ENOENT during a session's cwd race, a folder that
// reappears after a checkout, a remote that wasn't ready), keep retrying on a
// slow cadence so the tree self-heals instead of staying "UNREADABLE" forever.
const ROOT_ERROR_RETRY_MS = 3_000
function setProjectTree(updater: (current: ProjectTreeState) => ProjectTreeState) {
$projectTree.set(updater($projectTree.get()))
@@ -122,31 +95,6 @@ function clearProjectTree() {
$projectTree.set({ ...initialState, requestId: nextRootRequestId })
}
/** Sessions record their launch cwd; deleted worktrees and remote-backend
* paths arrive here as directories that don't exist on this machine. Rather
* than bricking the tree, display the sanitized workspace fallback (main
* prefers the configured default project dir). Local connections only —
* remote trees are read through the remote bridge. */
async function fallbackRootFor(cwd: string): Promise<string | null> {
if ($connection.get()?.mode === 'remote') {
return null
}
const sanitize = window.hermesDesktop?.sanitizeWorkspaceCwd
if (!sanitize) {
return null
}
try {
const { cwd: fallback, sanitized } = await sanitize(cwd)
return sanitized && fallback && fallback !== cwd ? fallback : null
} catch {
return null
}
}
async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {}) {
if (!cwd) {
clearProjectTree()
@@ -175,27 +123,11 @@ async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {}
loaded: false,
openState: current.cwd === cwd ? current.openState : {},
requestId,
resolvedCwd: '',
rootError: null,
rootLoading: true
})
let resolvedCwd = cwd
let { entries, error } = await readProjectDir(cwd, cwd)
if (error) {
const fallback = await fallbackRootFor(cwd)
if (fallback) {
const retry = await readProjectDir(fallback, fallback)
if (!retry.error) {
resolvedCwd = fallback
entries = retry.entries
error = undefined
}
}
}
const { entries, error } = await readProjectDir(cwd, cwd)
setProjectTree(latest => {
if (latest.cwd !== cwd || latest.requestId !== requestId) {
@@ -206,7 +138,6 @@ async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {}
...latest,
data: error ? [] : entries.map(e => makeNode(e.path, e.name, e.isDirectory)),
loaded: true,
resolvedCwd,
rootError: error || null,
rootLoading: false
}
@@ -214,7 +145,6 @@ async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {}
}
export function resetProjectTreeState() {
lastConnectionKey = ''
clearProjectTree()
clearProjectDirCache()
}
@@ -228,8 +158,6 @@ export function resetProjectTreeState() {
*/
export function useProjectTree(cwd: string): UseProjectTreeResult {
const state = useStore($projectTree)
const connection = useStore($connection)
const connectionKey = `${connection?.mode || 'local'}:${connection?.profile || ''}:${connection?.baseUrl || ''}`
const refreshRoot = useCallback(() => loadRoot(cwd, { force: true }), [cwd])
@@ -284,8 +212,7 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
}
})
const rootPath = $projectTree.get().resolvedCwd || cwd
const { entries, error } = await readProjectDir(id, rootPath)
const { entries, error } = await readProjectDir(id, cwd)
inflight.delete(id)
@@ -300,7 +227,7 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
...n,
loading: false,
error: error || undefined,
children: error ? [errorChild(n.id, error)] : entries.map(e => makeNode(e.path, e.name, e.isDirectory))
children: error ? [] : entries.map(e => makeNode(e.path, e.name, e.isDirectory))
}))
}
})
@@ -309,64 +236,14 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
)
useEffect(() => {
const connectionChanged = lastConnectionKey !== '' && lastConnectionKey !== connectionKey
lastConnectionKey = connectionKey
if (connectionChanged) {
clearProjectDirCache()
void loadRoot(cwd, { force: true })
return
}
void loadRoot(cwd)
}, [connectionKey, cwd])
// Self-heal: an errored root re-probes every few seconds while the tree is
// mounted. Each attempt bumps requestId, so a persistent error re-arms the
// timer; a success clears rootError and stops it.
useEffect(() => {
if (!cwd || state.cwd !== cwd || !state.rootError) {
return
}
const timer = window.setTimeout(() => void loadRoot(cwd, { force: true }), ROOT_ERROR_RETRY_MS)
return () => window.clearTimeout(timer)
}, [cwd, state.cwd, state.requestId, state.rootError])
// While showing the fallback root, quietly re-probe the session's real cwd
// (a worktree re-created, a checkout restored) and switch back when it
// reappears. The probe never touches state, so there's no flicker.
const usingFallback = state.cwd === cwd && Boolean(state.resolvedCwd) && state.resolvedCwd !== cwd
useEffect(() => {
if (!cwd || !usingFallback) {
return
}
let cancelled = false
const timer = window.setInterval(() => {
void readProjectDir(cwd, cwd).then(({ error }) => {
if (!cancelled && !error) {
void loadRoot(cwd, { force: true })
}
})
}, ROOT_ERROR_RETRY_MS)
return () => {
cancelled = true
window.clearInterval(timer)
}
}, [cwd, usingFallback])
}, [cwd])
return useMemo(
() => ({
collapseAll,
collapseNonce: state.cwd === cwd ? state.collapseNonce : 0,
data: state.cwd === cwd ? state.data : [],
effectiveCwd: state.cwd === cwd && state.resolvedCwd ? state.resolvedCwd : cwd,
loadChildren,
openState: state.cwd === cwd ? state.openState : {},
refreshRoot,
@@ -384,7 +261,6 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
state.cwd,
state.data,
state.openState,
state.resolvedCwd,
state.rootError,
state.rootLoading
]

View File

@@ -5,8 +5,8 @@ import { ErrorBoundary } from '@/components/error-boundary'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Loader } from '@/components/ui/loader'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { selectDesktopPaths } from '@/lib/desktop-fs'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { $panesFlipped } from '@/store/layout'
@@ -16,7 +16,6 @@ import { $currentCwd } from '@/store/session'
import { SidebarPanelLabel } from '../shell/sidebar-label'
import { RemoteFolderPicker } from './files/remote-picker'
import { ProjectTree } from './files/tree'
import { useProjectTree } from './files/use-project-tree'
@@ -33,11 +32,17 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
const currentCwd = useStore($currentCwd).trim()
const hasCwd = currentCwd.length > 0
const cwdName = hasCwd
? (currentCwd
.split(/[\\/]+/)
.filter(Boolean)
.pop() ?? currentCwd)
: r.noFolderSelected
const {
collapseAll,
collapseNonce,
data,
effectiveCwd,
loadChildren,
openState,
refreshRoot,
@@ -46,18 +51,11 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
setNodeOpen
} = useProjectTree(currentCwd)
const cwdName = hasCwd
? (effectiveCwd
.split(/[\\/]+/)
.filter(Boolean)
.pop() ?? effectiveCwd)
: r.noFolderSelected
const canCollapse = Object.values(openState).some(Boolean)
const chooseFolder = async () => {
const selected = await selectDesktopPaths({
defaultPath: hasCwd ? effectiveCwd : undefined,
const selected = await window.hermesDesktop?.selectPaths({
defaultPath: hasCwd ? currentCwd : undefined,
directories: true,
multiple: false,
title: r.changeCwdTitle
@@ -70,7 +68,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
const previewFile = async (path: string) => {
try {
const preview = await normalizeOrLocalPreviewTarget(path, effectiveCwd || undefined)
const preview = await normalizeOrLocalPreviewTarget(path, currentCwd || undefined)
if (!preview) {
throw new Error(r.couldNotPreview(path))
@@ -92,12 +90,10 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
: 'border-l shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'
)}
>
<RemoteFolderPicker />
<FilesystemTab
canCollapse={canCollapse}
collapseNonce={collapseNonce}
cwd={effectiveCwd}
cwd={currentCwd}
cwdName={cwdName}
data={data}
error={rootError}
@@ -126,12 +122,13 @@ interface FilesystemTabProps extends FileTreeBodyProps {
onRefresh: () => void
}
// Sidebar palette + hover-reveal: refresh tracks label hover; collapse-all
// stays visible while any folder is expanded.
// Sidebar-specific color/hover treatment only — size, radius, cursor and the
// base focus ring come from <Button size="icon-xs">. This constant exists
// purely to share the sidebar palette + the hover-reveal behavior below.
const HEADER_ACTION_CLASS =
'text-sidebar-foreground/70 hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:ring-sidebar-ring'
const HEADER_ACTION_LABEL_REVEAL = `${HEADER_ACTION_CLASS} pointer-events-none opacity-0 transition-opacity focus-visible:pointer-events-auto focus-visible:opacity-100 peer-focus-visible/project-label:pointer-events-auto peer-focus-visible/project-label:opacity-100 peer-hover/project-label:pointer-events-auto peer-hover/project-label:opacity-100`
const HEADER_ACTION_REVEAL_CLASS = `${HEADER_ACTION_CLASS} pointer-events-none opacity-0 transition-opacity focus-visible:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100`
function FilesystemTab({
canCollapse,
@@ -156,20 +153,20 @@ function FilesystemTab({
const r = t.rightSidebar
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="group/project-header flex min-h-0 flex-1 flex-col">
<RightSidebarSectionHeader>
<div className="peer/project-label flex min-w-0 flex-1">
<Tip label={hasCwd ? r.folderTip(cwd) : r.openFolder}>
<button
className="flex w-full min-w-0 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
className="flex min-w-0 flex-1 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
onClick={() => void onChangeFolder()}
type="button"
>
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
</button>
</div>
</Tip>
<Button
aria-label={r.refreshTree}
className={HEADER_ACTION_LABEL_REVEAL}
className={HEADER_ACTION_CLASS}
disabled={!hasCwd || loading}
onClick={onRefresh}
size="icon-xs"
@@ -188,7 +185,7 @@ function FilesystemTab({
</Button>
<Button
aria-label={r.collapseAll}
className={cn(HEADER_ACTION_CLASS, !canCollapse && 'pointer-events-none opacity-0')}
className={HEADER_ACTION_REVEAL_CLASS}
disabled={!hasCwd || !canCollapse}
onClick={onCollapseAll}
size="icon-xs"
@@ -208,7 +205,6 @@ function FilesystemTab({
onLoadChildren={onLoadChildren}
onNodeOpenChange={onNodeOpenChange}
onPreviewFile={onPreviewFile}
onRetry={onRefresh}
openState={openState}
/>
</div>
@@ -230,9 +226,6 @@ interface FileTreeBodyProps {
onLoadChildren: (id: string) => void | Promise<void>
onNodeOpenChange: (id: string, open: boolean) => void
onPreviewFile?: (path: string) => void
/** Force-reload the root. The hook also auto-retries while errored, so this
* is the impatient-user path. */
onRetry?: () => void
openState: ReturnType<typeof useProjectTree>['openState']
}
@@ -247,7 +240,6 @@ function FileTreeBody({
onLoadChildren,
onNodeOpenChange,
onPreviewFile,
onRetry,
openState
}: FileTreeBodyProps) {
const { t } = useI18n()
@@ -258,20 +250,7 @@ function FileTreeBody({
}
if (error) {
return (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-4 text-center">
<EmptyState body={r.unreadableBody(error)} title={r.unreadableTitle} />
{onRetry && (
<button
className="text-[0.68rem] font-medium text-muted-foreground transition hover:text-foreground"
onClick={onRetry}
type="button"
>
{r.tryAgain}
</button>
)}
</div>
)
return <EmptyState body={r.unreadableBody(error)} title={r.unreadableTitle} />
}
if (loading && data.length === 0) {

View File

@@ -9,7 +9,7 @@ import { useI18n } from '@/i18n'
import { SidebarPanelLabel } from '../../shell/sidebar-label'
import { setTerminalTakeover } from '../store'
import { KbdCombo } from '@/components/ui/kbd'
import { addSelectionShortcutLabel } from './selection'
import { useTerminalSession } from './use-terminal-session'
interface TerminalTabProps {
@@ -69,7 +69,7 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
variant="secondary"
>
{t.rightSidebar.addToChat}
<KbdCombo className="ml-1 opacity-70" combo="mod+l" size="sm" />
<span className="ml-1 text-[0.6rem] text-(--ui-text-tertiary)">{addSelectionShortcutLabel()}</span>
</Button>
</div>
)}

View File

@@ -99,6 +99,8 @@ export function resolveSurfaceColor(fallback: string): string {
export const isMacPlatform = () => navigator.platform.toLowerCase().includes('mac')
export const addSelectionShortcutLabel = () => (isMacPlatform() ? '⌘L' : 'Ctrl+L')
export function isAddSelectionShortcut(event: KeyboardEvent) {
const mod = isMacPlatform() ? event.metaKey : event.ctrlKey

View File

@@ -7,7 +7,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { CSSProperties } from 'react'
import { triggerHaptic } from '@/lib/haptics'
import { $filePreviewTarget, $previewTarget } from '@/store/preview'
import { useTheme } from '@/themes/context'
import { makeTerminalReader, setActiveTerminalReader } from './buffer'
@@ -21,17 +20,6 @@ import {
type TerminalStatus = 'closed' | 'open' | 'starting'
// ⌘/Ctrl+L is a global shortcut, so a text selection in the file preview pane
// lands in this handler with no xterm selection. Label those with the previewed
// file's name instead of the shell, so the composer ref reads as a file quote
// rather than a bogus "zsh:N lines".
function previewSelectionLabel(): string {
const target = $filePreviewTarget.get() ?? $previewTarget.get()
const source = target?.path || target?.url || ''
return source.split(/[\\/]/).filter(Boolean).pop() || target?.label?.trim() || ''
}
const HERMES_PATHS_MIME = 'application/x-hermes-paths'
function readEscapeSequence(data: string, index: number) {
@@ -269,20 +257,16 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
)
const addSelectionToChat = useCallback(() => {
const termSelection = (termRef.current?.getSelection() || selectionRef.current).trim()
const selectedText = termSelection || window.getSelection()?.toString() || ''
const selectedText = readSelection() || selectionRef.current
const trimmed = selectedText.trim()
if (!trimmed) {
return
}
// Terminal selection → shell-anchored label; anything else came from the
// preview pane sharing this global shortcut → label it with the file.
const label = termSelection
? selectionLabelRef.current ||
(termRef.current ? terminalSelectionLabel(termRef.current, shellNameRef.current, selectedText) : 'selection')
: previewSelectionLabel() || 'selection'
const label =
selectionLabelRef.current ||
(termRef.current ? terminalSelectionLabel(termRef.current, shellNameRef.current, selectedText) : 'selection')
onAddSelectionToChatRef.current(trimmed, label)
termRef.current?.clearSelection()
@@ -291,7 +275,7 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
setSelection('')
setSelectionStyle(null)
triggerHaptic('selection')
}, [])
}, [readSelection])
// Always listen — gating on the React selection state misses selections the
// TUI redraw races. Only swallow ⌘/Ctrl+L when there's text to send, else it
@@ -328,20 +312,11 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
const term = new Terminal({
allowProposedApi: true,
// Opaque canvas = WebGL's crisp fast-path. allowTransparency instead bakes
// glyphs as grayscale-alpha for compositing over a see-through canvas, which
// reads soft on every platform; VS Code keeps it off and our surface
// (--ui-bg-chrome) is opaque anyway, so withSurface paints it solid.
allowTransparency: false,
allowTransparency: true,
convertEol: true,
cursorBlink: true,
fontFamily: "'JetBrains Mono', 'Cascadia Code', 'SF Mono', Menlo, Consolas, monospace",
fontFamily: "'SF Mono', 'Menlo', 'Cascadia Code', 'JetBrains Mono', monospace",
fontSize: 11,
// VS Code's terminal renders 'normal'/'bold' (400/700); we were using Medium
// (500) as the base, which reads a touch heavy at this size.
fontWeight: 'normal',
fontWeightBold: 'bold',
letterSpacing: 0,
lineHeight: 1.12,
// Full-screen TUIs (hermes --tui, vim) grab the mouse, so a plain drag
// can't select — ⌥-drag (macOS) / Shift-drag (else) forces a native
@@ -623,15 +598,13 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
startSession()
}
// fonts.ready settles only already-requested faces; the regular (400),
// bold (700) and italic aren't asked for until styled output paints (past
// atlas init), so warm them up front — otherwise the WebGL atlas bakes a
// fallback face and the terminal renders thin until a repaint.
const warm = document.fonts?.load
? Promise.allSettled(['400', '700', 'italic 400'].map(v => document.fonts.load(`${v} 11px 'JetBrains Mono'`)))
: Promise.resolve()
const fonts = typeof document !== 'undefined' ? document.fonts : undefined
void warm.then(mount, mount)
if (fonts?.ready) {
void fonts.ready.then(mount, mount)
} else {
mount()
}
return () => {
disposed = true

View File

@@ -16,16 +16,9 @@ import {
} from '@/lib/chat-messages'
import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime'
import { gatewayEventRequiresSessionId } from '@/lib/gateway-events'
import {
dedupeGeneratedImageEchoesInParts,
generatedImageEchoSources,
stripGeneratedImageEchoes
} from '@/lib/generated-images'
import { triggerHaptic } from '@/lib/haptics'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { parseTodos } from '@/lib/todos'
import { setClarifyRequest } from '@/store/clarify'
import { refreshBackgroundProcesses } from '@/store/composer-status'
import { $gateway } from '@/store/gateway'
import { notify } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
@@ -44,7 +37,6 @@ import {
setYoloActive
} from '@/store/session'
import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents'
import { setSessionTodos } from '@/store/todos'
import { recordToolDiff } from '@/store/tool-diffs'
import type { RpcEvent } from '@/types/hermes'
@@ -60,7 +52,6 @@ interface MessageStreamOptions {
queryClient: QueryClient
refreshHermesConfig: () => Promise<void>
refreshSessions: () => Promise<void>
sessionStateByRuntimeIdRef: MutableRefObject<Map<string, ClientSessionState>>
updateSessionState: (
sessionId: string,
updater: (state: ClientSessionState) => ClientSessionState,
@@ -73,59 +64,6 @@ interface QueuedStreamDeltas {
reasoning: string
}
type SessionRuntimeStatePatch = Partial<
Pick<
ClientSessionState,
'branch' | 'cwd' | 'fast' | 'model' | 'personality' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo'
>
>
function sessionInfoStatePatch(payload: GatewayEventPayload | undefined): SessionRuntimeStatePatch {
const patch: SessionRuntimeStatePatch = {}
if (typeof payload?.model === 'string') {
patch.model = payload.model || ''
}
if (typeof payload?.provider === 'string') {
patch.provider = payload.provider || ''
}
if (typeof payload?.cwd === 'string') {
patch.cwd = payload.cwd
}
if (typeof payload?.branch === 'string') {
patch.branch = payload.branch
}
if (typeof payload?.personality === 'string') {
patch.personality = normalizePersonalityValue(payload.personality)
}
if (typeof payload?.reasoning_effort === 'string') {
patch.reasoningEffort = payload.reasoning_effort
}
if (typeof payload?.service_tier === 'string') {
patch.serviceTier = payload.service_tier
}
if (typeof payload?.fast === 'boolean') {
patch.fast = payload.fast
}
if (typeof payload?.yolo === 'boolean') {
patch.yolo = payload.yolo
}
return patch
}
function hasSessionInfoStatePatch(patch: SessionRuntimeStatePatch): boolean {
return Object.keys(patch).length > 0
}
// Minimum gap between two assistant-text flushes during a stream. Was 16ms
// (rAF only), which at typical LLM token rates of ~30-80 tok/sec meant every
// token got its own React commit + Streamdown markdown re-parse, scaling
@@ -254,14 +192,8 @@ export function useMessageStream({
queryClient,
refreshHermesConfig,
refreshSessions,
sessionStateByRuntimeIdRef,
updateSessionState
}: MessageStreamOptions) {
const sessionInterrupted = useCallback(
(sessionId: string) => sessionStateByRuntimeIdRef.current.get(sessionId)?.interrupted ?? false,
[sessionStateByRuntimeIdRef]
)
// Patch the in-flight assistant message (or seed it). Centralises the
// streamId/groupId bookkeeping every event callback would otherwise repeat.
const mutateStream = useCallback(
@@ -348,7 +280,7 @@ export function useMessageStream({
if (queued.assistant) {
mutateStream(
id,
parts => dedupeGeneratedImageEchoesInParts(appendAssistantTextPart(parts, queued.assistant)),
parts => appendAssistantTextPart(parts, queued.assistant),
() => [assistantTextPart(queued.assistant)]
)
}
@@ -485,20 +417,6 @@ export function useMessageStream({
// a tool part can't jump ahead of the text that preceded it.
flushQueuedDeltas(sessionId)
if (sessionInterrupted(sessionId)) {
return
}
// The composer status stack owns todo display now (no inline panel) —
// mirror every todo state the tool reports into its session store.
if (payload?.name === 'todo') {
const todos = parseTodos(payload.todos) ?? parseTodos(payload.result) ?? parseTodos(payload.args)
if (todos) {
setSessionTodos(sessionId, todos)
}
}
if (!nativeSubagentSessionsRef.current.has(sessionId)) {
for (const subagentPayload of delegateTaskPayloads(payload, phase, sourceEventType)) {
upsertSubagent(
@@ -512,12 +430,12 @@ export function useMessageStream({
mutateStream(
sessionId,
parts => dedupeGeneratedImageEchoesInParts(upsertToolPart(parts, payload, phase)),
parts => upsertToolPart(parts, payload, phase),
() => upsertToolPart([], payload, phase),
{ pending: m => phase !== 'complete' || (m.pending ?? false) }
)
},
[flushQueuedDeltas, mutateStream, sessionInterrupted]
[flushQueuedDeltas, mutateStream]
)
const completeAssistantMessage = useCallback(
@@ -545,11 +463,9 @@ export function useMessageStream({
const finalText = renderMediaTags(text).trim()
const completionError = completionErrorText(finalText)
const normalize = (value: string) => value.replace(/\s+/g, ' ').trim()
const dedupeReference = normalize(finalText)
const replaceTextPart = (parts: ChatMessagePart[]) => {
const visibleFinalText = stripGeneratedImageEchoes(finalText, generatedImageEchoSources(parts)).trim()
const dedupeReference = normalize(visibleFinalText)
const kept = parts.filter(part => {
if (part.type === 'text') {
return false
@@ -564,7 +480,7 @@ export function useMessageStream({
return !(r && (dedupeReference.startsWith(r) || r.startsWith(dedupeReference)))
})
return visibleFinalText ? [...kept, assistantTextPart(visibleFinalText)] : kept
return finalText ? [...kept, assistantTextPart(finalText)] : kept
}
const completeMessage = (message: ChatMessage): ChatMessage =>
@@ -700,11 +616,9 @@ export function useMessageStream({
(event: RpcEvent) => {
const payload = event.payload as GatewayEventPayload | undefined
const explicitSid = event.session_id || ''
if (!explicitSid && gatewayEventRequiresSessionId(event.type)) {
return
}
const sessionId = explicitSid || activeSessionIdRef.current
const isActiveEvent = !!sessionId && sessionId === activeSessionIdRef.current
@@ -714,27 +628,36 @@ export function useMessageStream({
// Apply session-scoped fields when the event targets the active
// session, OR when it's a global broadcast and we have no session.
const apply = explicitSid ? isActiveEvent : !activeSessionIdRef.current
const statePatch = sessionInfoStatePatch(payload)
const hasStatePatch = hasSessionInfoStatePatch(statePatch)
const modelChanged = typeof payload?.model === 'string'
const providerChanged = typeof payload?.provider === 'string'
const runningChanged = typeof payload?.running === 'boolean'
if (apply) {
const runtimeInfo: Partial<
Pick<
ClientSessionState,
'branch' | 'cwd' | 'fast' | 'model' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo'
>
> = {}
if (modelChanged) {
setCurrentModel(payload!.model || '')
runtimeInfo.model = payload!.model || ''
}
if (providerChanged) {
setCurrentProvider(payload!.provider || '')
runtimeInfo.provider = payload!.provider || ''
}
if (typeof payload?.cwd === 'string') {
setCurrentCwd(payload.cwd)
runtimeInfo.cwd = payload.cwd
}
if (typeof payload?.branch === 'string') {
setCurrentBranch(payload.branch)
runtimeInfo.branch = payload.branch
}
if (typeof payload?.personality === 'string') {
@@ -743,31 +666,28 @@ export function useMessageStream({
if (typeof payload?.reasoning_effort === 'string') {
setCurrentReasoningEffort(payload.reasoning_effort)
runtimeInfo.reasoningEffort = payload.reasoning_effort
}
if (typeof payload?.service_tier === 'string') {
setCurrentServiceTier(payload.service_tier)
runtimeInfo.serviceTier = payload.service_tier
}
if (typeof payload?.fast === 'boolean') {
setCurrentFastMode(payload.fast)
runtimeInfo.fast = payload.fast
}
if (typeof payload?.yolo === 'boolean') {
setYoloActive(payload.yolo)
runtimeInfo.yolo = payload.yolo
}
}
if (sessionId && hasStatePatch) {
updateSessionState(sessionId, state => ({
...state,
...statePatch,
branch: statePatch.branch ?? state.branch,
cwd: statePatch.cwd ?? state.cwd
}))
}
if (sessionId && Object.keys(runtimeInfo).length > 0) {
updateSessionState(sessionId, state => ({ ...state, ...runtimeInfo }))
}
if (apply) {
if (runningChanged && sessionId) {
updateSessionState(sessionId, state => {
const busy = Boolean(payload!.running)
@@ -900,22 +820,13 @@ export function useMessageStream({
// the sidebar indicator clears as soon as it's answered, not only at
// message.complete.
updateSessionState(sessionId, state => (state.needsInput ? { ...state, needsInput: false } : state))
// terminal/process tool calls are the only things that spawn or reap
// background processes — sync the composer status stack right after.
if (
!sessionInterrupted(sessionId) &&
(payload?.name === 'terminal' || payload?.name === 'process')
) {
void refreshBackgroundProcesses(sessionId)
}
}
if (typeof payload?.inline_diff === 'string' && payload.inline_diff.trim()) {
recordToolDiff(payload.tool_id || payload.name || '', payload.inline_diff)
}
} else if (SUBAGENT_EVENT_TYPES.has(event.type)) {
if (sessionId && payload && !sessionInterrupted(sessionId)) {
if (sessionId && payload) {
if (!nativeSubagentSessionsRef.current.has(sessionId)) {
pruneDelegateFallbackSubagents(sessionId)
}
@@ -967,8 +878,6 @@ export function useMessageStream({
// raise it and wait — the sidebar flags "needs input" and the inline bar
// surfaces once the user focuses that chat.
setApprovalRequest({
// false only when a tirith warning forbids it; backend omits the field otherwise.
allowPermanent: payload?.allow_permanent !== false,
command: typeof payload?.command === 'string' ? payload.command : '',
description: typeof payload?.description === 'string' ? payload.description : 'dangerous command',
sessionId: sessionId ?? null
@@ -1021,12 +930,6 @@ export function useMessageStream({
text: result ? JSON.stringify(result) : ''
})
}
} else if (event.type === 'status.update') {
// The gateway's notification poller announces background process
// completions / watch matches here — re-sync the status stack.
if (sessionId && payload?.kind === 'process') {
void refreshBackgroundProcesses(sessionId)
}
} else if (event.type === 'error') {
const errorMessage = payload?.message || 'Hermes reported an error'
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)
@@ -1067,7 +970,6 @@ export function useMessageStream({
flushQueuedDeltas,
queryClient,
refreshHermesConfig,
sessionInterrupted,
updateSessionState,
upsertToolCall
]

View File

@@ -3,9 +3,8 @@ import type { MutableRefObject } from 'react'
import { useEffect, useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { textPart } from '@/lib/chat-messages'
import { $composerAttachments, type ComposerAttachment } from '@/store/composer'
import { $busy, $connection, $messages, $sessions, setSessions } from '@/store/session'
import { $connection, $sessions, setSessions } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
import { uploadComposerAttachment, usePromptActions } from './use-prompt-actions'
@@ -44,7 +43,6 @@ function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
interface HarnessHandle {
cancelRun: () => Promise<void>
restoreToMessage: (messageId: string) => Promise<void>
steerPrompt: (text: string) => Promise<boolean>
submitText: (
text: string,
@@ -59,7 +57,6 @@ function Harness({
refreshSessions,
requestGateway,
resumeStoredSession,
seedMessages,
storedSessionId
}: {
busyRef?: MutableRefObject<boolean>
@@ -68,7 +65,6 @@ function Harness({
refreshSessions: () => Promise<void>
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
resumeStoredSession?: (storedSessionId: string) => Promise<void> | void
seedMessages?: unknown[]
storedSessionId?: null | string
}) {
const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
@@ -77,7 +73,7 @@ function Harness({
}
const localBusyRef = busyRef ?? { current: false }
const stateRef = useRef({
messages: seedMessages ?? [],
messages: [],
busy: false,
awaitingResponse: false,
interrupted: true
@@ -109,11 +105,10 @@ function Harness({
useEffect(() => {
onReady({
cancelRun: actions.cancelRun,
restoreToMessage: actions.restoreToMessage,
steerPrompt: actions.steerPrompt,
submitText: actions.submitText
})
}, [actions.cancelRun, actions.restoreToMessage, actions.steerPrompt, actions.submitText, onReady])
}, [actions.cancelRun, actions.steerPrompt, actions.submitText, onReady])
return null
}
@@ -325,81 +320,6 @@ describe('usePromptActions submit / queue drain semantics', () => {
})
})
it('a rejected fromQueue drain returns false (entry stays queued) and a later retry sends it', async () => {
// A stale-session 404 must not strand the queued entry: submitPrompt returns
// false on failure so the composer keeps it, and the edge-independent
// auto-drain re-attempts once the session is idle again. storedSessionId is
// null so the session.resume recovery path is skipped and the error surfaces.
let attempt = 0
const requestGateway = vi.fn(async (method: string) => {
if (method === 'prompt.submit') {
attempt += 1
if (attempt === 1) {
throw new Error('404: {"detail":"Session not found"}')
}
}
return {} as never
})
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
storedSessionId={null}
/>
)
const first = await handle!.submitText('please send me', { fromQueue: true })
expect(first).toBe(false)
const second = await handle!.submitText('please send me', { fromQueue: true })
expect(second).toBe(true)
expect(requestGateway).toHaveBeenCalledWith('prompt.submit', {
session_id: RUNTIME_SESSION_ID,
text: 'please send me'
})
})
it('rides out a transient "session busy" so the user never sees it (retries, no error bubble)', async () => {
// A submit racing the settle edge can hit a transient 4009 before the turn
// has fully wound down. It must be invisible: retried in place until the
// gateway accepts, never a red "session busy" bubble.
let attempt = 0
const seeds: Record<string, unknown>[] = []
const requestGateway = vi.fn(async (method: string) => {
if (method === 'prompt.submit') {
attempt += 1
if (attempt === 1) {
throw new Error('4009: session busy')
}
}
return {} as never
})
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
onSeedState={s => seeds.push(s)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
expect(await handle!.submitText('sent while settling')).toBe(true)
expect(attempt).toBe(2) // rode past the busy on the second try
// No assistant-error message was appended for the transient busy.
expect(seeds.some(s => Array.isArray(s.messages) && (s.messages as { error?: string }[]).some(m => m.error))).toBe(
false
)
})
it('a normal (non-queue) submit still respects the busyRef guard', async () => {
const busyRef = { current: true }
const requestGateway = vi.fn(async () => ({}) as never)
@@ -475,125 +395,6 @@ describe('usePromptActions steerPrompt', () => {
})
})
describe('usePromptActions restoreToMessage', () => {
beforeEach(() => {
$busy.set(false)
$messages.set([
{ id: 'u1', role: 'user', parts: [textPart('first prompt')] },
{ id: 'a1', role: 'assistant', parts: [textPart('first answer')] },
{ id: 'u2', role: 'user', parts: [textPart('second prompt')] },
{ id: 'a2', role: 'assistant', parts: [textPart('second answer')] }
])
})
afterEach(() => {
cleanup()
$busy.set(false)
$messages.set([])
vi.restoreAllMocks()
})
it('rewinds to the target user turn and resubmits its text', async () => {
const requestGateway = vi.fn(async () => ({}) as never)
let lastState: Record<string, unknown> = {}
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
onSeedState={state => (lastState = state)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
seedMessages={$messages.get()}
/>
)
await handle!.restoreToMessage('u1')
// Ordinal 0 = "truncate before the first visible user message": the gateway
// drops that turn and everything after, then runs the same text again.
expect(requestGateway).toHaveBeenCalledWith('prompt.submit', {
session_id: RUNTIME_SESSION_ID,
text: 'first prompt',
truncate_before_user_ordinal: 0
})
expect((lastState.messages as { id: string }[]).map(m => m.id)).toEqual(['u1'])
expect(lastState.busy).toBe(true)
})
it('rethrows gateway failures and clears the busy flags for the dialog to surface', async () => {
const requestGateway = vi.fn(async () => {
throw new Error('gateway exploded')
})
let lastState: Record<string, unknown> = {}
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
onSeedState={state => (lastState = state)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
await expect(handle!.restoreToMessage('u2')).rejects.toThrow('gateway exploded')
expect(lastState.busy).toBe(false)
})
it('interrupts the live turn and retries past "session busy" when reverting mid-stream', async () => {
$busy.set(true)
let submitAttempts = 0
const requestGateway = vi.fn(async (method: string) => {
if (method === 'prompt.submit') {
submitAttempts += 1
// The cooperative interrupt hasn't wound the turn down yet on the first
// try; the second attempt lands once the gateway reports idle.
if (submitAttempts === 1) {
throw new Error('session busy')
}
}
return {} as never
})
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
seedMessages={$messages.get()}
/>
)
await handle!.restoreToMessage('u1')
expect(requestGateway).toHaveBeenCalledWith('session.interrupt', { session_id: RUNTIME_SESSION_ID })
expect(submitAttempts).toBe(2)
expect(requestGateway).toHaveBeenCalledWith('prompt.submit', {
session_id: RUNTIME_SESSION_ID,
text: 'first prompt',
truncate_before_user_ordinal: 0
})
})
it('ignores non-user targets and unknown ids without touching the gateway', async () => {
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
await handle!.restoreToMessage('a1')
await handle!.restoreToMessage('missing')
expect(requestGateway).not.toHaveBeenCalled()
})
})
describe('usePromptActions file attachment sync', () => {
afterEach(() => {
cleanup()
@@ -876,7 +677,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
const requestGateway = vi.fn(async (method: string) => {
calls.push(method)
if (method === 'prompt.submit') {
throw new Error('gateway exploded')
throw new Error('session busy')
}
return {} as never
})

View File

@@ -35,7 +35,6 @@ import {
terminalContextBlocksFromDraft,
updateComposerAttachment
} from '@/store/composer'
import { resetSessionBackground } from '@/store/composer-status'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
@@ -53,8 +52,6 @@ import {
setSessions,
setYoloActive
} from '@/store/session'
import { clearSessionSubagents } from '@/store/subagents'
import { clearSessionTodos } from '@/store/todos'
import type {
ClientSessionState,
@@ -117,40 +114,6 @@ function isSessionNotFoundError(error: unknown): boolean {
return /session not found/i.test(message)
}
// The gateway refuses prompt.submit while a turn is running (4009 "session
// busy"). It's a transient concurrency guard, never a user-facing error: a
// submit racing the settle edge (or a rewind interrupting mid-turn) just waits
// a beat for the turn to wind down, then lands. Bounded so a genuinely stuck
// turn still surfaces eventually.
const SESSION_BUSY_RETRY_TIMEOUT_MS = 6_000
const SESSION_BUSY_RETRY_INTERVAL_MS = 150
function isSessionBusyError(error: unknown): boolean {
return /session busy/i.test(error instanceof Error ? error.message : String(error))
}
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
// Retry a gateway call across transient "session busy" so it never reaches the
// user — the turn settles within the deadline and the call lands.
async function withSessionBusyRetry<T>(call: () => Promise<T>): Promise<T> {
const deadline = Date.now() + SESSION_BUSY_RETRY_TIMEOUT_MS
for (;;) {
try {
return await call()
} catch (err) {
if (isSessionBusyError(err) && Date.now() < deadline) {
await sleep(SESSION_BUSY_RETRY_INTERVAL_MS)
continue
}
throw err
}
}
}
function base64FromDataUrl(dataUrl: string): string {
const comma = dataUrl.indexOf(',')
@@ -560,7 +523,6 @@ export function usePromptActions({
// Images use their base64 preview so the thumbnail renders inline without
// a (remote-mode 403-prone) /api/media fetch — see optimisticAttachmentRef.
let attachmentRefs = attachments.map(optimisticAttachmentRef).filter((r): r is string => Boolean(r))
const buildContextText = (atts: ComposerAttachment[]): string => {
const contextRefs = atts
.map(a => a.refText)
@@ -578,7 +540,6 @@ export function usePromptActions({
// bounce the drained send. The drain lock serializes them; the user path
// keeps the guard so a stray Enter mid-turn can't double-submit.
const hasSendable = Boolean(visibleText || terminalContextBlocks || attachments.length || hasImage)
if (!hasSendable || (!options?.fromQueue && busyRef.current)) {
return false
}
@@ -691,7 +652,6 @@ export function usePromptActions({
const syncedAttachments = await syncAttachmentsForSubmit(sessionId, attachments, {
updateComposerAttachments: usingComposerAttachments
})
// Rewrite the optimistic message + prompt text with the synced refs so
// the gateway receives @file: paths that resolve in its workspace.
// (Images keep their inline base64 preview — see optimisticAttachmentRef.)
@@ -705,19 +665,18 @@ export function usePromptActions({
let submitErr: unknown = null
try {
await withSessionBusyRetry(() => requestGateway('prompt.submit', { session_id: sessionId, text }))
await requestGateway('prompt.submit', { session_id: sessionId, text })
} catch (firstErr) {
if (isSessionNotFoundError(firstErr) && selectedStoredSessionIdRef.current) {
// Re-register the session in the gateway and get a fresh live ID.
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
session_id: selectedStoredSessionIdRef.current
})
const recoveredId = resumed?.session_id
if (recoveredId) {
activeSessionIdRef.current = recoveredId
await withSessionBusyRetry(() => requestGateway('prompt.submit', { session_id: recoveredId, text }))
await requestGateway('prompt.submit', { session_id: recoveredId, text })
} else {
submitErr = firstErr
}
@@ -736,17 +695,9 @@ export function usePromptActions({
return true
} catch (err) {
releaseBusy()
// A queued drain that raced a not-yet-settled turn gets a transient
// "session busy" (4009). Don't surface an error bubble/toast — the entry
// stays queued and the composer's bounded auto-drain retries when idle.
if (options?.fromQueue && isSessionBusyError(err)) {
return false
}
const message = inlineErrorMessage(err, copy.promptFailed)
releaseBusy()
updateSessionState(sessionId, state => ({
...state,
messages: [
@@ -1283,13 +1234,12 @@ export function usePromptActions({
const cancelRun = useCallback(async () => {
const sessionId = activeSessionId || activeSessionIdRef.current
const releaseBusy = () => {
setMutableRef(busyRef, false)
setBusy(false)
}
setAwaitingResponse(false)
// Interrupting keeps whatever was already generated and just
// stops — no "[interrupted]" marker. A pending/streaming message with no
// body text is dropped entirely so we never leave an empty bubble behind.
const finalizeMessages = (messages: ChatMessage[], streamId?: string | null) =>
messages
.filter(
@@ -1301,7 +1251,8 @@ export function usePromptActions({
)
if (!sessionId) {
releaseBusy()
setMutableRef(busyRef, false)
setBusy(false)
setMessages(finalizeMessages($messages.get()))
return
@@ -1309,12 +1260,13 @@ export function usePromptActions({
updateSessionState(sessionId, state => {
const streamId = state.streamId
const messages = finalizeMessages(state.messages, streamId)
return {
...state,
messages,
busy: false,
busy: true,
awaitingResponse: false,
streamId: null,
pendingBranchGroup: null,
@@ -1322,13 +1274,8 @@ export function usePromptActions({
}
})
clearSessionTodos(sessionId)
clearSessionSubagents(sessionId)
resetSessionBackground(sessionId)
try {
await requestGateway('session.interrupt', { session_id: sessionId })
releaseBusy()
} catch (err) {
let stopError = err
@@ -1337,13 +1284,11 @@ export function usePromptActions({
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
session_id: selectedStoredSessionIdRef.current
})
const recoveredId = resumed?.session_id
if (recoveredId) {
activeSessionIdRef.current = recoveredId
await requestGateway('session.interrupt', { session_id: recoveredId })
releaseBusy()
return
}
@@ -1352,7 +1297,8 @@ export function usePromptActions({
}
}
releaseBusy()
setMutableRef(busyRef, false)
setBusy(false)
notifyError(stopError, copy.stopFailed)
}
}, [
@@ -1475,101 +1421,13 @@ export function usePromptActions({
[activeSessionId, copy.regenerateFailed, requestGateway, updateSessionState]
)
// Cursor-style "restore checkpoint": rewind the conversation to a past user
// prompt and run it again from there. Reuses the edit composer's rewind
// mechanism — `prompt.submit` with `truncate_before_user_ordinal` drops that
// user turn and everything after it from the session history, then the same
// text is submitted as a fresh turn. Callers confirm before invoking; errors
// are rethrown so the confirmation dialog can surface them inline.
// Submit a rewind (truncate-before-ordinal + resubmit). Because edit/restore
// can fire while a turn is streaming, interrupt the live turn first — the
// cooperative interrupt takes a beat, so the shared busy-retry rides it out.
const submitRewindPrompt = useCallback(
async (sessionId: string, text: string, truncateOrdinal: number | undefined, wasRunning: boolean) => {
if (wasRunning) {
try {
await requestGateway('session.interrupt', { session_id: sessionId })
} catch {
// Best-effort — the busy-retry below still gates the submit.
}
}
await withSessionBusyRetry(() =>
requestGateway('prompt.submit', {
session_id: sessionId,
text,
...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal })
})
)
},
[requestGateway]
)
const restoreToMessage = useCallback(
async (messageId: string) => {
const sessionId = activeSessionId || activeSessionIdRef.current
if (!sessionId) {
return
}
const messages = $messages.get()
const sourceIndex = messages.findIndex(m => m.id === messageId)
const source = messages[sourceIndex]
if (!source || source.role !== 'user') {
return
}
const text = chatMessageText(source).trim()
if (!text) {
return
}
const wasRunning = $busy.get()
const truncateBeforeUserOrdinal = visibleUserOrdinal(messages, sourceIndex)
// The turns we're discarding may have spawned todos and background
// processes; they belong to the abandoned timeline, so wipe their status
// rows (and kill the live processes) before the fresh run repopulates.
clearSessionTodos(sessionId)
resetSessionBackground(sessionId)
clearNotifications()
setMutableRef(busyRef, true)
setBusy(true)
setAwaitingResponse(true)
updateSessionState(sessionId, state => ({
...state,
busy: true,
awaitingResponse: true,
pendingBranchGroup: null,
sawAssistantPayload: false,
interrupted: false,
messages: state.messages.slice(0, sourceIndex + 1)
}))
try {
await submitRewindPrompt(sessionId, text, truncateBeforeUserOrdinal, wasRunning)
} catch (err) {
setMutableRef(busyRef, false)
setBusy(false)
setAwaitingResponse(false)
updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false }))
throw err
}
},
[activeSessionId, activeSessionIdRef, busyRef, submitRewindPrompt, updateSessionState]
)
const editMessage = useCallback(
async (edited: AppendMessage) => {
const sessionId = activeSessionId || activeSessionIdRef.current
const sourceId = edited.sourceId || edited.parentId
const text = appendText(edited)
if (!sessionId || !sourceId || !text || edited.role !== 'user') {
if (!sessionId || !sourceId || !text || edited.role !== 'user' || $busy.get()) {
return
}
@@ -1581,23 +1439,12 @@ export function usePromptActions({
return
}
// Sending an edit is a revert: rewind to this prompt and re-run with the
// new text. It can fire mid-turn, so capture the live state — the submit
// helper interrupts first when a turn is running.
const wasRunning = $busy.get()
// Failed turn: optimistic user msg never reached the gateway, so truncating
// by ordinal would 422. Submit as a plain resend instead.
const nextMessage = messages[sourceIndex + 1]
const isFailedTurn = nextMessage?.role === 'assistant' && Boolean(nextMessage.error)
const editedMessage: ChatMessage = { ...source, parts: [textPart(text)] }
// Editing rewinds the conversation to this prompt — same as restore — so
// drop the abandoned timeline's todos/background rows (and kill the live
// processes) before the re-run repopulates them.
clearSessionTodos(sessionId)
resetSessionBackground(sessionId)
clearNotifications()
setMutableRef(busyRef, true)
setBusy(true)
@@ -1612,18 +1459,24 @@ export function usePromptActions({
messages: [...state.messages.slice(0, sourceIndex), editedMessage]
}))
const submit = (truncateOrdinal?: number) =>
requestGateway('prompt.submit', {
session_id: sessionId,
text,
...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal })
})
const isStaleTargetError = (err: unknown) =>
/no longer in session history|not in session history/i.test(err instanceof Error ? err.message : String(err))
try {
await submitRewindPrompt(sessionId, text, isFailedTurn ? undefined : visibleUserOrdinal(messages, sourceIndex), wasRunning)
await submit(isFailedTurn ? undefined : visibleUserOrdinal(messages, sourceIndex))
} catch (err) {
let surfaced = err
if (!isFailedTurn && isStaleTargetError(err)) {
try {
// Already interrupted on the first attempt — submit as a plain resend.
await submitRewindPrompt(sessionId, text, undefined, false)
await submit()
return
} catch (retryErr) {
@@ -1638,7 +1491,7 @@ export function usePromptActions({
notifyError(surfaced, copy.editFailed)
}
},
[activeSessionId, activeSessionIdRef, busyRef, copy.editFailed, submitRewindPrompt, updateSessionState]
[activeSessionId, activeSessionIdRef, busyRef, copy.editFailed, requestGateway, updateSessionState]
)
const handleThreadMessagesChange = useCallback(
@@ -1681,7 +1534,6 @@ export function usePromptActions({
handleThreadMessagesChange,
handoffSession,
reloadFromMessage,
restoreToMessage,
steerPrompt,
submitText,
transcribeVoiceAudio

View File

@@ -1,119 +0,0 @@
import { cleanup, render, waitFor } from '@testing-library/react'
import type { MutableRefObject } from 'react'
import { useEffect } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { $activeGatewayProfile, $newChatProfile } from '@/store/profile'
import { $currentCwd } from '@/store/session'
import type { ClientSessionState } from '../../types'
import { useSessionActions } from './use-session-actions'
vi.mock('@/hermes', async importOriginal => ({
...(await importOriginal<Record<string, unknown>>()),
deleteSession: vi.fn(),
getSessionMessages: vi.fn(),
listAllProfileSessions: vi.fn(),
setApiRequestProfile: vi.fn(),
setSessionArchived: vi.fn()
}))
const RUNTIME_SESSION_ID = 'rt-new-001'
function Harness({
onReady,
requestGateway
}: {
onReady: (create: (preview?: string | null) => Promise<string | null>) => void
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}) {
const ref = <T,>(value: T): MutableRefObject<T> => ({ current: value })
const actions = useSessionActions({
activeSessionId: null,
activeSessionIdRef: ref<string | null>(null),
busyRef: ref(false),
creatingSessionRef: ref(false),
ensureSessionState: () => ({}) as ClientSessionState,
getRouteToken: () => 'token',
navigate: vi.fn() as never,
requestGateway,
runtimeIdByStoredSessionIdRef: ref(new Map<string, string>()),
selectedStoredSessionId: null,
selectedStoredSessionIdRef: ref<string | null>(null),
sessionStateByRuntimeIdRef: ref(new Map<string, ClientSessionState>()),
syncSessionStateToView: vi.fn(),
updateSessionState: () => ({}) as ClientSessionState
})
useEffect(() => {
onReady(actions.createBackendSessionForSend)
}, [actions.createBackendSessionForSend, onReady])
return null
}
async function createWith(profileSetup: () => void): Promise<Record<string, unknown> | undefined> {
let createParams: Record<string, unknown> | undefined
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === 'session.create') {
createParams = params
return { session_id: RUNTIME_SESSION_ID, stored_session_id: null } as never
}
return {} as never
})
$currentCwd.set('')
profileSetup()
let create: ((preview?: string | null) => Promise<string | null>) | null = null
render(<Harness onReady={c => (create = c)} requestGateway={requestGateway} />)
await waitFor(() => expect(create).not.toBeNull())
await create!()
return createParams
}
describe('createBackendSessionForSend profile routing', () => {
afterEach(() => {
cleanup()
$newChatProfile.set(null)
$activeGatewayProfile.set('default')
vi.restoreAllMocks()
})
it('routes a plain new chat (no explicit profile) to the live gateway profile', async () => {
// The "rubberband to default" bug: the top New Session button clears
// $newChatProfile to null. In global-remote mode one backend serves every
// profile, so an omitted `profile` lands the chat on the launch (default)
// profile. The session must instead carry the active gateway profile.
const params = await createWith(() => {
$activeGatewayProfile.set('coder')
$newChatProfile.set(null)
})
expect(params).toMatchObject({ profile: 'coder' })
})
it('honours an explicit per-profile "+" selection', async () => {
const params = await createWith(() => {
$activeGatewayProfile.set('coder')
$newChatProfile.set('analyst')
})
expect(params).toMatchObject({ profile: 'analyst' })
})
it('passes the default profile for single-profile users (backend resolves it to launch)', async () => {
const params = await createWith(() => {
$activeGatewayProfile.set('default')
$newChatProfile.set(null)
})
expect(params).toMatchObject({ profile: 'default' })
})
})

View File

@@ -2,7 +2,7 @@ import type { MutableRefObject } from 'react'
import { useCallback, useRef } from 'react'
import type { NavigateFunction } from 'react-router-dom'
import { deleteSession, getSession, getSessionMessages, setSessionArchived } from '@/hermes'
import { deleteSession, getSessionMessages, setSessionArchived } from '@/hermes'
import { useI18n } from '@/i18n'
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
import { normalizePersonalityValue } from '@/lib/chat-runtime'
@@ -12,7 +12,7 @@ import { clearQueuedPrompts } from '@/store/composer-queue'
import { $pinnedSessionIds } from '@/store/layout'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { $activeGatewayProfile, $newChatProfile, $profiles, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
import {
$currentCwd,
$messages,
@@ -43,8 +43,7 @@ import {
workspaceCwdForNewSession
} from '@/store/session'
import { reportBackendContract } from '@/store/updates'
import { isWatchWindow } from '@/store/windows'
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, SessionRuntimeInfo, UsageStats } from '@/types/hermes'
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, UsageStats } from '@/types/hermes'
import { NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../routes'
import type { ClientSessionState, SidebarNavItem } from '../../types'
@@ -210,91 +209,16 @@ function patchSessionWorkspace(sessionId: string, cwd: string | undefined) {
setSessions(prev => prev.map(session => (session.id === sessionId ? { ...session, cwd } : session)))
}
function sessionMatchesStoredId(session: SessionInfo, storedSessionId: string): boolean {
return session.id === storedSessionId || session._lineage_root_id === storedSessionId
}
function upsertResolvedSession(session: SessionInfo, storedSessionId: string) {
const lineage = session._lineage_root_id ?? session.id
setSessions(prev => [
session,
...prev.filter(existing => {
if (sessionMatchesStoredId(existing, storedSessionId)) {
return false
}
return (existing._lineage_root_id ?? existing.id) !== lineage
})
])
}
async function resolveStoredSession(storedSessionId: string): Promise<SessionInfo | undefined> {
const cached = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
if (cached) {
return cached
}
// Direct by-id on the live backend — one row lookup, no list scan. Covers
// single-profile users and any id on the active profile (e.g. an old session
// past the sidebar's recent window). 404 just means it's not on this profile.
try {
const session = await getSession(storedSessionId)
upsertResolvedSession(session, storedSessionId)
return session
} catch {
// Not on the active profile — fall through to the cross-profile probe.
}
// Multi-profile only: probe each other profile by id (still one cheap lookup
// each) rather than pulling every profile's recent sessions. The first hit
// carries its owning `profile`, which routes the resume to the right backend.
const activeKey = normalizeProfileKey($activeGatewayProfile.get())
const otherProfiles = $profiles
.get()
.map(profile => normalizeProfileKey(profile.name))
.filter(key => key !== activeKey)
for (const profile of otherProfiles) {
try {
const session = await getSession(storedSessionId, profile)
upsertResolvedSession(session, storedSessionId)
return session
} catch {
// Not on this profile; try the next.
}
}
return undefined
}
type SessionRuntimeStatePatch = Partial<
Pick<
ClientSessionState,
| 'branch'
| 'cwd'
| 'fast'
| 'model'
| 'personality'
| 'provider'
| 'reasoningEffort'
| 'serviceTier'
| 'yolo'
>
>
function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionRuntimeStatePatch | null {
function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined): Partial<
Pick<ClientSessionState, 'branch' | 'cwd' | 'fast' | 'model' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo'>
> | null {
if (!info) {
return null
}
const sessionState: SessionRuntimeStatePatch = {}
const sessionState: Partial<
Pick<ClientSessionState, 'branch' | 'cwd' | 'fast' | 'model' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo'>
> = {}
reportBackendContract(info.desktop_contract)
@@ -302,12 +226,12 @@ function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionRuntimeS
requestDesktopOnboarding(info.credential_warning)
}
if (typeof info.model === 'string') {
if (info.model) {
setCurrentModel(info.model)
sessionState.model = info.model
}
if (typeof info.provider === 'string') {
if (info.provider) {
setCurrentProvider(info.provider)
sessionState.provider = info.provider
}
@@ -323,9 +247,7 @@ function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionRuntimeS
}
if (typeof info.personality === 'string') {
const personality = normalizePersonalityValue(info.personality)
setCurrentPersonality(personality)
sessionState.personality = personality
setCurrentPersonality(normalizePersonalityValue(info.personality))
}
if (typeof info.reasoning_effort === 'string') {
@@ -355,16 +277,6 @@ function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionRuntimeS
return sessionState
}
function applyStoredSessionPreviewRuntimeInfo(stored: { model?: null | string } | undefined) {
setCurrentModel(stored?.model || '')
setCurrentProvider('')
setCurrentReasoningEffort('')
setCurrentServiceTier('')
setCurrentFastMode(false)
setYoloActive(false)
setCurrentPersonality('')
}
export function useSessionActions({
activeSessionId,
activeSessionIdRef,
@@ -431,17 +343,13 @@ export function useSessionActions({
creatingSessionRef.current = true
try {
// A plain new session (top "New Session", /new, keybind) leaves
// $newChatProfile null to mean "use the live context"; the per-profile
// "+" sets it explicitly. Resolve null to the active gateway profile so
// session.create always carries it: in global-remote mode one backend
// serves every profile, so an omitted profile param silently lands the
// chat on the launch (default) profile — the "rubberbands back to
// default" bug. This is a no-op for single-profile/local-pooled users:
// a backend resolves its own launch profile to None (_profile_home).
const newChatProfile = $newChatProfile.get() ?? normalizeProfileKey($activeGatewayProfile.get())
await ensureGatewayProfile(newChatProfile)
// Route the new chat to the chosen profile's backend (null = primary,
// so single-profile users are unaffected).
await ensureGatewayProfile($newChatProfile.get())
const cwd = $currentCwd.get().trim() || workspaceCwdForNewSession()
// Pass the owning profile so a new chat under a non-launch profile (global
// remote mode) builds its agent + persists against THAT profile's home/db.
const newChatProfile = $newChatProfile.get()
const created = await requestGateway<SessionCreateResponse>('session.create', {
cols: 96,
@@ -547,67 +455,25 @@ export function useSessionActions({
const isCurrentResume = () =>
resumeRequestRef.current === requestId && selectedStoredSessionIdRef.current === storedSessionId
// Paint the click before the profile-resolve / gateway-swap awaits below,
// so there's zero dead air: highlight the row instantly (the sidebar reads
// $selectedStoredSessionId) and, for a cold target, drop the previous
// transcript so the thread shows its loader instead of the old session
// lingering until resume lands. A warm-cached target keeps its transcript —
// the cached fast-path repaints it this same tick. Setting the ref here is
// also what use-route-resume's self-heal assumes ("set synchronously at
// resume entry").
setFreshDraftReady(false)
clearNotifications()
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
const warmRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
if (!warmRuntimeId || !sessionStateByRuntimeIdRef.current.get(warmRuntimeId)) {
setActiveSessionId(null)
activeSessionIdRef.current = null
setMessages([])
}
// Swap the single live gateway to this session's profile before any
// gateway call (no-op when it's already on that profile / single-profile).
// resolveStoredSession finds the row by id (cheap), so an uncached pasted
// id loads as fast as a sidebar click instead of hanging on a list scan.
const storedForProfile = await resolveStoredSession(storedSessionId)
const storedForProfile = $sessions.get().find(session => session.id === storedSessionId)
const sessionProfile = storedForProfile?.profile
if (resumeRequestRef.current !== requestId) {
return
}
await ensureGatewayProfile(sessionProfile)
const cachedRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
const cachedState = cachedRuntimeId && sessionStateByRuntimeIdRef.current.get(cachedRuntimeId)
if (cachedRuntimeId && cachedState) {
const stored = $sessions.get().find(session => session.id === storedSessionId)
const cachedViewState =
!cachedState.model && stored?.model != null
? {
...cachedState,
model: stored.model || ''
}
: cachedState
if (cachedViewState !== cachedState) {
sessionStateByRuntimeIdRef.current.set(cachedRuntimeId, cachedViewState)
}
setFreshDraftReady(false)
clearNotifications()
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
setActiveSessionId(cachedRuntimeId)
activeSessionIdRef.current = cachedRuntimeId
syncSessionStateToView(cachedRuntimeId, cachedViewState)
setCurrentCwd(cachedViewState.cwd)
setCurrentBranch(cachedViewState.branch)
syncSessionStateToView(cachedRuntimeId, cachedState)
setCurrentCwd(cachedState.cwd)
setCurrentBranch(cachedState.branch)
setSessionStartedAt(Date.now())
try {
@@ -647,8 +513,7 @@ export function useSessionActions({
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
setSessionStartedAt(Date.now())
const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
applyStoredSessionPreviewRuntimeInfo(stored)
const stored = $sessions.get().find(session => session.id === storedSessionId)
if (stored) {
setCurrentUsage(current => ({
@@ -659,46 +524,40 @@ export function useSessionActions({
}))
}
let resumedRunning = false
try {
const watchWindow = isWatchWindow()
// Load the local snapshot first, then ask the gateway to resume.
// Previously these raced:
// 1. clear messages to []
// 2. local getSessionMessages -> 45 msgs
// 3. a second resume path cleared [] again
// 4. gateway resume -> 43 msgs
// That is the ctrl+R flash chain. Avoid showing an empty thread
// while we already have a route-scoped session id, and don't race the
// local snapshot against gateway resume.
let localSnapshot = $messages.get()
// REST transcript prefetch and the gateway resume RPC are independent
// — run them concurrently so a big session's wall time is
// max(prefetch, resume) instead of their sum. The prefetch paints the
// transcript as soon as it lands; the RPC binds the runtime id.
// Watch windows skip the prefetch — lazy resume attaches the live mirror.
const prefetchPromise = watchWindow ? null : getSessionMessages(storedSessionId, sessionProfile)
const resumePromise = requestGateway<SessionResumeResponse>('session.resume', {
session_id: storedSessionId,
cols: 96,
...(watchWindow ? { lazy: true } : {}),
...(sessionProfile ? { profile: sessionProfile } : {})
})
// The rejection is consumed by the `await` below; this guard only
// keeps it from surfacing as unhandled while the prefetch settles.
resumePromise.catch(() => undefined)
try {
if (prefetchPromise) {
const storedMessages = await prefetchPromise
const storedMessages = await getSessionMessages(storedSessionId, sessionProfile)
if (isCurrentResume()) {
localSnapshot = preserveLocalAssistantErrors(toChatMessages(storedMessages.messages), $messages.get())
if (isCurrentResume()) {
localSnapshot = preserveLocalAssistantErrors(toChatMessages(storedMessages.messages), $messages.get())
if (!chatMessageArraysEquivalent($messages.get(), localSnapshot)) {
setMessages(localSnapshot)
}
if (!chatMessageArraysEquivalent($messages.get(), localSnapshot)) {
setMessages(localSnapshot)
}
}
} catch {
// Non-fatal: gateway resume below can still hydrate the session.
}
const resumed = await resumePromise
const resumed = await requestGateway<SessionResumeResponse>('session.resume', {
session_id: storedSessionId,
cols: 96,
// Owning profile: in app-global remote mode one backend serves every
// profile, so the gateway opens this profile's state.db + home to
// resume + persist the right session (no-op for single/launch profile).
...(sessionProfile ? { profile: sessionProfile } : {})
})
if (!isCurrentResume()) {
return
@@ -706,22 +565,25 @@ export function useSessionActions({
const currentMessages = $messages.get()
// Keep the local snapshot when resume would only reshuffle runtime
// projection. When the REST prefetch already hydrated the transcript,
// skip converting/reconciling the resume payload entirely — on a
// 1000+-message session that second conversion plus the deep
// equivalence compare costs over a second of main-thread time.
const resumedMessages = preserveLocalAssistantErrors(
reconcileResumeMessages(toChatMessages(resumed.messages), currentMessages),
currentMessages
)
// Avoid a second visible transcript rebuild on resume/switch.
// `getSessionMessages()` is the stable stored transcript snapshot and
// paints first; `session.resume` can return a slightly different
// runtime-shaped projection (e.g. tool/system coalescing), which was
// causing a second full message-list replacement a second later.
// Keep the already-painted local snapshot for the view/cache when it
// exists; use gateway messages only as a fallback when no local
// snapshot was available.
const preferredMessages =
localSnapshot.length > 0
? localSnapshot
: (() => {
const resumedMessages = preserveLocalAssistantErrors(
reconcileResumeMessages(toChatMessages(resumed.messages), currentMessages),
currentMessages
)
return chatMessageArraysEquivalent(currentMessages, resumedMessages) ? currentMessages : resumedMessages
})()
: chatMessageArraysEquivalent(currentMessages, resumedMessages)
? currentMessages
: resumedMessages
const messagesForView = preserveLocalAssistantErrors(preferredMessages, currentMessages)
@@ -731,16 +593,14 @@ export function useSessionActions({
patchSessionWorkspace(storedSessionId, runtimeInfo?.cwd)
resumedRunning = Boolean((resumed as { running?: boolean }).running)
updateSessionState(
resumed.session_id,
state => ({
...state,
...(runtimeInfo ?? {}),
messages: messagesForView,
busy: resumedRunning,
awaitingResponse: resumedRunning
busy: false,
awaitingResponse: false
}),
storedSessionId
)
@@ -759,9 +619,9 @@ export function useSessionActions({
notifyError(err, copy.resumeFailed)
} finally {
if (isCurrentResume()) {
busyRef.current = resumedRunning
setBusy(resumedRunning)
setAwaitingResponse(resumedRunning)
busyRef.current = false
setBusy(false)
setAwaitingResponse(false)
}
}
},
@@ -902,7 +762,7 @@ export function useSessionActions({
async (storedSessionId: string) => {
clearNotifications()
const removed = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
const removed = $sessions.get().find(s => s.id === storedSessionId)
const wasSelected = selectedStoredSessionId === storedSessionId
const closingRuntimeId = wasSelected ? activeSessionId : null
const previousMessages = $messages.get()
@@ -911,7 +771,7 @@ export function useSessionActions({
// live tip after compression. Drop both so the pin can't linger.
const removedPinId = removed ? sessionPinId(removed) : storedSessionId
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
// Keep $sessionsTotal in sync so the sidebar's "Load N more" footer
// doesn't keep claiming the removed row is still on the server.
setSessionsTotal(prev => Math.max(0, prev - 1))
@@ -946,7 +806,7 @@ export function useSessionActions({
setFreshDraftReady(false)
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
const stored = $sessions.get().find(session => session.id === storedSessionId)
if (stored) {
setCurrentUsage(current => ({
@@ -985,7 +845,7 @@ export function useSessionActions({
async (storedSessionId: string) => {
clearNotifications()
const archived = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
const archived = $sessions.get().find(s => s.id === storedSessionId)
const wasSelected = selectedStoredSessionId === storedSessionId
const previousPinned = $pinnedSessionIds.get()
// Pins are keyed on the durable lineage-root id; the stored id may be the
@@ -993,7 +853,7 @@ export function useSessionActions({
const archivedPinId = archived ? sessionPinId(archived) : storedSessionId
// Soft-hide: drop from the sidebar immediately, keep the data.
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
// Archived sessions are hidden by the listSessions(min_messages=1) query
// on the next refresh, so they count as "removed" for the load-more
// footer math.
@@ -1010,12 +870,12 @@ export function useSessionActions({
// in flight and briefly reinsert the still-unarchived backend row. Win
// that race after the mutation succeeds so right-click → Archive does
// not appear to do nothing until the next full refresh.
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
$pinnedSessionIds.set($pinnedSessionIds.get().filter(id => id !== storedSessionId && id !== archivedPinId))
notify({ durationMs: 2_000, kind: 'success', message: copy.archived })
} catch (err) {
if (archived) {
setSessions(prev => [archived, ...prev.filter(session => !sessionMatchesStoredId(session, storedSessionId))])
setSessions(prev => [archived, ...prev.filter(s => s.id !== storedSessionId)])
setSessionsTotal(prev => prev + 1)
}

View File

@@ -2,20 +2,7 @@ import { act, cleanup, render } from '@testing-library/react'
import type { MutableRefObject } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
$currentFastMode,
$currentModel,
$currentProvider,
$currentReasoningEffort,
$currentServiceTier,
$turnStartedAt,
setCurrentFastMode,
setCurrentModel,
setCurrentProvider,
setCurrentReasoningEffort,
setCurrentServiceTier,
setTurnStartedAt
} from '@/store/session'
import { $turnStartedAt, setTurnStartedAt } from '@/store/session'
import { useSessionStateCache } from './use-session-state-cache'
@@ -59,22 +46,12 @@ describe('useSessionStateCache — per-session turn timer', () => {
return null as unknown as number
})
setTurnStartedAt(null)
setCurrentModel('')
setCurrentProvider('')
setCurrentReasoningEffort('')
setCurrentServiceTier('')
setCurrentFastMode(false)
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
setTurnStartedAt(null)
setCurrentModel('')
setCurrentProvider('')
setCurrentReasoningEffort('')
setCurrentServiceTier('')
setCurrentFastMode(false)
})
it("keeps a background session's running turn clock and never mirrors it to the view", () => {
@@ -138,78 +115,4 @@ describe('useSessionStateCache — per-session turn timer', () => {
})
expect($turnStartedAt.get()).toBeNull()
})
it('mirrors the focused session model metadata when switching from a cached session', () => {
let cache!: Cache
const { rerender } = render(
<Harness activeSessionId="fg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="fg-stored" />
)
act(() => {
cache.updateSessionState(
'bg-runtime',
state => ({
...state,
fast: true,
model: 'anthropic/claude-opus-4.8',
provider: 'anthropic',
reasoningEffort: 'high',
serviceTier: 'priority'
}),
'bg-stored'
)
})
// Background metadata is cached but must not bleed into the visible statusbar.
expect($currentModel.get()).toBe('')
expect($currentReasoningEffort.get()).toBe('')
expect($currentFastMode.get()).toBe(false)
rerender(<Harness activeSessionId="bg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="bg-stored" />)
const bgState = cache.sessionStateByRuntimeIdRef.current.get('bg-runtime')
expect(bgState).toBeTruthy()
act(() => {
cache.syncSessionStateToView('bg-runtime', bgState!)
})
expect($currentModel.get()).toBe('anthropic/claude-opus-4.8')
expect($currentProvider.get()).toBe('anthropic')
expect($currentReasoningEffort.get()).toBe('high')
expect($currentServiceTier.get()).toBe('priority')
expect($currentFastMode.get()).toBe(true)
})
it('clears stale model metadata when the newly focused session has no cached value', () => {
setCurrentModel('previous-model')
setCurrentProvider('previous-provider')
setCurrentReasoningEffort('high')
setCurrentServiceTier('priority')
setCurrentFastMode(true)
let cache!: Cache
const { rerender } = render(
<Harness activeSessionId="fg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="fg-stored" />
)
act(() => {
cache.updateSessionState('bg-runtime', state => ({ ...state }), 'bg-stored')
})
rerender(<Harness activeSessionId="bg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="bg-stored" />)
const bgState = cache.sessionStateByRuntimeIdRef.current.get('bg-runtime')
expect(bgState).toBeTruthy()
act(() => {
cache.syncSessionStateToView('bg-runtime', bgState!)
})
expect($currentModel.get()).toBe('')
expect($currentProvider.get()).toBe('')
expect($currentReasoningEffort.get()).toBe('')
expect($currentServiceTier.get()).toBe('')
expect($currentFastMode.get()).toBe(false)
})
})

View File

@@ -11,7 +11,6 @@ import {
noteSessionActivity,
setCurrentFastMode,
setCurrentModel,
setCurrentPersonality,
setCurrentProvider,
setCurrentReasoningEffort,
setCurrentServiceTier,
@@ -54,16 +53,6 @@ interface SessionStateCacheOptions {
setMessages: (messages: ChatMessage[]) => void
}
function syncRuntimeMetadataToView(state: ClientSessionState) {
setCurrentModel(state.model ?? '')
setCurrentProvider(state.provider ?? '')
setCurrentReasoningEffort(state.reasoningEffort ?? '')
setCurrentServiceTier(state.serviceTier ?? '')
setCurrentFastMode(state.fast ?? false)
setYoloActive(state.yolo ?? false)
setCurrentPersonality(state.personality ?? '')
}
export function useSessionStateCache({
activeSessionId,
busyRef,
@@ -148,7 +137,12 @@ export function useSessionStateCache({
setMessages(nextMessages)
}
syncRuntimeMetadataToView(pending.state)
setCurrentModel(pending.state.model)
setCurrentProvider(pending.state.provider)
setCurrentReasoningEffort(pending.state.reasoningEffort)
setCurrentServiceTier(pending.state.serviceTier)
setCurrentFastMode(pending.state.fast)
setYoloActive(pending.state.yolo)
setBusy(pending.state.busy)
setMutableRef(busyRef, pending.state.busy)
setAwaitingResponse(pending.state.awaitingResponse)
@@ -173,7 +167,6 @@ export function useSessionStateCache({
return
}
syncRuntimeMetadataToView(state)
pendingViewStateRef.current = { sessionId, state }
// Terminal / attention transitions (turn finished, error, or the agent is

View File

@@ -9,7 +9,6 @@ import { Check, Download, Loader2, Palette, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
import { $translucency, setTranslucency } from '@/store/translucency'
import { useTheme } from '@/themes/context'
import { installVscodeThemeFromMarketplace } from '@/themes/install'
import { isUserTheme, removeUserTheme, resolveTheme } from '@/themes/user-themes'
@@ -136,7 +135,6 @@ export function AppearanceSettings() {
const { t, isSavingLocale } = useI18n()
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
const toolViewMode = useStore($toolViewMode)
const translucency = useStore($translucency)
const profiles = useStore($profiles)
const activeProfileKey = normalizeProfileKey(useStore($activeGatewayProfile))
const a = t.settings.appearance
@@ -185,32 +183,6 @@ export function AppearanceSettings() {
title={a.colorMode}
/>
<ListRow
action={
<div className="flex items-center gap-3">
<input
aria-label={a.translucencyTitle}
className="h-1 w-40 cursor-pointer appearance-none rounded-full bg-(--ui-stroke-tertiary)"
max={100}
min={0}
onChange={event => {
triggerHaptic('selection')
setTranslucency(Number(event.target.value))
}}
step={5}
style={{ accentColor: 'var(--dt-primary)' }}
type="range"
value={translucency}
/>
<span className="w-9 text-right text-[length:var(--conversation-caption-font-size)] tabular-nums text-(--ui-text-tertiary)">
{translucency}%
</span>
</div>
}
description={a.translucencyDesc}
title={a.translucencyTitle}
/>
<ListRow
below={
<>

View File

@@ -15,7 +15,7 @@ import type { AuxiliaryModelsResponse, ModelOptionProvider, StaleAuxAssignment }
import { useI18n } from '@/i18n'
import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { startManualLocalEndpoint, startManualProviderOAuth } from '@/store/onboarding'
import { startManualProviderOAuth } from '@/store/onboarding'
import { CONTROL_TEXT } from './constants'
import { ListRow, LoadingState, Pill, SectionHeading } from './primitives'
@@ -224,23 +224,10 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
}, [apiKeyDraft, selectedProviderRow])
// OAuth / external providers can't be activated with a pasted key — hand off
// to the shared onboarding flow scoped to this provider's real sign-in. The
// custom / local endpoint is NOT an OAuth provider, so it gets the dedicated
// local-endpoint form (URL + optional API key) instead of being dead-ended
// on the OAuth picker (the original "booted back to the first screen" loop).
// to the shared onboarding flow scoped to this provider's real sign-in.
const startProviderSetup = useCallback(() => {
const slug = selectedProviderRow?.slug
if (!slug) {
return
}
const lower = slug.toLowerCase()
if (lower === 'custom' || lower === 'local' || lower.startsWith('custom:')) {
startManualLocalEndpoint()
} else {
startManualProviderOAuth(slug)
if (selectedProviderRow?.slug) {
startManualProviderOAuth(selectedProviderRow.slug)
}
}, [selectedProviderRow])

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Tip } from '@/components/ui/tooltip'
import { deleteSession, listAllProfileSessions, setSessionArchived } from '@/hermes'
import { deleteSession, listSessions, setSessionArchived } from '@/hermes'
import { useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
@@ -43,14 +43,14 @@ export function SessionsSettings() {
setLoading(true)
try {
const result = await listAllProfileSessions(ARCHIVED_FETCH_LIMIT, 0, 'only')
const result = await listSessions(ARCHIVED_FETCH_LIMIT, 0, 'only')
setLocalSessions(result.sessions)
} catch (err) {
notifyError(err, s.failedLoad)
} finally {
setLoading(false)
}
}, [s.failedLoad])
}, [])
useEffect(() => {
void load()

View File

@@ -5,7 +5,6 @@ import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { Kbd, KbdCombo } from '@/components/ui/kbd'
import { useI18n } from '@/i18n'
import {
KEYBIND_ACTIONS,
@@ -167,11 +166,15 @@ function KeybindRow({ action }: { action: KeybindActionMeta }) {
type="button"
>
{capturing ? (
<Kbd variant="capturing">{k.pressKey}</Kbd>
<span className="kbd-cap kbd-capturing">{k.pressKey}</span>
) : combos.length > 0 ? (
combos.map(combo => <KbdCombo combo={combo} key={combo} />)
combos.map(combo => (
<span className="kbd-cap" key={combo}>
{formatCombo(combo)}
</span>
))
) : (
<Kbd variant="ghost">{k.set}</Kbd>
<span className="kbd-cap kbd-cap--ghost">{k.set}</span>
)}
</button>
@@ -206,7 +209,9 @@ function ReadonlyRow({ shortcut }: { shortcut: KeybindReadonly }) {
<span className="min-w-0 flex-1 truncate text-[0.82rem] text-foreground/75">{label}</span>
<div className="flex shrink-0 items-center gap-1">
{shortcut.keys.map(key => (
<KbdCombo combo={key} key={key} />
<span className="kbd-cap" key={key}>
{formatCombo(key)}
</span>
))}
</div>
<span aria-hidden className="size-6 shrink-0" />

View File

@@ -19,10 +19,7 @@ export const titlebarButtonClass =
'text-muted-foreground/85 hover:bg-(--ui-control-hover-background) hover:text-foreground'
export const titlebarHeaderBaseClass =
'pointer-events-none relative z-3 flex h-(--titlebar-height) w-full min-w-0 shrink-0 items-center justify-start gap-3 overflow-hidden border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-[max(0.75rem,var(--titlebar-content-inset,0rem))] pr-[calc(var(--titlebar-tools-right,0.75rem)+var(--titlebar-tools-width,0px)+0.75rem)]'
// Title row inside the header — must stay in the flex truncate chain.
export const titlebarHeaderTitleClass = 'min-w-0 flex-1 overflow-hidden'
'pointer-events-none relative z-3 flex h-(--titlebar-height) shrink-0 items-center justify-start gap-3 border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-[max(0.75rem,var(--titlebar-content-inset,0rem))]'
export const titlebarHeaderShadowClass =
"after:pointer-events-none after:absolute after:left-0 after:right-0 after:top-full after:h-4 after:bg-linear-to-b after:from-(--ui-chat-surface-background) after:to-transparent after:content-['']"

View File

@@ -129,7 +129,6 @@ export interface ClientSessionState {
serviceTier: string
fast: boolean
yolo: boolean
personality: string
busy: boolean
awaitingResponse: boolean
streamId: string | null

Some files were not shown because too many files have changed in this diff Show More