Compare commits

..

2 Commits

Author SHA1 Message Date
teknium1
f6b0b09988 feat(web): make grounded citations + summary streaming toggleable via config.yaml
- web.citations: auto (default) | always | off
  * auto: new CITATION_GUIDANCE_AUTO instructs the model to cite inline
    only for research/report/fact-finding-shaped requests and answer
    naturally for incidental lookups (Perplexity's leaked prompts handle
    query-class exemptions the same way - instruction, not classifier)
  * always: unconditional guidance (previous behavior)
  * off: no source ids, no guidance - byte-identical to pre-feature output
- web.summary_stream: true (default) | false - gates the live CLI
  summarization box
- Tool schema descriptions made mode-agnostic ('results MAY carry a
  source field... follow guidance when present') so the static schema
  stays byte-stable for a conversation regardless of config
- Docs: configuration.md 'Grounded citations' section
- 7 new tests covering mode resolution + off-mode output shape
2026-06-12 12:25:20 -07:00
teknium1
853d32fb90 feat(web): Perplexity-style grounded citations + live summary streaming box
Grounding (web_search + web_extract):
- Process-wide source registry assigns stable numbered citation ids
  (url -> [n]) shared across search and extract calls, so the model only
  ever emits small integers it received - it cannot hallucinate a URL
  (the structural trick behind Perplexity's grounding)
- Every search/extract result carries its marker in a 'source' field;
  responses with content include a compact citation_guidance block
  modeled on Perplexity's leaked prompts (per-sentence inline [n] cites,
  max 3 per sentence, cite-while-writing, never invent ids, Sources list)
- Summarizer prompts hardened per ALCE (arXiv:2305.14627) and WebGPT
  (arXiv:2112.09332): preserve citable facts as verbatim quotes, keep
  figures exact, never blend outside knowledge, flag gaps explicitly

Live summary streaming (CLI):
- New tools/summary_display.py: tiny callback registry + single display
  slot; no-op everywhere a front-end doesn't register (gateway, cron,
  subagents, tests)
- web_extract summarization now tries a streaming call first when a
  display is attached, mirroring tokens into a dim 'Summarizing . <url>'
  box in the CLI (same UX as reasoning blocks); any streaming failure
  falls back to the existing non-streaming retry/fallback path
- Parallel page summaries race for the slot; only one streams, others
  run silently - no interleaved boxes
2026-06-12 03:24:42 -07:00
259 changed files with 4417 additions and 14906 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

4
.gitignore vendored
View File

@@ -132,7 +132,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

@@ -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:

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

@@ -69,31 +69,6 @@ SUMMARY_PREFIX = (
)
LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:"
# Metadata key added to context compression summary messages so that frontends
# (CLI, Desktop, gateway, TUI) can distinguish them from real assistant/user
# messages and filter or render them appropriately without content-prefix
# heuristics. See https://github.com/NousResearch/hermes-agent/issues/38389
#
# Underscore-prefixed ON PURPOSE: the wire sanitizers
# (agent/transports/chat_completions.py convert_messages and the summary-path
# mirror in agent/chat_completion_helpers.py) strip every top-level message
# key starting with "_" before the request leaves the process. Strict
# OpenAI-compatible gateways (Fireworks, Mistral, Moonshot/Kimi, opencode-go)
# reject payloads carrying unknown keys with "Extra inputs are not permitted",
# poisoning every subsequent request in the session — a bare key like
# "is_compressed_summary" would reach the wire and trip exactly that.
COMPRESSED_SUMMARY_METADATA_KEY = "_compressed_summary"
# Appended to every standalone summary message (and to the merged-into-tail
# prefix) so the model has an unambiguous "summary ends here" boundary.
# Without it, weak models read the verbatim "## Active Task" quote as fresh
# user input (#11475, #14521) or regurgitate an assistant-role summary as
# their own output (#33256).
_SUMMARY_END_MARKER = (
"--- END OF CONTEXT SUMMARY — "
"respond to the message below, not the summary above ---"
)
# Handoff prefixes that shipped in earlier releases. A summary persisted under
# one of these can be inherited into a resumed lineage (#35344); when it is
# re-normalized on re-compaction we must strip the OLD prefix too, otherwise the
@@ -171,11 +146,6 @@ _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`'\")\]}<>]+")
@@ -1646,13 +1616,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,19 +1632,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,
@@ -1866,105 +1817,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]],
@@ -2023,12 +1875,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 +1887,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 +1960,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)
# ------------------------------------------------------------------
@@ -2360,33 +2193,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

@@ -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

@@ -508,16 +508,13 @@ PLATFORM_HINTS = {
),
"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 "

View File

@@ -22,31 +22,9 @@ TitleCallback = Callable[[str], None]
_TITLE_PROMPT = (
"Generate a short, descriptive title (3-7 words) for a conversation that starts with the "
"following exchange. The title should capture the main topic or intent. "
"Write the title in the same language the user is writing in. "
"Return ONLY the title text, nothing else. No quotes, no punctuation at the end, no prefixes."
)
_TITLE_PROMPT_PINNED_LANGUAGE = (
"Generate a short, descriptive title (3-7 words) for a conversation that starts with the "
"following exchange. The title should capture the main topic or intent. "
"Write the title in {language}. "
"Return ONLY the title text, nothing else. No quotes, no punctuation at the end, no prefixes."
)
def _title_language() -> str:
"""Return configured title language, or empty string to match the user."""
try:
from hermes_cli.config import load_config
return str(
((load_config() or {}).get("auxiliary") or {})
.get("title_generation", {})
.get("language", "")
).strip()
except Exception:
return ""
def generate_title(
user_message: str,
@@ -70,11 +48,8 @@ def generate_title(
user_snippet = user_message[:500] if user_message else ""
assistant_snippet = assistant_response[:500] if assistant_response else ""
language = _title_language()
prompt = _TITLE_PROMPT_PINNED_LANGUAGE.format(language=language) if language else _TITLE_PROMPT
messages = [
{"role": "system", "content": prompt},
{"role": "system", "content": _TITLE_PROMPT},
{"role": "user", "content": f"User: {user_snippet}\n\nAssistant: {assistant_snippet}"},
]

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,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,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')
@@ -143,14 +142,7 @@ function rejectUnsafePathSyntax(filePath, purpose = 'File read') {
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))
}
const raw = rejectUnsafePathSyntax(filePath, purpose)
if (/^file:/i.test(raw)) {
let resolvedPath

View File

@@ -106,19 +106,6 @@ test('resolveRequestedPathForIpc resolves relative paths from the trimmed base d
)
})
test('resolveRequestedPathForIpc expands ~ to the home directory', () => {
assert.equal(resolveRequestedPathForIpc('~', { purpose: 'Directory read' }), path.resolve(os.homedir()))
assert.equal(
resolveRequestedPathForIpc('~/www/project', { purpose: 'Directory read' }),
path.resolve(os.homedir(), 'www/project')
)
// `~user` shorthand is NOT expanded — only the caller's own home.
assert.equal(
resolveRequestedPathForIpc('~other/secret', { baseDir: os.tmpdir(), purpose: 'Directory read' }),
path.resolve(os.tmpdir(), '~other/secret')
)
})
test('resolveReadableFileForIpc validates existence type size and sensitivity', async t => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-hardening-'))
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))

View File

@@ -26,23 +26,20 @@ const { pathToFileURL } = require('node:url')
const { execFileSync, spawn } = require('node:child_process')
const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
const { runBootstrap } = require('./bootstrap-runner.cjs')
const {
buildSessionWindowUrl,
createSessionWindowRegistry,
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 { PortPool } = require('./port-pool.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,
@@ -112,6 +109,12 @@ if (USER_DATA_OVERRIDE) {
app.setPath('userData', resolvedUserData)
}
const PORT_FLOOR = 9120
const PORT_CEILING = 9199
// In-process port reservations that close the pickPort() TOCTOU window where
// two concurrent backend spawns could be handed the same port. See
// port-pool.cjs for the full rationale.
const portPool = new PortPool(PORT_FLOOR, PORT_CEILING)
const DEV_SERVER = process.env.HERMES_DESKTOP_DEV_SERVER
const IS_PACKAGED = app.isPackaged
const IS_MAC = process.platform === 'darwin'
@@ -345,110 +348,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 }
@@ -1261,14 +1164,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 +1302,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 +1730,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 +1743,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
@@ -2470,14 +2323,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 +2452,24 @@ async function ensureRuntime(backend) {
return backend
}
function isPortAvailable(port) {
return new Promise(resolve => {
const server = net.createServer()
server.once('error', () => resolve(false))
server.once('listening', () => {
server.close(() => resolve(true))
})
server.listen(port, '127.0.0.1')
})
}
async function pickPort() {
const port = await portPool.reserve(isPortAvailable)
if (port === null) {
throw new Error(`No free localhost port in ${PORT_FLOOR}-${PORT_CEILING}`)
}
return port
}
function fetchJson(url, token, options = {}) {
return new Promise((resolve, reject) => {
@@ -4684,41 +4547,49 @@ 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 backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
const hermesCwd = resolveHermesCwd()
const webDist = resolveWebDist()
const dashboardArgs = ['--profile', profile, 'dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)]
let backend
let hermesCwd
let webDist
try {
backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
hermesCwd = resolveHermesCwd()
webDist = resolveWebDist()
} catch (error) {
// These run before the child exists / its exit handler is attached, so a
// throw here would otherwise leak the reservation and slowly exhaust the
// 9120-9199 range across switch cycles in one app session.
portPool.release(port)
throw error
}
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)
@@ -4732,11 +4603,13 @@ async function spawnPoolBackend(profile, entry) {
child.once('error', error => {
rememberLog(`Hermes backend for profile "${profile}" failed to start: ${error.message}`)
backendPool.delete(profile)
portPool.release(port)
rejectStart?.(error)
})
child.once('exit', (code, signal) => {
rememberLog(`Hermes backend for profile "${profile}" exited (${signal || code})`)
backendPool.delete(profile)
portPool.release(port)
if (!ready) {
rejectStart?.(
new Error(`Hermes backend for profile "${profile}" exited before it became ready (${signal || code}).`)
@@ -4744,10 +4617,6 @@ 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
@@ -4775,6 +4644,7 @@ function stopPoolBackend(profile) {
const entry = backendPool.get(profile)
if (!entry) return
backendPool.delete(profile)
if (entry.port) portPool.release(entry.port)
if (entry.process && !entry.process.killed) {
try {
entry.process.kill('SIGTERM')
@@ -4860,6 +4730,11 @@ async function startHermes() {
}
if (connectionPromise) return connectionPromise
// Hoisted so the outer .catch can release a port reserved by pickPort() when
// a throw (e.g. ensureRuntime failing) happens before the child's exit
// handler is attached. Stays null on the remote path (no port picked).
let reservedPort = null
connectionPromise = (async () => {
await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8)
// Resolve for the desktop's primary profile so a per-profile remote
@@ -4887,9 +4762,11 @@ async function startHermes() {
}
}
await advanceBootProgress('backend.port', 'Finding an open local port', 16)
const port = await pickPort()
reservedPort = port
const token = crypto.randomBytes(32).toString('base64url')
// --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 +4784,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)
@@ -4956,6 +4829,7 @@ async function startHermes() {
)
hermesProcess = null
connectionPromise = null
portPool.release(port)
sendBackendExit({ code: null, signal: null, error: error.message })
rejectBackendStart?.(error)
})
@@ -4963,6 +4837,7 @@ async function startHermes() {
rememberLog(`Hermes backend exited (${signal || code})`)
hermesProcess = null
connectionPromise = null
portPool.release(port)
sendBackendExit({ code, signal })
if (!backendReady) {
const message = `Hermes backend exited before it became ready (${signal || code}).`
@@ -4983,10 +4858,6 @@ 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])
@@ -5026,6 +4897,7 @@ async function startHermes() {
{ allowDecrease: true }
)
connectionPromise = null
portPool.release(reservedPort)
throw error
})
@@ -5073,29 +4945,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 +4974,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 +4984,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 +5010,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 +5047,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 +5158,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 }
})
@@ -5721,35 +5571,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')
@@ -6001,8 +5822,6 @@ ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dir
ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath))
ipcMain.handle('hermes:fs:worktrees', async (_event, cwds) => worktreesForIpc(cwds))
ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
if (!nodePty) {
throw new Error('PTY support is unavailable. Reinstall desktop dependencies and restart Hermes.')
@@ -6189,15 +6008,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()
})
@@ -6355,7 +6170,7 @@ let _rendererReadyForDeepLink = false
function _extractDeepLink(argv) {
if (!Array.isArray(argv)) return null
return argv.find(a => typeof a === 'string' && a.startsWith(`${HERMES_PROTOCOL}://`)) || null
return argv.find((a) => typeof a === 'string' && a.startsWith(`${HERMES_PROTOCOL}://`)) || null
}
function handleDeepLink(url) {
@@ -6399,7 +6214,9 @@ ipcMain.handle('hermes:deep-link-ready', () => {
_pendingDeepLink = null
handleDeepLink(
`${HERMES_PROTOCOL}://${queued.kind}/${encodeURIComponent(queued.name)}` +
(Object.keys(queued.params).length ? '?' + new URLSearchParams(queued.params).toString() : '')
(Object.keys(queued.params).length
? '?' + new URLSearchParams(queued.params).toString()
: ''),
)
}
return { ok: true }
@@ -6410,7 +6227,9 @@ function registerDeepLinkProtocol() {
if (process.defaultApp && process.argv.length >= 2) {
// Dev: register with the electron exec path + entry script so the OS can
// relaunch us with the URL.
app.setAsDefaultProtocolClient(HERMES_PROTOCOL, process.execPath, [path.resolve(process.argv[1])])
app.setAsDefaultProtocolClient(HERMES_PROTOCOL, process.execPath, [
path.resolve(process.argv[1]),
])
} else {
app.setAsDefaultProtocolClient(HERMES_PROTOCOL)
}
@@ -6443,6 +6262,7 @@ app.on('open-url', (event, url) => {
handleDeepLink(url)
})
app.whenReady().then(() => {
if (IS_MAC) {
Menu.setApplicationMenu(buildApplicationMenu())

View File

@@ -0,0 +1,73 @@
'use strict'
/**
* In-process port reservation pool for the desktop backend launcher.
*
* pickPort() probes a localhost port with a throwaway server and closes it
* before the real bind happens in a separate Python child. Between that probe
* and the child's bind there is a TOCTOU window: a second concurrent spawn
* (the primary backend racing a pool backend) can be handed the SAME port, and
* one then dies with EADDRINUSE ("address already in use" -> "Object has been
* destroyed" boot loop). Reserving the chosen port in THIS process until the
* child exits closes that window.
*
* The OS bind remains the source of truth; this only deconflicts racers inside
* this process — it can't stop a foreign squatter, which the probe + the
* EADDRINUSE self-heal still cover.
*
* The pool is dependency-injected (the availability probe is passed in) and
* free of Electron/Node socket I/O, so it is unit-tested without real sockets
* (see port-pool.test.cjs).
*/
class PortPool {
/**
* @param {number} floor inclusive lowest port to hand out
* @param {number} ceiling inclusive highest port to hand out
*/
constructor(floor, ceiling) {
this.floor = floor
this.ceiling = ceiling
this._reserved = new Set()
}
/** @returns {boolean} whether `port` is currently reserved in-process. */
has(port) {
return this._reserved.has(port)
}
/** Release a previously reserved port. No-op if it was not reserved. */
release(port) {
this._reserved.delete(port)
}
/** Drop all reservations. */
clear() {
this._reserved.clear()
}
/** @returns {number} count of currently reserved ports. */
get size() {
return this._reserved.size
}
/**
* Reserve and return the lowest port in [floor, ceiling] that is neither
* already reserved in-process nor rejected by `isAvailable(port)`, or null
* if every port is taken. `isAvailable` may be sync (boolean) or async
* (Promise<boolean>); it is awaited either way.
*
* @param {(port: number) => boolean | Promise<boolean>} isAvailable
* @returns {Promise<number|null>}
*/
async reserve(isAvailable) {
for (let port = this.floor; port <= this.ceiling; port += 1) {
if (this._reserved.has(port)) continue
if (!(await isAvailable(port))) continue
this._reserved.add(port)
return port
}
return null
}
}
module.exports = { PortPool }

View File

@@ -0,0 +1,77 @@
/**
* Tests for electron/port-pool.cjs.
*
* Run with: node --test electron/port-pool.test.cjs
*
* PortPool is the in-process reservation that closes the pickPort() TOCTOU
* window. These cover selection order, skipping reserved/unavailable ports,
* release/reuse, exhaustion, and async probes — without real sockets.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const { PortPool } = require('./port-pool.cjs')
const allFree = () => true
test('reserve returns the lowest free port and reserves it', async () => {
const pool = new PortPool(9120, 9199)
const port = await pool.reserve(allFree)
assert.equal(port, 9120)
assert.ok(pool.has(9120))
assert.equal(pool.size, 1)
})
test('reserve skips ports already reserved in-process', async () => {
const pool = new PortPool(9120, 9199)
const first = await pool.reserve(allFree)
const second = await pool.reserve(allFree)
assert.equal(first, 9120)
assert.equal(second, 9121)
})
test('reserve skips ports the probe rejects', async () => {
const pool = new PortPool(9120, 9199)
const busy = new Set([9120, 9121])
const port = await pool.reserve(p => !busy.has(p))
assert.equal(port, 9122)
})
test('reserve returns null when every port is taken', async () => {
const pool = new PortPool(9120, 9121)
await pool.reserve(allFree)
await pool.reserve(allFree)
assert.equal(await pool.reserve(allFree), null)
})
test('release frees a reserved port for reuse', async () => {
const pool = new PortPool(9120, 9120)
assert.equal(await pool.reserve(allFree), 9120)
assert.equal(await pool.reserve(allFree), null) // exhausted
pool.release(9120)
assert.ok(!pool.has(9120))
assert.equal(await pool.reserve(allFree), 9120) // reusable
})
test('release is a no-op for an unreserved port', () => {
const pool = new PortPool(9120, 9199)
pool.release(9120)
assert.equal(pool.size, 0)
})
test('reserve awaits an async probe', async () => {
const pool = new PortPool(9120, 9199)
const busy = new Set([9120])
const port = await pool.reserve(p => Promise.resolve(!busy.has(p)))
assert.equal(port, 9121)
})
test('clear drops all reservations', async () => {
const pool = new PortPool(9120, 9199)
await pool.reserve(allFree)
await pool.reserve(allFree)
assert.equal(pool.size, 2)
pool.clear()
assert.equal(pool.size, 0)
})

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),

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

@@ -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

@@ -36,7 +36,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-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-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/port-pool.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs",
"typecheck": "tsc -p . --noEmit",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
@@ -90,7 +90,6 @@
"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 +98,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"
},

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

@@ -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)}
@@ -1747,7 +1669,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 +1696,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 +1723,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 +1736,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 +1824,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,12 @@ 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
@@ -361,38 +357,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">

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

@@ -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,7 +7,7 @@ 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 { KbdGroup } from '@/components/ui/kbd'
import { getHermesConfigRecord, listAllProfileSessions } from '@/hermes'
import { useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
@@ -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,10 +119,6 @@ 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]
const toSessionEntry = (session: SessionRow): SessionEntry => ({
@@ -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

@@ -11,6 +11,7 @@ import { Pane, PaneMain } from '@/components/pane-shell'
import { useMediaQuery } from '@/hooks/use-media-query'
import { useSkinCommand } from '@/themes/use-skin-command'
import { requestComposerFocus, requestComposerInsert } from './chat/composer/focus'
import { formatRefValue } from '../components/assistant-ui/directive-text'
import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes'
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
@@ -20,7 +21,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 +76,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 +141,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]
@@ -275,27 +273,22 @@ export function DesktopController() {
// 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 => {
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?.()
}, [])
@@ -561,27 +554,15 @@ export function DesktopController() {
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 +582,6 @@ export function DesktopController() {
queryClient,
refreshHermesConfig,
refreshSessions,
sessionStateByRuntimeIdRef,
updateSessionState
})
@@ -731,7 +711,6 @@ export function DesktopController() {
editMessage,
handleThreadMessagesChange,
reloadFromMessage,
restoreToMessage,
steerPrompt,
submitText,
transcribeVoiceAudio
@@ -966,7 +945,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 +990,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

@@ -12,8 +12,6 @@ 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
@@ -202,16 +200,18 @@ 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 ? (
<Codicon name="loading" size="0.75rem" spinning />

View File

@@ -221,36 +221,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

@@ -64,10 +64,6 @@ 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 +80,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 +91,6 @@ const initialState: ProjectTreeState = {
loaded: false,
openState: {},
requestId: 0,
resolvedCwd: '',
rootError: null,
rootLoading: false
}
@@ -107,11 +100,6 @@ 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 +110,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 +138,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 +153,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
}
@@ -284,8 +230,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)
@@ -311,62 +256,19 @@ 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])
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 +286,6 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
state.cwd,
state.data,
state.openState,
state.resolvedCwd,
state.rootError,
state.rootLoading
]

View File

@@ -5,6 +5,7 @@ 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'
@@ -33,11 +34,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 +53,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,
defaultPath: hasCwd ? currentCwd : undefined,
directories: true,
multiple: false,
title: r.changeCwdTitle
@@ -70,7 +70,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))
@@ -97,7 +97,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
<FilesystemTab
canCollapse={canCollapse}
collapseNonce={collapseNonce}
cwd={effectiveCwd}
cwd={currentCwd}
cwdName={cwdName}
data={data}
error={rootError}
@@ -126,12 +126,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 +157,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 +189,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 +209,6 @@ function FilesystemTab({
onLoadChildren={onLoadChildren}
onNodeOpenChange={onNodeOpenChange}
onPreviewFile={onPreviewFile}
onRetry={onRefresh}
openState={openState}
/>
</div>
@@ -230,9 +230,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 +244,6 @@ function FileTreeBody({
onLoadChildren,
onNodeOpenChange,
onPreviewFile,
onRetry,
openState
}: FileTreeBodyProps) {
const { t } = useI18n()
@@ -258,20 +254,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,19 +312,13 @@ 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",
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',
fontWeight: '400',
fontWeightBold: '700',
letterSpacing: 0,
lineHeight: 1.12,
// Full-screen TUIs (hermes --tui, vim) grab the mouse, so a plain drag
@@ -623,10 +601,8 @@ 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.
// fonts.ready settles only already-requested faces; bold/italic aren't asked
// for until styled output paints (past atlas init), so warm them up front.
const warm = document.fonts?.load
? Promise.allSettled(['400', '700', 'italic 400'].map(v => document.fonts.load(`${v} 11px 'JetBrains Mono'`)))
: Promise.resolve()

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,
@@ -76,7 +67,15 @@ interface QueuedStreamDeltas {
type SessionRuntimeStatePatch = Partial<
Pick<
ClientSessionState,
'branch' | 'cwd' | 'fast' | 'model' | 'personality' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo'
| 'branch'
| 'cwd'
| 'fast'
| 'model'
| 'personality'
| 'provider'
| 'reasoningEffort'
| 'serviceTier'
| 'yolo'
>
>
@@ -254,14 +253,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 +341,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 +478,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 +491,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 +524,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 +541,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 +677,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
@@ -900,22 +875,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)
}
@@ -1021,12 +987,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 +1027,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, listAllProfileSessions, 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,7 +43,6 @@ import {
workspaceCwdForNewSession
} from '@/store/session'
import { reportBackendContract } from '@/store/updates'
import { isWatchWindow } from '@/store/windows'
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, SessionRuntimeInfo, UsageStats } from '@/types/hermes'
import { NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../routes'
@@ -236,42 +235,18 @@ async function resolveStoredSession(storedSessionId: string): Promise<SessionInf
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)
const result = await listAllProfileSessions(500, 0, 'include', 'recent', 'all')
const resolved = result.sessions.find(session => sessionMatchesStoredId(session, 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.
if (resolved) {
upsertResolvedSession(resolved, storedSessionId)
}
}
return undefined
return resolved
} catch {
return undefined
}
}
type SessionRuntimeStatePatch = Partial<
@@ -431,17 +406,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,31 +518,8 @@ export function useSessionActions({
const isCurrentResume = () =>
resumeRequestRef.current === requestId && selectedStoredSessionIdRef.current === storedSessionId
// Paint the click before the profile-resolve / gateway-swap awaits below,
// so there's zero dead air: highlight the row instantly (the sidebar reads
// $selectedStoredSessionId) and, for a cold target, drop the previous
// transcript so the thread shows its loader instead of the old session
// lingering until resume lands. A warm-cached target keeps its transcript —
// the cached fast-path repaints it this same tick. Setting the ref here is
// also what use-route-resume's self-heal assumes ("set synchronously at
// resume entry").
setFreshDraftReady(false)
clearNotifications()
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
const warmRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
if (!warmRuntimeId || !sessionStateByRuntimeIdRef.current.get(warmRuntimeId)) {
setActiveSessionId(null)
activeSessionIdRef.current = null
setMessages([])
}
// Swap the single live gateway to this session's profile before any
// gateway call (no-op when it's already on that profile / single-profile).
// resolveStoredSession finds the row by id (cheap), so an uncached pasted
// id loads as fast as a sidebar click instead of hanging on a list scan.
const storedForProfile = await resolveStoredSession(storedSessionId)
const sessionProfile = storedForProfile?.profile
@@ -586,7 +534,6 @@ export function useSessionActions({
if (cachedRuntimeId && cachedState) {
const stored = $sessions.get().find(session => session.id === storedSessionId)
const cachedViewState =
!cachedState.model && stored?.model != null
? {
@@ -659,46 +606,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 +647,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 +675,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 +701,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)
}
}
},

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

@@ -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

@@ -6,7 +6,6 @@ import { type FormEvent, type KeyboardEvent, useCallback, useMemo, useRef, useSt
import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
import { Button } from '@/components/ui/button'
import { KbdCombo } from '@/components/ui/kbd'
import { Textarea } from '@/components/ui/textarea'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
@@ -230,10 +229,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
value={draft}
/>
<div className="flex items-center justify-between gap-2">
<span className="inline-flex items-center gap-1 text-[0.6875rem] text-muted-foreground/85">
<KbdCombo combo="mod+enter" size="sm" />
{copy.shortcutSuffix}
</span>
<span className="text-[0.6875rem] text-muted-foreground/85">{copy.shortcut}</span>
<div className="flex items-center gap-1.5">
{hasChoices && (
<Button

View File

@@ -2,22 +2,12 @@
import { TextMessagePartProvider, useMessagePartText } from '@assistant-ui/react'
import {
parseMarkdownIntoBlocks,
type StreamdownTextComponents,
StreamdownTextPrimitive,
type SyntaxHighlighterProps
} from '@assistant-ui/react-streamdown'
import { code } from '@streamdown/code'
import {
type ComponentProps,
memo,
type ReactNode,
useDeferredValue,
useEffect,
useMemo,
useRef,
useState
} from 'react'
import { type ComponentProps, memo, type ReactNode, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { SyntaxHighlighter } from '@/components/chat/shiki-highlighter'
@@ -36,7 +26,6 @@ import {
mediaStreamUrl
} from '@/lib/media'
import { previewTargetFromMarkdownHref } from '@/lib/preview-targets'
import { tailBoundedRemend } from '@/lib/remend-tail'
import { cn } from '@/lib/utils'
// Math rendering plugin (KaTeX). Configured once at module scope — the
@@ -53,51 +42,6 @@ import { cn } from '@/lib/utils'
// LLM convention). The default false-setting only accepts `$$...$$`.
const mathPlugin = createMemoizedMathPlugin({ singleDollarTextMath: true })
// Replaces Streamdown's `parseIncompleteMarkdown` (full-text remend per
// flush) with a tail-bounded repair — see lib/remend-tail.ts. Must stay
// module-scope so the prop identity is stable across renders.
function preprocessWithTailRepair(text: string): string {
return tailBoundedRemend(preprocessMarkdown(text))
}
// Memoized block splitter. Streamdown calls `parseMarkdownIntoBlocks` (a full
// `marked` lex of the entire message, ~1.6ms per 28KB) inside a useMemo keyed
// on the text — but the same text is re-lexed every time a message REMOUNTS
// (virtualizer scroll, session switch) and whenever multiple surfaces render
// the same content (deferred + smooth reveal republish). A small module-level
// LRU keyed by the exact source string removes all of those repeat parses
// with zero correctness risk (same input → same output). Streaming tail
// growth misses the cache by design (every flush is a new string) — that
// single lex is the irreducible cost.
const BLOCK_CACHE_MAX = 64
const BLOCK_CACHE_MIN_LENGTH = 1024
const blockCache = new Map<string, string[]>()
function parseMarkdownIntoBlocksCached(markdown: string): string[] {
if (markdown.length < BLOCK_CACHE_MIN_LENGTH) {
return parseMarkdownIntoBlocks(markdown)
}
const hit = blockCache.get(markdown)
if (hit) {
// Refresh recency (Map iteration order is insertion order).
blockCache.delete(markdown)
blockCache.set(markdown, hit)
return hit
}
const blocks = parseMarkdownIntoBlocks(markdown)
blockCache.set(markdown, blocks)
if (blockCache.size > BLOCK_CACHE_MAX) {
blockCache.delete(blockCache.keys().next().value as string)
}
return blocks
}
async function mediaSrc(path: string): Promise<string> {
if (/^(?:https?|data):/i.test(path)) {
return path
@@ -297,13 +241,6 @@ function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>)
// keeps draining its tail instead of snapping.
const REVEAL_DRAIN_MS = 500
const REVEAL_MAX_CHARS_PER_FRAME = 30
// Floor between reveal commits. Each commit republishes the text context and
// re-runs the whole Streamdown pipeline (preprocess → remend → lex → micromark
// on the open block) over the full accumulated text — at raw rAF cadence
// that's 60 full parses/second and was the dominant streaming cost for
// reasoning text. ~33ms keeps the reveal visually fluid (2 frames) while
// halving the parse work.
const REVEAL_MIN_COMMIT_MS = 33
function useSmoothReveal(text: string, isRunning: boolean): string {
const [displayed, setDisplayed] = useState(isRunning ? '' : text)
@@ -336,27 +273,10 @@ function useSmoothReveal(text: string, isRunning: boolean): string {
const tick = () => {
const now = performance.now()
const dt = now - lastTickRef.current
// Skip this frame if the floor hasn't elapsed — the backlog math below
// is dt-proportional, so delayed commits reveal proportionally more.
if (dt < REVEAL_MIN_COMMIT_MS) {
frameRef.current = requestAnimationFrame(tick)
return
}
lastTickRef.current = now
const remaining = targetRef.current.length - shownRef.current.length
const add = Math.min(
remaining,
// dt-scaled so the per-commit cap stays equivalent to the old
// per-frame cap at any commit cadence.
Math.ceil((REVEAL_MAX_CHARS_PER_FRAME * dt) / 16.7),
Math.max(1, Math.ceil((remaining * dt) / REVEAL_DRAIN_MS))
)
const add = Math.min(remaining, REVEAL_MAX_CHARS_PER_FRAME, Math.max(1, Math.ceil((remaining * dt) / REVEAL_DRAIN_MS)))
shownRef.current = targetRef.current.slice(0, shownRef.current.length + add)
setDisplayed(shownRef.current)
@@ -540,20 +460,17 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
containerProps={containerProps}
lineNumbers={false}
mode="streaming"
// Incomplete-markdown repair is handled by `preprocessWithTailRepair`
// below (tail-bounded remend) instead of Streamdown's built-in pass,
// which re-runs remend over the ENTIRE message on every flush — ~18%
// of streaming script time on 50KB+ messages. The repair itself stays
// always-on (even between flushes / for completed messages): an
// unclosed ```python ... ``` whose body contains `$` (shell snippets,
// JS template strings, dollar amounts) would otherwise leak those
// dollars to the math parser and render broken inline math. Shiki is
// independently deferred via `defer={isStreaming}` on the
// SyntaxHighlighter component.
parseIncompleteMarkdown={false}
parseMarkdownIntoBlocksFn={parseMarkdownIntoBlocksCached}
// Always auto-close incomplete fences — even during streaming.
// Without this, an unclosed ```python ... ``` whose body contains
// `$` (very common: shell snippets, JS template strings, dollar
// amounts) leaks those dollars out to the math parser and they
// get rendered as broken inline math until the closing fence
// arrives. Shiki is independently deferred via `defer={isStreaming}`
// on the SyntaxHighlighter component, so we don't pay code-block
// tokenization on every token even with this set.
parseIncompleteMarkdown
plugins={plugins}
preprocess={preprocessWithTailRepair}
preprocess={preprocessMarkdown}
/>
)
}

View File

@@ -58,9 +58,9 @@ Element.prototype.animate = function animate() {
} as unknown as Animation
}
// jsdom returns 0 for offset*; some layout code reads those to size the
// jsdom returns 0 for offset*; the virtualizer reads those to size its
// viewport. Fall through to client* (which tests can override) or a sane
// default so message rows render with non-zero dimensions.
// default so virtualized items render.
function stubOffsetDimension(
prop: 'offsetHeight' | 'offsetWidth',
clientProp: 'clientHeight' | 'clientWidth',
@@ -216,21 +216,25 @@ function assistantTodoMessage(
} as ThreadMessage
}
function assistantImageMessage(running = false): ThreadMessage {
function assistantReasoningTodoMessage(
todos: Array<{ content: string; id: string; status: 'cancelled' | 'completed' | 'in_progress' | 'pending' }>
): ThreadMessage {
return {
id: `assistant-image-${running ? 'running' : 'done'}`,
id: 'assistant-reasoning-todo-1',
role: 'assistant',
content: [
{ type: 'reasoning', text: 'Let me make a quick todo list.' },
{
type: 'tool-call',
toolCallId: 'image-1',
toolName: 'image_generate',
args: { prompt: 'draw a cat' },
argsText: JSON.stringify({ prompt: 'draw a cat' }),
...(running ? {} : { result: { image: 'https://cdn.example/cat.png', success: true } })
}
toolCallId: 'todo-1',
toolName: 'todo',
args: { todos },
argsText: JSON.stringify({ todos }),
result: { todos }
},
{ type: 'text', text: 'Done — fake list created.' }
],
status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' },
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
@@ -280,6 +284,20 @@ function StreamingHarness() {
)
}
function StaticThreadHarness() {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [userMessage(), assistantMessage('complete response', false)],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
function TodoHarness({ message }: { message: ThreadMessage }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [message],
@@ -421,11 +439,222 @@ describe('assistant-ui streaming renderer', () => {
expect(screen.getByRole('alert').textContent).toContain('OpenRouter rejected the request (403).')
})
// Scroll behavior (follow-at-bottom, escape-on-scroll-up, re-engage) is owned
// by the use-stick-to-bottom library and covered by its own test suite. We
// don't re-assert its scrollTop mechanics here — doing so in jsdom (no real
// layout, spring animation via rAF) only produces brittle change-detector
// tests. The rendering/streaming-content tests below remain the contract.
it('does not pull the viewport back down after the user scrolls up during streaming', async () => {
const { container } = render(<StreamingHarness />)
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
const viewport = content.parentElement as HTMLDivElement
let scrollHeight = 1_000
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
Object.defineProperty(viewport, 'scrollHeight', {
configurable: true,
get: () => scrollHeight
})
await wait(80)
await act(async () => {
viewport.scrollTop = 800
fireEvent.scroll(viewport)
})
await wait(0)
await act(async () => {
fireEvent.wheel(viewport, { deltaY: -120 })
viewport.scrollTop = 420
fireEvent.scroll(viewport)
})
scrollHeight = 1_200
await act(async () => {
for (const observer of resizeObservers) {
observer.trigger(1_200)
}
})
await wait(0)
expect(viewport.scrollTop).toBe(420)
})
it('does not auto-follow idle layout shifts', async () => {
const { container } = render(<StaticThreadHarness />)
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
const viewport = content.parentElement as HTMLDivElement
let scrollHeight = 1_000
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
Object.defineProperty(viewport, 'scrollHeight', {
configurable: true,
get: () => scrollHeight
})
await wait(80)
await act(async () => {
viewport.scrollTop = 420
fireEvent.scroll(viewport)
})
scrollHeight = 1_200
await act(async () => {
for (const observer of resizeObservers) {
observer.trigger(1_200)
}
})
await wait(0)
expect(viewport.scrollTop).toBe(420)
})
it('does not follow streaming content growth even while parked at the bottom', async () => {
const { container } = render(<StreamingHarness />)
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
const viewport = content.parentElement as HTMLDivElement
let clientHeight = 200
let scrollHeight = 1_000
Object.defineProperty(viewport, 'clientHeight', {
configurable: true,
get: () => clientHeight
})
Object.defineProperty(viewport, 'scrollHeight', {
configurable: true,
get: () => scrollHeight
})
await wait(80)
// Park the user at the bottom of the current content.
await act(async () => {
viewport.scrollTop = 800
fireEvent.scroll(viewport)
})
clientHeight = 240
await act(async () => {
viewport.scrollTop = 760
fireEvent.scroll(viewport)
})
// Content grows as tokens stream in. Streaming auto-follow is removed, so
// the viewport must NOT chase the new bottom — it stays where the user
// last left it.
scrollHeight = 1_200
await act(async () => {
for (const observer of resizeObservers) {
observer.trigger(1_200)
}
})
await wait(0)
expect(viewport.scrollTop).toBe(760)
})
it('honors the first upward wheel scroll even when a programmatic bottom-pin scroll event is still pending', async () => {
const { container } = render(<StreamingHarness />)
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
const viewport = content.parentElement as HTMLDivElement
let scrollHeight = 1_000
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
Object.defineProperty(viewport, 'scrollHeight', {
configurable: true,
get: () => scrollHeight
})
await wait(80)
await wait(0)
await act(async () => {
fireEvent.wheel(viewport, { deltaY: -120 })
viewport.scrollTop = 420
fireEvent.scroll(viewport)
})
scrollHeight = 1_200
await act(async () => {
for (const observer of resizeObservers) {
observer.trigger(1_200)
}
})
await wait(0)
expect(viewport.scrollTop).toBe(420)
})
it('does not snap to the bottom on final code-highlight growth after a run completes', async () => {
const { container } = render(<StreamingHarness />)
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
const viewport = content.parentElement as HTMLDivElement
let scrollHeight = 1_000
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
Object.defineProperty(viewport, 'scrollHeight', {
configurable: true,
get: () => scrollHeight
})
await wait(80)
await act(async () => {
viewport.scrollTop = 800
fireEvent.scroll(viewport)
})
await wait(650)
// Completion re-measures (Shiki highlight) and grows the content. The
// post-run bottom lock is removed, so the viewport stays put instead of
// snapping to the new bottom.
scrollHeight = 1_700
await wait(0)
expect(viewport.scrollTop).toBe(800)
})
it('does not restart bottom-follow after completion when the user scrolled up', async () => {
const { container } = render(<StreamingHarness />)
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
const viewport = content.parentElement as HTMLDivElement
let scrollHeight = 1_000
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
Object.defineProperty(viewport, 'scrollHeight', {
configurable: true,
get: () => scrollHeight
})
await wait(80)
await act(async () => {
viewport.scrollTop = 800
fireEvent.scroll(viewport)
})
await act(async () => {
fireEvent.wheel(viewport, { deltaY: -120 })
viewport.scrollTop = 420
fireEvent.scroll(viewport)
})
await wait(650)
scrollHeight = 1_700
await wait(0)
expect(viewport.scrollTop).toBe(420)
})
it('renders an incomplete streaming fenced code block as a code card', async () => {
const { container } = render(<RunningMessageHarness message={assistantMessage('```ts\nconst answer = 42\n')} />)
@@ -441,19 +670,14 @@ describe('assistant-ui streaming renderer', () => {
it('renders an incomplete streaming reasoning fenced code block as a code card', async () => {
const { container } = render(<RunningReasoningHarness />)
const ui = within(container)
const thinkingToggle = ui.getByRole('button', { name: /thinking/i })
if (thinkingToggle.getAttribute('aria-expanded') !== 'true') {
fireEvent.click(thinkingToggle)
}
fireEvent.click(ui.getByRole('button', { name: /thinking/i }))
await waitFor(() => {
expect(container.querySelector('[data-slot="code-card"]')).toBeTruthy()
})
await waitFor(() => {
expect(container.querySelector('[data-slot="aui_reasoning-text"]')?.textContent).toContain('const answer = 42')
})
expect(container.querySelector('[data-slot="aui_reasoning-text"]')?.textContent).toContain('const answer = 42')
expect(container.textContent).not.toContain('```ts')
})
@@ -494,7 +718,7 @@ describe('assistant-ui streaming renderer', () => {
expect(container.textContent).toContain('Interim answer.')
})
it('does not render an inline todo panel — todos live in the composer status stack', () => {
it('renders live todo rows during a running turn', () => {
const { container } = render(
<TodoHarness
message={assistantTodoMessage([
@@ -504,18 +728,52 @@ describe('assistant-ui streaming renderer', () => {
/>
)
expect(container.querySelector('[data-slot="aui_todo-hoisted"]')).toBeNull()
const ui = within(container)
expect(container.querySelector('[data-slot="aui_todo-hoisted"]')).toBeTruthy()
expect(ui.getAllByText('Boil water').length).toBeGreaterThan(0)
expect(ui.getByText('Gather ingredients')).toBeTruthy()
expect(ui.queryByText(/pending/i)).toBeNull()
expect(ui.queryByRole('button', { name: /todo/i })).toBeNull()
})
it('renders completed image generation results in the tool slot', async () => {
const { container } = render(<MessageHarness message={assistantImageMessage()} />)
it('renders archived todos after turn completion regardless of pending state', () => {
const first = render(
<TodoHarness message={assistantTodoMessage([{ content: 'Boil water', id: 'boil', status: 'pending' }], false)} />
)
await waitFor(() => {
expect(screen.getByRole('img', { name: 'Generated image' }).getAttribute('src')).toBe(
'https://cdn.example/cat.png'
)
})
expect(container.querySelector('[data-slot="aui_generated-image"]')).toBeTruthy()
expect(screen.queryByRole('status', { name: /rendering image/i })).toBeNull()
const ui = within(first.container)
expect(ui.getAllByText('Boil water').length).toBeGreaterThan(0)
first.unmount()
const second = render(
<TodoHarness
message={assistantTodoMessage([{ content: 'Serve latte', id: 'serve', status: 'completed' }], false)}
/>
)
const archivedUi = within(second.container)
expect(archivedUi.getAllByText('Serve latte').length).toBeGreaterThan(0)
})
it('hoists todo outside the thinking disclosure when reasoning is present', () => {
const { container } = render(
<TodoHarness
message={assistantReasoningTodoMessage([
{ content: 'Buy oats', id: 'oats', status: 'completed' },
{ content: "Reply to Sam's email", id: 'email', status: 'in_progress' }
])}
/>
)
const todoPanel = container.querySelector('[data-slot="aui_todo-hoisted"]')
const thinkingDisclosure = container.querySelector('[data-slot="aui_thinking-disclosure"]')
expect(todoPanel).toBeTruthy()
expect(thinkingDisclosure).toBeTruthy()
expect(Boolean(thinkingDisclosure?.contains(todoPanel as Node))).toBe(false)
})
})

View File

@@ -1,307 +0,0 @@
import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react'
import {
type ComponentProps,
type FC,
memo,
type ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState
} from 'react'
import { useStickToBottom } from 'use-stick-to-bottom'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import {
onScrollToBottomRequest,
onThreadEditClose,
onThreadEditOpen,
resetThreadScroll,
setThreadAtBottom
} from '@/store/thread-scroll'
import { MessageRenderBoundary } from './message-render-boundary'
type ThreadMessageComponents = ComponentProps<typeof ThreadPrimitive.MessageByIndex>['components']
type MessageGroup = { id: string; weight: number } & (
| { index: number; kind: 'standalone' }
| { indices: number[]; kind: 'turn' }
)
// DOM is bounded by a rendered-PART budget, not a message/turn count: a single
// assistant message folds every tool call into a part, so heavy sessions are
// ~40 turns / ~100 messages but ~1000 parts — and parts are what drive node
// count. "Show earlier" prepends another page; whole turns stay intact so the
// sticky human bubble never loses its turn. This is the long-session perf lever
// WITHOUT a virtualizer — pure rendering, never touches scrollTop, so it can't
// fight use-stick-to-bottom (the single scroll owner).
const RENDER_BUDGET = 300
interface ThreadMessageListProps {
clampToComposer: boolean
components: ThreadMessageComponents
emptyPlaceholder?: ReactNode
loadingIndicator?: ReactNode
sessionKey?: string | null
}
// Group each user message with the assistant turn(s) that follow it so the
// human bubble can `position: sticky` against the scroller across its whole
// turn (see StickyHumanMessageContainer in thread.tsx).
function buildGroups(signature: string): MessageGroup[] {
if (!signature) {
return []
}
const messages = signature.split('\n').map(row => {
const [index, id, role, weight] = row.split(':')
return { id, index: Number(index), role, weight: Number(weight) || 1 }
})
const groups: MessageGroup[] = []
for (let i = 0; i < messages.length; i++) {
const message = messages[i]
if (message.role !== 'user') {
groups.push({ id: message.id, index: message.index, kind: 'standalone', weight: message.weight })
continue
}
const indices = [message.index]
let weight = message.weight
while (i + 1 < messages.length && messages[i + 1].role !== 'user') {
weight += messages[++i].weight
indices.push(messages[i].index)
}
groups.push({ id: message.id, indices, kind: 'turn', weight })
}
return groups
}
const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
clampToComposer,
components,
emptyPlaceholder,
loadingIndicator,
sessionKey
}) => {
const messageSignature = useAuiState(s =>
s.thread.messages
.map((message, index) => `${index}:${message.id}:${message.role}:${message.content?.length ?? 1}`)
.join('\n')
)
const { t } = useI18n()
const groups = buildGroups(messageSignature)
const renderEmpty = groups.length === 0 && Boolean(emptyPlaceholder)
// use-stick-to-bottom owns scrollTop (single writer): follow while locked,
// escape on user scroll-up, re-lock at bottom. Snap instantly, not spring — a
// spring can't tell live-token growth from a session-switch bulk relayout, and
// chasing the latter reads as the view scrolling to random spots before
// settling. Its refs hang off our own DOM so the sticky human bubbles survive.
const { scrollRef, contentRef, isAtBottom, scrollToBottom, stopScroll } = useStickToBottom({
initial: 'instant',
resize: 'instant'
})
const [renderBudget, setRenderBudget] = useState(RENDER_BUDGET)
// Walk turns newest-first, summing their part weights until the budget is met;
// everything before that first kept turn is hidden.
let firstVisible = groups.length
for (let i = groups.length - 1, weight = 0; i >= 0; i--) {
weight += groups[i].weight
firstVisible = i
if (weight >= renderBudget) {
break
}
}
const hiddenCount = firstVisible
const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups
const restoreFromBottomRef = useRef<number | null>(null)
useEffect(() => setThreadAtBottom(isAtBottom), [isAtBottom])
useEffect(() => () => resetThreadScroll(), [])
// Floating jump button (outside this subtree) → return to the bottom.
useEffect(() => onScrollToBottomRequest(() => void scrollToBottom()), [scrollToBottom])
const endEditHold = useCallback(() => {
scrollRef.current?.removeAttribute('data-editing')
}, [scrollRef])
// Inline edit grows a sticky bubble. Escape before focus/layout so the
// resize-follow can't snap scrollTop; native anchoring holds the viewport.
const beginEditHold = useCallback(() => {
const el = scrollRef.current
if (!el) {
return
}
endEditHold()
stopScroll()
el.setAttribute('data-editing', 'true')
}, [endEditHold, scrollRef, stopScroll])
useEffect(() => onThreadEditOpen(beginEditHold), [beginEditHold])
useEffect(() => onThreadEditClose(endEditHold), [endEditHold])
useEffect(() => () => endEditHold(), [endEditHold])
// New run → snap to the latest turn.
useAuiEvent('thread.runStart', () => void scrollToBottom())
// Reset the cap and pin to bottom on mount + every session switch (messages
// swap in place on a long-lived runtime, so sessionKey is the only signal).
// The swap is multi-step and lays out over many frames; letting the library
// follow re-pins every frame to a moving target — visible as ~10 scroll jumps.
// Instead: quiet it, glue to the true bottom until the height holds steady,
// then hand back locked. Live streaming afterward uses the normal resize follow.
useLayoutEffect(() => {
setRenderBudget(RENDER_BUDGET)
const el = scrollRef.current
if (!el) {
return
}
stopScroll()
el.scrollTop = el.scrollHeight
let frame = 0
let stableFrames = 0
let lastHeight = el.scrollHeight
const settle = () => {
const node = scrollRef.current
if (!node) {
return
}
const height = node.scrollHeight
stableFrames = height === lastHeight ? stableFrames + 1 : 0
lastHeight = height
node.scrollTop = height
// ~5 steady frames ≈ layout has settled; the frame cap bounds slow loads.
if (stableFrames >= 5 || ++frame > 90) {
void scrollToBottom('instant')
return
}
rafId = requestAnimationFrame(settle)
}
let rafId = requestAnimationFrame(settle)
return () => cancelAnimationFrame(rafId)
}, [scrollRef, scrollToBottom, sessionKey, stopScroll])
// Prepend an older page while preserving the on-screen position. The user is
// scrolled up (reading history) so the stick-to-bottom lock is escaped and
// won't fight this manual restore.
const showEarlier = useCallback(() => {
const el = scrollRef.current
restoreFromBottomRef.current = el ? el.scrollHeight - el.scrollTop : null
setRenderBudget(budget => budget + RENDER_BUDGET)
}, [scrollRef])
useLayoutEffect(() => {
const el = scrollRef.current
if (el && restoreFromBottomRef.current != null) {
el.scrollTop = el.scrollHeight - restoreFromBottomRef.current
restoreFromBottomRef.current = null
}
}, [scrollRef, renderBudget])
return (
<div
className="relative min-h-0 max-w-full overflow-hidden contain-[layout_paint]"
style={{ height: clampToComposer ? 'var(--thread-viewport-height)' : '100%' }}
>
<div
className="size-full overflow-x-hidden overflow-y-auto overscroll-contain"
data-following={isAtBottom ? 'true' : 'false'}
data-slot="aui_thread-viewport"
ref={scrollRef as React.RefCallback<HTMLDivElement>}
>
{renderEmpty ? (
<div
className="mx-auto grid h-full w-full max-w-(--composer-width) grid-rows-[minmax(0,1fr)_auto] min-w-0 gap-(--conversation-turn-gap) px-6 py-8"
data-slot="aui_thread-content"
>
{emptyPlaceholder}
</div>
) : (
<div
className={cn(
'mx-auto flex w-full max-w-(--composer-width) min-w-0 flex-col px-6 pt-[calc(var(--titlebar-height)+1.5rem)]'
)}
data-slot="aui_thread-content"
ref={contentRef as React.RefCallback<HTMLDivElement>}
>
{hiddenCount > 0 && (
<button
className="mx-auto mb-(--conversation-turn-gap) rounded-full border border-border/65 bg-(--composer-fill) px-3 py-1 text-xs text-muted-foreground hover:text-foreground"
onClick={showEarlier}
type="button"
>
{t.assistant.thread.showEarlier}
</button>
)}
{visibleGroups.map(group => (
<div
className="flex min-w-0 flex-col gap-(--conversation-turn-gap) pb-(--conversation-turn-gap)"
key={group.id}
>
<MessageRenderBoundary resetKey={messageSignature}>
{group.kind === 'turn' ? (
<div
className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)"
data-slot="aui_turn-pair"
>
{group.indices.map(index => (
<ThreadPrimitive.MessageByIndex components={components} index={index} key={index} />
))}
</div>
) : (
<ThreadPrimitive.MessageByIndex components={components} index={group.index} />
)}
</MessageRenderBoundary>
</div>
))}
{loadingIndicator}
{clampToComposer && (
<div
aria-hidden="true"
className="shrink-0"
data-slot="aui_composer-clearance"
style={{ height: 'var(--thread-last-message-clearance)' }}
/>
)}
</div>
)}
</div>
</div>
)
}
export const ThreadMessageList = memo(ThreadMessageListInner)

View File

@@ -0,0 +1,469 @@
import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react'
import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual'
import {
type ComponentProps,
type FC,
memo,
type ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef
} from 'react'
import { setMutableRef } from '@/lib/mutable-ref'
import { cn } from '@/lib/utils'
import { setThreadScrolledUp } from '@/store/thread-scroll'
import { MessageRenderBoundary } from './message-render-boundary'
const ESTIMATED_ITEM_HEIGHT = 220
const OVERSCAN = 4
const AT_BOTTOM_THRESHOLD = 4
type ThreadMessageComponents = ComponentProps<typeof ThreadPrimitive.MessageByIndex>['components']
type MessageGroup = { id: string; index: number; kind: 'standalone' } | { id: string; indices: number[]; kind: 'turn' }
interface VirtualizedThreadProps {
clampToComposer: boolean
components: ThreadMessageComponents
emptyPlaceholder?: ReactNode
loadingIndicator?: ReactNode
sessionKey?: string | null
}
function buildGroups(signature: string): MessageGroup[] {
if (!signature) {
return []
}
const messages = signature.split('\n').map(row => {
const [index, id, role] = row.split(':')
return { id, index: Number(index), role }
})
const groups: MessageGroup[] = []
for (let i = 0; i < messages.length; i++) {
const message = messages[i]
if (message.role !== 'user') {
groups.push({ id: message.id, index: message.index, kind: 'standalone' })
continue
}
const indices = [message.index]
while (i + 1 < messages.length && messages[i + 1].role !== 'user') {
indices.push(messages[++i].index)
}
groups.push({ id: message.id, indices, kind: 'turn' })
}
return groups
}
const VirtualizedThreadInner: FC<VirtualizedThreadProps> = ({
clampToComposer,
components,
emptyPlaceholder,
loadingIndicator,
sessionKey
}) => {
const messageSignature = useAuiState(s =>
s.thread.messages.map((message, index) => `${index}:${message.id}:${message.role}`).join('\n')
)
const isRunning = useAuiState(s => s.thread.isRunning)
const groups = useMemo(() => buildGroups(messageSignature), [messageSignature])
const renderEmpty = groups.length === 0 && Boolean(emptyPlaceholder)
const scrollerRef = useRef<HTMLDivElement | null>(null)
// Shared ref so scrollToFn can check whether the user is parked at the
// bottom without needing a ref from inside useThreadScrollAnchor.
const stickyBottomRef = useRef(true)
const virtualizer = useVirtualizer({
count: groups.length,
estimateSize: () => ESTIMATED_ITEM_HEIGHT,
getItemKey: index => groups[index]?.id ?? index,
getScrollElement: () => scrollerRef.current,
// Seed the rect so the initial range mounts something before
// `observeElementRect` reports the real layout (it overrides this).
initialRect: { height: 600, width: 800 },
overscan: OVERSCAN,
// When the virtualizer adjusts scroll due to item measurement changes,
// skip the adjustment if the user is at the bottom. Our ResizeObserver +
// pinToBottom loop handles scroll anchoring; letting the virtualizer also
// adjust creates a feedback loop where the two fight each other,
// producing visible rubber-banding (the view snaps to the composer
// then jumps back up).
scrollToFn: (offset, _options, instance) => {
const el = instance.scrollElement
if (!el) {
return
}
if (stickyBottomRef.current) {
const maxScroll = el.scrollHeight - el.clientHeight
const distFromBottom = maxScroll - el.scrollTop
if (distFromBottom <= AT_BOTTOM_THRESHOLD && offset < maxScroll) {
return
}
}
;(el as HTMLElement).scrollTo(0, offset)
}
})
useThreadScrollAnchor({
enabled: !renderEmpty,
groupCount: groups.length,
isRunning,
scrollerRef,
sessionKey: sessionKey ?? null,
stickyBottomRef,
virtualizer
})
const virtualItems = virtualizer.getVirtualItems()
const totalSize = virtualizer.getTotalSize()
const paddingTop = virtualItems[0]?.start ?? 0
const paddingBottom = Math.max(0, totalSize - (virtualItems.at(-1)?.end ?? 0))
return (
<div
className="relative min-h-0 max-w-full overflow-hidden contain-[layout_paint]"
style={{ height: clampToComposer ? 'var(--thread-viewport-height)' : '100%' }}
>
<div
className="size-full overflow-x-hidden overflow-y-auto overscroll-contain"
data-slot="aui_thread-viewport"
ref={scrollerRef}
>
{renderEmpty ? (
<div
className="mx-auto grid h-full w-full max-w-(--composer-width) grid-rows-[minmax(0,1fr)_auto] min-w-0 gap-(--conversation-turn-gap) px-6 py-8"
data-slot="aui_thread-content"
>
{emptyPlaceholder}
</div>
) : (
<div
className={cn(
'mx-auto flex w-full max-w-(--composer-width) min-w-0 flex-col px-6 pt-[calc(var(--titlebar-height)+1.5rem)]'
)}
data-slot="aui_thread-content"
>
{/* Natural-flow virtualization: mounted items render as normal
flex siblings so `position: sticky` on the human bubble
resolves against the scroller without transform interference.
Padding spacers reserve scroll space for unmounted items. */}
<div style={{ paddingBottom: `${paddingBottom}px`, paddingTop: `${paddingTop}px` }}>
{virtualItems.map(virtualItem => {
const group = groups[virtualItem.index]
if (!group) {
return null
}
return (
<div
className="flex min-w-0 flex-col gap-(--conversation-turn-gap) pb-(--conversation-turn-gap)"
data-index={virtualItem.index}
key={virtualItem.key}
ref={virtualizer.measureElement}
>
<MessageRenderBoundary resetKey={messageSignature}>
{group.kind === 'turn' ? (
<div
className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)"
data-slot="aui_turn-pair"
>
{group.indices.map(index => (
<ThreadPrimitive.MessageByIndex components={components} index={index} key={index} />
))}
</div>
) : (
<ThreadPrimitive.MessageByIndex components={components} index={group.index} />
)}
</MessageRenderBoundary>
</div>
)
})}
</div>
{loadingIndicator}
{clampToComposer && (
<div
aria-hidden="true"
className="shrink-0"
data-slot="aui_composer-clearance"
style={{ height: 'var(--thread-last-message-clearance)' }}
/>
)}
</div>
)}
</div>
</div>
)
}
export const VirtualizedThread = memo(VirtualizedThreadInner)
function scrollElementToBottom(el: HTMLDivElement) {
el.scrollTop = el.scrollHeight
}
interface ScrollAnchorOptions {
enabled: boolean
groupCount: number
isRunning: boolean
scrollerRef: React.RefObject<HTMLDivElement | null>
sessionKey: string | null
stickyBottomRef: React.MutableRefObject<boolean>
virtualizer: Virtualizer<HTMLDivElement, Element>
}
function useThreadScrollAnchor({
enabled,
groupCount,
isRunning,
scrollerRef,
sessionKey,
stickyBottomRef,
virtualizer
}: ScrollAnchorOptions) {
// `stickyBottomRef` = parked at bottom, content growth should follow. Cleared on
// user-driven upward scroll; re-armed when they reach bottom again.
// This is a shared ref — scrollToFn reads it to prevent the virtualizer's
// measurement adjustments from fighting our pinToBottom.
const lastTopRef = useRef(0)
const lastHeightRef = useRef(0)
const lastClientHeightRef = useRef(0)
// Counter that tracks how many scroll events we expect to be ours rather
// than the user's. `pinToBottom` writes `el.scrollTop`, which fires an
// async `scroll` event; without this guard the on-scroll handler can race
// with the programmatic write (because content also grew, the *resulting*
// scrollTop can be lower than `lastTopRef` from the previous frame) and
// misread the programmatic pin as the user scrolling up — which disarms
// sticky-bottom and the user's just-submitted message slides above the
// fold. See `apps/desktop/scripts/measure-jump.mjs` for the repro
// (distFromBottom 0 → 49 within one frame, sticking forever).
const programmaticScrollPendingRef = useRef(0)
const prevSessionKeyRef = useRef(sessionKey)
const prevGroupCountRef = useRef(0)
const pinToBottom = useCallback(() => {
const el = scrollerRef.current
if (!el) {
return
}
// Already parked at the bottom: writing `scrollTop` is a no-op and the
// browser fires NO scroll event, so arming the programmatic gate here would
// leave it permanently set. Repeated pins (streaming heartbeats, the
// post-run lock loop) then accumulate the gate, and the next genuine user
// scroll-up is misread as one of our programmatic scrolls — re-arming
// sticky-bottom and yanking the viewport back down. Refresh trackers, bail.
const distFromBottom = el.scrollHeight - (el.scrollTop + el.clientHeight)
if (distFromBottom <= AT_BOTTOM_THRESHOLD) {
lastTopRef.current = el.scrollTop
lastHeightRef.current = el.scrollHeight
lastClientHeightRef.current = el.clientHeight
return
}
// Hold the disarm gate across the scroll event the next line will fire.
// Set to 1 rather than incrementing: coalesced writes within a frame fire a
// single scroll event, so a counter > 1 can never drain and would swallow a
// later real user scroll.
programmaticScrollPendingRef.current = 1
scrollElementToBottom(el)
lastTopRef.current = el.scrollTop
lastHeightRef.current = el.scrollHeight
lastClientHeightRef.current = el.clientHeight
}, [scrollerRef])
const jumpToBottom = useCallback(() => {
setMutableRef(stickyBottomRef, true)
if (groupCount > 0) {
virtualizer.scrollToIndex(groupCount - 1, { align: 'end', behavior: 'auto' })
}
requestAnimationFrame(() => {
if (stickyBottomRef.current) {
pinToBottom()
}
})
}, [groupCount, pinToBottom, stickyBottomRef, virtualizer])
useEffect(() => () => setThreadScrolledUp(false), [])
// Track at-bottom state, dim composer when scrolled up, disarm on user
// scroll/wheel/touch.
useEffect(() => {
const el = scrollerRef.current
if (!el) {
return undefined
}
const disarm = () => {
setMutableRef(stickyBottomRef, false)
programmaticScrollPendingRef.current = 0
}
const onScroll = () => {
const top = el.scrollTop
// If this scroll event is the consequence of `pinToBottom` writing
// `el.scrollTop`, treat it as ours: don't disarm. The RO + rAF pin
// loop will re-pin on the next frame if the browser clamped us
// short of bottom (because content grew in the same frame).
// Without this guard the post-pin scrollTop gets misread as the
// user scrolling up, disarming sticky-bottom permanently and
// leaving the just-submitted message below the fold.
if (programmaticScrollPendingRef.current > 0) {
programmaticScrollPendingRef.current -= 1
lastTopRef.current = top
lastHeightRef.current = el.scrollHeight
lastClientHeightRef.current = el.clientHeight
// Always re-arm — sticky-bottom should hold through clamp races.
setMutableRef(stickyBottomRef, true)
const atBottom = el.scrollHeight - (top + el.clientHeight) <= AT_BOTTOM_THRESHOLD
setThreadScrolledUp(!atBottom)
return
}
// Disarm only when `scrollTop` decreases while both content height and
// viewport height are stable. A bare `top < lastTopRef.current` check is
// unsafe: virtualizer measurement, streaming markdown, composer resizing,
// window resizing, and toolbar/status updates can all move scrollTop as a
// layout side effect. Wheel-up and touchmove still disarm immediately via
// their own listeners below, so real user intent remains covered.
const heightGrew = el.scrollHeight > lastHeightRef.current
const clientHeightChanged = Math.abs(el.clientHeight - lastClientHeightRef.current) > 1
if (!heightGrew && !clientHeightChanged && top + 1 < lastTopRef.current) {
setMutableRef(stickyBottomRef, false)
}
lastTopRef.current = top
lastHeightRef.current = el.scrollHeight
lastClientHeightRef.current = el.clientHeight
const atBottom = el.scrollHeight - (top + el.clientHeight) <= AT_BOTTOM_THRESHOLD
if (atBottom) {
setMutableRef(stickyBottomRef, true)
}
setThreadScrolledUp(!atBottom)
}
const onWheel = (event: WheelEvent) => {
if (event.deltaY < 0) {
disarm()
}
}
el.addEventListener('scroll', onScroll, { passive: true })
el.addEventListener('wheel', onWheel, { passive: true })
el.addEventListener('touchmove', disarm, { passive: true })
return () => {
el.removeEventListener('scroll', onScroll)
el.removeEventListener('wheel', onWheel)
el.removeEventListener('touchmove', disarm)
}
}, [scrollerRef, stickyBottomRef])
// Intentionally NO streaming auto-follow. Earlier builds ran a
// ResizeObserver here that re-pinned the viewport to the bottom on every
// content growth while a turn was running, so the chat tracked tokens as
// they streamed. That behavior is removed by request: once a turn is in
// flight the viewport stays exactly where the user left it. The viewport
// is still moved to the bottom ONCE per user submit / new turn / session
// change (see the layout effect and the session-change effect below) so a
// freshly submitted message lands in view — but it does not chase the
// stream afterward.
// Jump to bottom on session change OR when an empty thread first gets
// content. Both share the same intent and the same effect.
useEffect(() => {
const sessionChanged = prevSessionKeyRef.current !== sessionKey
const becameNonEmpty = prevGroupCountRef.current === 0 && groupCount > 0
prevSessionKeyRef.current = sessionKey
prevGroupCountRef.current = groupCount
if (enabled && (sessionChanged || becameNonEmpty)) {
jumpToBottom()
}
}, [enabled, groupCount, jumpToBottom, sessionKey])
// Pre-paint pin: when groupCount increases while armed (a new turn arriving
// from the user submit or assistant turn start), pin BEFORE the browser
// commits the layout to screen. Using useLayoutEffect rather than useEffect
// so this runs synchronously after React commits the DOM mutation but before
// the browser paints. Without this, there's a ~50ms visual window where the
// new message sits below the fold.
//
// We pin TWICE in this critical path — once synchronously, then once on
// the next rAF. The second pin catches the case where React mounts the
// new message in the second commit (after our layout effect ran), which
// grows scrollHeight again; without the rAF pin the user briefly sees a
// ~15 px gap below the new message. This fires once per user submit / new
// turn arrival — it is NOT streaming-token follow (that path is removed
// above), so a turn that streams a long response after this initial jump
// will not chase the bottom.
const prevGroupCountForLayoutRef = useRef(groupCount)
useLayoutEffect(() => {
if (!enabled) {
return
}
if (groupCount > prevGroupCountForLayoutRef.current && stickyBottomRef.current) {
// Defer to rAF so that browser scroll/wheel events from the current
// frame are processed first. Without this deferral, a trackpad
// scroll-up during streaming can race with this effect: the wheel
// event hasn't fired yet so stickyBottomRef is still true, and the
// immediate pinToBottom() would snap the viewport back to bottom
// against the user's intent.
requestAnimationFrame(() => {
if (stickyBottomRef.current) {
pinToBottom()
}
})
}
prevGroupCountForLayoutRef.current = groupCount
}, [enabled, groupCount, pinToBottom, stickyBottomRef])
// Intentionally NO post-run bottom lock. Earlier builds kept pinning to
// the bottom for POST_RUN_BOTTOM_LOCK_MS after `isRunning` flipped false to
// chase final Shiki re-highlight measurement. With streaming follow gone,
// re-pinning at completion would yank the viewport back to the bottom even
// though the user is reading earlier content — the opposite of what's
// wanted. The one-time submit / new-turn jump already covers landing a
// fresh message in view.
const prevIsRunningForLayoutRef = useRef(isRunning)
useLayoutEffect(() => {
prevIsRunningForLayoutRef.current = isRunning
}, [isRunning])
useAuiEvent('thread.runStart', jumpToBottom)
}

View File

@@ -7,8 +7,7 @@ import {
MessagePrimitive,
type ToolCallMessagePartProps,
useAui,
useAuiState,
useMessageRuntime
useAuiState
} from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { IconPlayerStopFilled } from '@tabler/icons-react'
@@ -53,28 +52,24 @@ import {
} from '@/app/chat/composer/rich-editor'
import { detectTrigger, textBeforeCaret, type TriggerState } from '@/app/chat/composer/text-utils'
import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover'
import {
extractDroppedFiles,
HERMES_PATHS_MIME,
isImagePath,
partitionDroppedFiles
} from '@/app/chat/hooks/use-composer-actions'
import { extractDroppedFiles, HERMES_PATHS_MIME, isImagePath, partitionDroppedFiles } from '@/app/chat/hooks/use-composer-actions'
import { uploadComposerAttachment } from '@/app/session/hooks/use-prompt-actions'
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
import { DirectiveContent, hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/markdown-text'
import { ThreadMessageList } from '@/components/assistant-ui/thread-list'
import { VirtualizedThread } from '@/components/assistant-ui/thread-virtualizer'
import { HoistedTodoPanel, todosFromMessageContent } from '@/components/assistant-ui/todo-tool'
import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool-fallback'
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
import { UserMessageText } from '@/components/assistant-ui/user-message-text'
import { useElapsedSeconds } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
import { DisclosureRow } from '@/components/chat/disclosure-row'
import { GeneratedImage } from '@/components/chat/generated-image-result'
import { GeneratedImageProvider, useGeneratedImageContext } from '@/components/chat/generated-image-context'
import { ImageGenerationPlaceholder } from '@/components/chat/image-generation-placeholder'
import { Intro, type IntroProps } from '@/components/chat/intro'
import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { Codicon } from '@/components/ui/codicon'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { CopyButton } from '@/components/ui/copy-button'
import {
DropdownMenu,
@@ -99,18 +94,13 @@ import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
import type { ComposerAttachment } from '@/store/composer'
import { notifyError } from '@/store/notifications'
import { $connection } from '@/store/session'
import { notifyThreadEditClose, notifyThreadEditOpen } from '@/store/thread-scroll'
import { $voicePlayback } from '@/store/voice-playback'
type ThreadLoadingState = 'response' | 'session'
interface MessageActionProps {
messageId: string
/** Lazy accessor — reads the live message text at action time. Passing the
* text itself as a prop forces the whole footer to re-render on every
* streaming delta flush (the text changes ~30×/s), which profiling showed
* was a large slice of per-token script time on long transcripts. */
getMessageText: () => string
messageText: string
onBranchInNewChat?: (messageId: string) => void
}
@@ -138,28 +128,6 @@ function messageContentText(content: unknown): string {
return Array.isArray(content) ? content.map(partText).join('').trim() : ''
}
// Cheap streaming-stable "does this message have visible text" check: returns
// on the first non-whitespace text part without concatenating the whole
// message. Used as a useAuiState selector so its boolean output stays stable
// across token flushes (flips false→true once per turn).
function contentHasVisibleText(content: unknown): boolean {
if (typeof content === 'string') {
return content.trim().length > 0
}
if (!Array.isArray(content)) {
return false
}
for (const part of content) {
if (partText(part).trim().length > 0) {
return true
}
}
return false
}
export const Thread: FC<{
clampToComposer?: boolean
cwd?: string | null
@@ -168,7 +136,6 @@ export const Thread: FC<{
loading?: ThreadLoadingState
onBranchInNewChat?: (messageId: string) => void
onCancel?: () => Promise<void> | void
onRestoreToMessage?: (messageId: string) => Promise<void> | void
sessionId?: string | null
sessionKey?: string | null
}> = ({
@@ -179,7 +146,6 @@ export const Thread: FC<{
loading,
onBranchInNewChat,
onCancel,
onRestoreToMessage,
sessionId = null,
sessionKey
}) => {
@@ -188,9 +154,9 @@ export const Thread: FC<{
AssistantMessage: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} />,
SystemMessage,
UserEditComposer: () => <UserEditComposer cwd={cwd} gateway={gateway} sessionId={sessionId} />,
UserMessage: () => <UserMessage onCancel={onCancel} onRestoreToMessage={onRestoreToMessage} />
UserMessage: () => <UserMessage onCancel={onCancel} />
}),
[cwd, gateway, onBranchInNewChat, onCancel, onRestoreToMessage, sessionId]
[cwd, gateway, onBranchInNewChat, onCancel, sessionId]
)
const emptyPlaceholder = intro ? (
@@ -200,16 +166,18 @@ export const Thread: FC<{
) : undefined
return (
<div className="relative grid h-full min-h-0 max-w-full grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent contain-[layout_paint]">
<ThreadMessageList
clampToComposer={clampToComposer}
components={messageComponents}
emptyPlaceholder={emptyPlaceholder}
loadingIndicator={loading === 'response' ? <ResponseLoadingIndicator /> : null}
sessionKey={sessionKey}
/>
{loading === 'session' && <CenteredThreadSpinner />}
</div>
<GeneratedImageProvider>
<div className="relative grid h-full min-h-0 max-w-full grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent contain-[layout_paint]">
<VirtualizedThread
clampToComposer={clampToComposer}
components={messageComponents}
emptyPlaceholder={emptyPlaceholder}
loadingIndicator={loading === 'response' ? <ResponseLoadingIndicator /> : null}
sessionKey={sessionKey}
/>
{loading === 'session' && <CenteredThreadSpinner />}
</div>
</GeneratedImageProvider>
)
}
@@ -246,39 +214,21 @@ const CenteredThreadSpinner: FC = () => {
const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => {
const messageId = useAuiState(s => s.message.id)
const messageRuntime = useMessageRuntime()
// PERF: this component must NOT subscribe to the streaming text. Every
// selector here returns a value that stays referentially stable across
// token flushes (booleans, status strings, '' while running), so the
// 30 Hz delta stream only re-renders the markdown part and the tiny
// StreamStallIndicator leaf — not the footer/preview/root subtree.
const messageStatus = useAuiState(s => s.message.status?.type)
const isRunning = messageStatus === 'running'
const isPlaceholder = useAuiState(s => s.message.status?.type === 'running' && s.message.content.length === 0)
const hasVisibleText = useAuiState(s => contentHasVisibleText(s.message.content))
// Preview targets only materialize once the turn completes — while running
// the selector returns '' (stable), so per-token flushes skip the regex
// scan and the re-render it would cause.
const completedText = useAuiState(s =>
s.message.status?.type === 'running' ? '' : messageContentText(s.message.content)
)
const content = useAuiState(s => s.message.content)
const messageText = messageContentText(content)
const hoistedTodos = useMemo(() => todosFromMessageContent(content), [content])
const previewTargets = useMemo(() => {
if (!completedText || !/(https?:\/\/|file:\/\/)/i.test(completedText)) {
if (!messageText || !/(https?:\/\/|file:\/\/)/i.test(messageText)) {
return []
}
return pickPrimaryPreviewTarget(extractPreviewTargets(completedText))
}, [completedText])
return pickPrimaryPreviewTarget(extractPreviewTargets(messageText))
}, [messageText])
const getMessageText = useCallback(
() => messageContentText(messageRuntime.getState().content),
[messageRuntime]
)
const enterRef = useEnterAnimation(isRunning, `assistant-message:${messageId}`)
const messageStatus = useAuiState(s => s.message.status?.type)
const isPlaceholder = messageStatus === 'running' && content.length === 0
const enterRef = useEnterAnimation(messageStatus === 'running', `assistant-message:${messageId}`)
if (isPlaceholder) {
return null
@@ -289,16 +239,16 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
className="group flex w-full min-w-0 max-w-full flex-col gap-0 self-start overflow-hidden"
data-role="assistant"
data-slot="aui_assistant-message-root"
data-streaming={isRunning ? 'true' : undefined}
data-streaming={messageStatus === 'running' ? 'true' : undefined}
ref={enterRef}
>
<div
className="wrap-anywhere min-w-0 max-w-full overflow-hidden text-pretty text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground"
data-slot="aui_assistant-message-content"
>
{/* Todos render in the composer status stack now, not inline. */}
{hoistedTodos.length > 0 && <HoistedTodoPanel todos={hoistedTodos} />}
<MessagePrimitive.Parts components={MESSAGE_PARTS_COMPONENTS} />
{isRunning && <StreamStallIndicator />}
{messageStatus === 'running' && <StreamStallIndicator activity={`${content.length}:${messageText.length}`} />}
{previewTargets.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{previewTargets.map(target => (
@@ -315,8 +265,8 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
</ErrorPrimitive.Root>
</MessagePrimitive.Error>
</div>
{hasVisibleText && (
<AssistantFooter getMessageText={getMessageText} messageId={messageId} onBranchInNewChat={onBranchInNewChat} />
{messageText.trim().length > 0 && (
<AssistantFooter messageId={messageId} messageText={messageText} onBranchInNewChat={onBranchInNewChat} />
)}
</MessagePrimitive.Root>
)
@@ -357,28 +307,10 @@ const STREAM_STALL_S = 2
// Tail "still thinking" indicator: the pre-first-token spinner goes away once
// text flows, but if the stream then goes quiet mid-turn (tool think-time,
// provider stall) nothing signals that work continues. Watch a per-flush
// provider stall) nothing signals that work continues. Watch a per-render
// activity signal; when it hasn't changed for STREAM_STALL_S, re-show the
// dither + a timer counting from the last activity.
//
// Subscribes to the activity signal ITSELF (rather than taking it as a prop)
// so that per-token updates re-render only this leaf, not the whole
// AssistantMessage subtree.
const StreamStallIndicator: FC = () => {
const activity = useAuiState(s => {
let textLength = 0
for (const part of s.message.content) {
const text = (part as { text?: unknown }).text
if (typeof text === 'string') {
textLength += text.length
}
}
return `${s.message.content.length}:${textLength}`
})
const StreamStallIndicator: FC<{ activity: string }> = ({ activity }) => {
const [stalled, setStalled] = useState(false)
useEffect(() => {
@@ -402,12 +334,21 @@ const StreamStallIndicator: FC = () => {
)
}
const ImageGenerateTool: FC<ToolCallMessagePartProps> = ({ args, result }) => {
const aspectRatio = typeof args?.aspect_ratio === 'string' ? args.aspect_ratio : undefined
const ImageGenerateTool: FC<ToolCallMessagePartProps> = ({ result }) => {
const generatedImage = useGeneratedImageContext()
const running = result === undefined
useEffect(() => {
generatedImage?.setPending(running)
}, [generatedImage, running])
if (!running) {
return null
}
return (
<div className="mt-1.5">
<GeneratedImage aspectRatio={aspectRatio} result={result} />
<ImageGenerationPlaceholder />
</div>
)
}
@@ -637,7 +578,7 @@ function formatMessageTimestamp(
return SHORT_FMT.format(date)
}
const AssistantActionBar: FC<MessageActionProps> = ({ messageId, getMessageText, onBranchInNewChat }) => {
const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, onBranchInNewChat }) => {
const { t } = useI18n()
const copy = t.assistant.thread
const [menuOpen, setMenuOpen] = useState(false)
@@ -658,7 +599,7 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, getMessageText,
)}
data-slot="aui_msg-actions"
>
<CopyButton appearance="icon" buttonSize="icon" label={copy.copy} text={getMessageText} />
<CopyButton appearance="icon" buttonSize="icon" disabled={!messageText} label={copy.copy} text={messageText} />
<ActionBarPrimitive.Reload asChild>
<TooltipIconButton onClick={() => triggerHaptic('submit')} tooltip={copy.refresh}>
<Codicon name="refresh" />
@@ -676,7 +617,7 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, getMessageText,
<GitBranchIcon />
{copy.branchNewChat}
</DropdownMenuItem>
<ReadAloudItem getText={getMessageText} messageId={messageId} />
<ReadAloudItem messageId={messageId} text={messageText} />
</DropdownMenuContent>
</DropdownMenu>
</ActionBarPrimitive.Root>
@@ -684,7 +625,7 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, getMessageText,
)
}
const ReadAloudItem: FC<{ getText: () => string; messageId: string }> = ({ getText, messageId }) => {
const ReadAloudItem: FC<{ messageId: string; text: string }> = ({ messageId, text }) => {
const { t } = useI18n()
const copy = t.assistant.thread
const voicePlayback = useStore($voicePlayback)
@@ -698,8 +639,6 @@ const ReadAloudItem: FC<{ getText: () => string; messageId: string }> = ({ getTe
const Icon = isPreparing ? Loader2Icon : isSpeaking ? VolumeXIcon : Volume2Icon
const read = useCallback(async () => {
const text = getText()
if (!text || $voicePlayback.get().status !== 'idle') {
return
}
@@ -709,11 +648,11 @@ const ReadAloudItem: FC<{ getText: () => string; messageId: string }> = ({ getTe
} catch (error) {
notifyError(error, copy.readAloudFailed)
}
}, [copy.readAloudFailed, getText, messageId])
}, [copy.readAloudFailed, messageId, text])
return (
<DropdownMenuItem
disabled={isPreparing || (!isSpeaking && anyPlaybackActive)}
disabled={isPreparing || (!isSpeaking && (anyPlaybackActive || !text))}
onSelect={e => {
e.preventDefault()
void (isSpeaking ? stopVoicePlayback() : read())
@@ -767,22 +706,15 @@ function messageAttachmentRefs(value: unknown): string[] {
return value.every(ref => typeof ref === 'string') ? value : EMPTY_ATTACHMENT_REFS
}
function StickyHumanMessageContainer({ attachments, children }: { attachments?: ReactNode; children: ReactNode }) {
function StickyHumanMessageContainer({ children }: { children: ReactNode }) {
return (
// Fragment, not a wrapper: a wrapping element becomes the sticky's
// containing block (it'd stick within its own height = never). The bubble
// and attachments are flow siblings so the bubble pins against the scroller
// while attachments below it scroll away.
<>
<div
className="group/user-message sticky z-40 -mx-4 flex w-[calc(100%+2rem)] min-w-0 max-w-none flex-col items-stretch gap-0 self-end overflow-visible bg-(--ui-chat-surface-background) px-4 pb-(--conversation-turn-gap) pt-1"
data-role="user"
data-slot="aui_user-message-root"
>
{children}
</div>
{attachments}
</>
<div
className="group/user-message sticky z-40 -mx-4 flex w-[calc(100%+2rem)] min-w-0 max-w-none flex-col items-stretch gap-0 self-end overflow-visible bg-(--ui-chat-surface-background) px-4 pb-(--conversation-turn-gap) pt-2"
data-role="user"
data-slot="aui_user-message-root"
>
{children}
</div>
)
}
@@ -805,46 +737,11 @@ const USER_ACTION_ICON_BUTTON_CLASS =
const USER_ACTION_ICON_SIZE = '0.6875rem'
const StopGlyph = <IconPlayerStopFilled aria-hidden className="size-3.5 -translate-y-px" />
// Background-process notifications are injected into the conversation as user
// messages (the agent must react to them, and message-role alternation forbids
// a synthetic system row mid-loop). They are NOT something the human typed, so
// render them as a compact system-style notice instead of a user bubble.
// Shape: see tools/process_registry.py format_process_notification().
const PROCESS_NOTIFICATION_RE = /^\[IMPORTANT: Background process [\s\S]*\]$/
const ProcessNotificationNote: FC<{ text: string }> = ({ text }) => {
const body = text.replace(/^\[IMPORTANT:\s*/, '').replace(/\]$/, '')
const newline = body.indexOf('\n')
const headline = (newline === -1 ? body : body.slice(0, newline)).trim()
const detail = newline === -1 ? '' : body.slice(newline + 1).trim()
return (
<div className="flex max-w-[min(86%,44rem)] flex-col gap-0.5 self-center px-2 py-0.5 text-[0.6875rem] leading-5 text-muted-foreground/60">
<span className="flex items-center gap-1.5">
<Codicon className="shrink-0 text-muted-foreground/55" name="terminal" size="0.75rem" />
<span className="wrap-anywhere">{headline}</span>
</span>
{detail && (
<details className="pl-[1.3125rem]">
<summary className="cursor-pointer select-none text-muted-foreground/45 hover:text-muted-foreground/70">
output
</summary>
<pre className="mt-0.5 max-h-48 overflow-auto whitespace-pre-wrap font-mono text-[0.625rem] leading-4 text-muted-foreground/55">
{detail}
</pre>
</details>
)}
</div>
)
}
const UserMessage: FC<{
onCancel?: () => Promise<void> | void
onRestoreToMessage?: (messageId: string) => Promise<void> | void
}> = ({ onCancel, onRestoreToMessage }) => {
}> = ({ onCancel }) => {
const { t } = useI18n()
const copy = t.assistant.thread
const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false)
const messageId = useAuiState(s => s.message.id)
const content = useAuiState(s => s.message.content)
const messageText = messageContentText(content)
@@ -875,10 +772,8 @@ const UserMessage: FC<{
// changes, not on every frame while the outer max-height animates open.
const clampInnerRef = useRef<HTMLDivElement | null>(null)
const [bodyClamped, setBodyClamped] = useState(false)
const lastClampHeightRef = useRef(-1)
const lineHeightRef = useRef(0)
const measureClamp = useCallback((entries: readonly ResizeObserverEntry[]) => {
const measureClamp = useCallback(() => {
const inner = clampInnerRef.current
const outer = inner?.parentElement
@@ -886,105 +781,68 @@ const UserMessage: FC<{
return
}
// Prefer the size the ResizeObserver already computed — reading
// `scrollHeight` outside RO timing forces a synchronous layout, and with
// many user bubbles observed at once those reads interleave with the
// style write below into a read-write-read reflow cascade.
const entryHeight = entries.find(entry => entry.target === inner)?.borderBoxSize?.[0]?.blockSize
const fullHeight = Math.ceil(entryHeight ?? inner.scrollHeight)
if (fullHeight === lastClampHeightRef.current) {
return
}
lastClampHeightRef.current = fullHeight
// Line-height is stable for the life of the bubble (font settings don't
// change under it) — resolve the computed style once.
if (!lineHeightRef.current) {
const styles = getComputedStyle(inner)
lineHeightRef.current = parseFloat(styles.lineHeight) || 1.5 * parseFloat(styles.fontSize) || 20
}
const styles = getComputedStyle(inner)
const lineHeight = parseFloat(styles.lineHeight) || 1.5 * parseFloat(styles.fontSize) || 20
const fullHeight = inner.scrollHeight
outer.style.setProperty('--human-msg-full', `${fullHeight}px`)
setBodyClamped(fullHeight > lineHeightRef.current * 2 + 1)
setBodyClamped(fullHeight > lineHeight * 2 + 1)
}, [])
useResizeObserver(measureClamp, clampInnerRef)
// Injected background-process notification, not a human prompt — render the
// compact system-style notice (after all hooks above have run).
if (PROCESS_NOTIFICATION_RE.test(messageText.trim())) {
return (
<MessagePrimitive.Root
className="flex w-full min-w-0 flex-col items-stretch"
data-role="user"
data-slot="aui_user-message-root"
>
<ProcessNotificationNote text={messageText.trim()} />
</MessagePrimitive.Root>
)
}
const hasBody = messageText.trim().length > 0
const isLatestUser = messageId === latestUserId
const showStop = isLatestUser && threadRunning && Boolean(onCancel)
// Restore (re-run this exact prompt) is available everywhere the Stop button
// isn't — including mid-stream on older prompts, since the action interrupts
// the live turn before rewinding.
const showRestore = !showStop && Boolean(onRestoreToMessage) && hasBody
const showRestore = !isLatestUser && !threadRunning
const bubbleClassName = cn(
USER_BUBBLE_BASE_CLASS,
'cursor-pointer pr-9 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 transition-colors',
'border-(--ui-stroke-tertiary) hover:border-(--ui-stroke-secondary)'
'border-(--ui-stroke-tertiary) pr-9 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 transition-colors',
!threadRunning && 'cursor-pointer hover:border-(--ui-stroke-secondary)'
)
const bubbleContent = hasBody && (
// Render the user's text through a minimal markdown pipeline:
// backtick `code` and ``` fenced ``` blocks, with directive chips
// (`@file:` etc.) still resolved inside the plain-text spans.
<div className="sticky-human-clamp" data-clamped={bodyClamped ? 'true' : undefined}>
{/* Match the edit composer's collapsed line box (min-h-[1.25rem]) so
clicking to edit can't grow the bubble by a sub-pixel and reflow the
turn 1px. */}
<div className="min-h-[1.25rem]" ref={clampInnerRef}>
<UserMessageText className="wrap-anywhere" text={messageText} />
</div>
</div>
const bubbleContent = (
<>
{attachmentRefs.length > 0 && (
<span className="-mx-1 flex flex-wrap gap-1 border-b border-border/45 pb-1.5">
<DirectiveContent text={attachmentRefs.join(' ')} />
</span>
)}
{hasBody && (
// Render the user's text through a minimal markdown pipeline:
// backtick `code` and ``` fenced ``` blocks, with directive chips
// (`@file:` etc.) still resolved inside the plain-text spans.
<div className="sticky-human-clamp" data-clamped={bodyClamped ? 'true' : undefined}>
<div ref={clampInnerRef}>
<UserMessageText className="wrap-anywhere" text={messageText} />
</div>
</div>
)}
</>
)
return (
<MessagePrimitive.Root asChild>
<StickyHumanMessageContainer
attachments={
// Attachments live BELOW the sticky bubble in normal flow, so they
// scroll away behind the pinned bubble instead of riding along with
// it. Image refs render as thumbnails, file refs as chips; no border.
attachmentRefs.length > 0 ? (
<div className="flex flex-wrap gap-1 -mt-3 mb-2">
<DirectiveContent text={attachmentRefs.join(' ')} />
</div>
) : null
}
>
<StickyHumanMessageContainer>
<ActionBarPrimitive.Root className="relative w-full max-w-full" data-slot="aui_user-bubble-actions">
<div className="human-message-with-todos-wrapper flex w-full flex-col gap-0">
<div className="relative w-full">
{/* Always editable — clicking opens the edit composer even while a
turn streams; sending the edit reverts (interrupt + rewind). */}
<ActionBarPrimitive.Edit asChild>
<button
aria-label={copy.editMessage}
className={bubbleClassName}
onClick={() => triggerHaptic('selection')}
onPointerDown={() => notifyThreadEditOpen()}
title={copy.editMessage}
type="button"
>
{bubbleContent}
</button>
</ActionBarPrimitive.Edit>
{threadRunning ? (
<div className={bubbleClassName}>{bubbleContent}</div>
) : (
<ActionBarPrimitive.Edit asChild>
<button
aria-label={copy.editMessage}
className={bubbleClassName}
onClick={() => triggerHaptic('selection')}
title={copy.editMessage}
type="button"
>
{bubbleContent}
</button>
</ActionBarPrimitive.Edit>
)}
{(showStop || showRestore) && (
<div className="pointer-events-none absolute right-2 bottom-2 z-10 flex items-center justify-center opacity-0 transition-opacity group-hover/user-message:opacity-100 group-focus-within/user-message:opacity-100">
{showStop ? (
@@ -1002,20 +860,13 @@ const UserMessage: FC<{
{StopGlyph}
</button>
) : (
<button
aria-label={copy.restoreCheckpoint}
className={cn('pointer-events-auto size-6', USER_ACTION_ICON_BUTTON_CLASS)}
onClick={event => {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
setRestoreConfirmOpen(true)
}}
title={copy.restoreFromHere}
type="button"
<span
aria-hidden="true"
className="flex size-6 items-center justify-center rounded-md text-(--ui-text-tertiary)"
title={copy.editableCheckpoint}
>
<Codicon name="discard" size="0.875rem" />
</button>
</span>
)}
</div>
)}
@@ -1043,17 +894,6 @@ const UserMessage: FC<{
</BranchPickerPrimitive.Root>
</div>
</ActionBarPrimitive.Root>
{showRestore && (
<ConfirmDialog
confirmLabel={copy.restoreConfirm}
description={copy.restoreBody}
destructive
onClose={() => setRestoreConfirmOpen(false)}
onConfirm={() => onRestoreToMessage?.(messageId)}
open={restoreConfirmOpen}
title={copy.restoreTitle}
/>
)}
</StickyHumanMessageContainer>
</MessagePrimitive.Root>
)
@@ -1168,8 +1008,6 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
const at = useAtCompletions({ cwd, gateway, sessionId })
const slash = useSlashCompletions({ gateway })
useEffect(() => () => notifyThreadEditClose(), [])
const focusEditor = useCallback(() => {
const editor = editorRef.current
@@ -1423,10 +1261,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
}
const remote = $connection.get()?.mode === 'remote'
const requestGateway = <T,>(method: string, params?: Record<string, unknown>) =>
gateway.request<T>(method, params)
const requestGateway = <T,>(method: string, params?: Record<string, unknown>) => gateway.request<T>(method, params)
const refs: InlineRefInput[] = []
for (const candidate of osDrops) {
@@ -1695,8 +1530,9 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
aria-label={copy.editMessage}
autoCapitalize="off"
autoCorrect="off"
autoFocus
className={cn(
'ui-prompt-input-editor__input max-h-48 w-full resize-none bg-transparent p-0 pr-7 text-[length:var(--conversation-text-font-size)] text-foreground/95 outline-none',
'ui-prompt-input-editor__input max-h-48 w-full resize-none bg-transparent p-0 pr-7 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 outline-none',
'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60',
'**:data-ref-text:cursor-default',
expanded ? 'min-h-16' : 'min-h-[1.25rem]'

View File

@@ -0,0 +1,109 @@
import { type FC } from 'react'
import { Checkbox } from '@/components/ui/checkbox'
import { Loader2Icon } from '@/lib/icons'
import { parseTodos, type TodoItem, type TodoStatus } from '@/lib/todos'
import { cn } from '@/lib/utils'
export function todosFromMessageContent(content: unknown): TodoItem[] {
if (!Array.isArray(content)) {
return []
}
let latest: null | TodoItem[] = null
for (const part of content) {
if (!part || typeof part !== 'object') {
continue
}
const row = part as Record<string, unknown>
if (row.type !== 'tool-call' || row.toolName !== 'todo') {
continue
}
const parsed = parseTodos(row.result) ?? parseTodos(row.args)
if (parsed !== null) {
latest = parsed
}
}
return latest ?? []
}
const headerLabel = (todos: readonly TodoItem[]): string =>
todos.find(t => t.status === 'in_progress')?.content ??
todos.find(t => t.status === 'pending')?.content ??
todos.at(-1)?.content ??
'Tasks'
const Checkmark: FC<{ status: TodoStatus; label: string }> = ({ status, label }) => {
if (status === 'in_progress') {
return (
<span
aria-label={`In progress: ${label}`}
className="grid size-[1.1rem] shrink-0 place-items-center rounded-full border border-ring/65 bg-[color-mix(in_srgb,var(--dt-ring)_14%,transparent)]"
>
<Loader2Icon className="size-3 animate-spin text-ring" />
</span>
)
}
const checked = status === 'completed'
return (
<Checkbox
aria-label={label}
checked={checked}
className={cn(
'size-[1.1rem] shrink-0 rounded-full border-border/80 pointer-events-none disabled:cursor-default disabled:opacity-100',
checked &&
'data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground [&_[data-slot=checkbox-indicator]_svg]:size-3',
status === 'cancelled' && 'border-muted-foreground/40'
)}
disabled
/>
)
}
export const HoistedTodoPanel: FC<{ todos: TodoItem[] }> = ({ todos }) => {
if (!todos.length) {
return null
}
const label = headerLabel(todos)
return (
<section
className="mt-1 mb-3 inline-block w-fit max-w-full overflow-hidden rounded-2xl border border-border/70 bg-card align-top shadow-[0_1px_2px_0_hsl(var(--foreground)/0.04),0_1px_4px_-1px_hsl(var(--foreground)/0.06)]"
data-slot="aui_todo-hoisted"
>
<header className="px-3 pt-3 pb-2">
<span
className="block max-w-full truncate text-[0.85rem] font-semibold leading-tight tracking-tight text-foreground"
title={label}
>
{label}
</span>
</header>
<ul className="grid min-w-0 gap-0.5 px-3 pb-3">
{todos.map(todo => (
<li
// Active row at full presence; everything else fades. Opacity on
// the row so the checkbox glyph dims with the text.
className={cn(
'flex min-w-0 items-center gap-3 py-1.5 transition-opacity',
todo.status === 'in_progress' ? 'opacity-100' : 'opacity-45'
)}
key={todo.id}
>
<Checkmark label={todo.content} status={todo.status} />
<span className="min-w-0 wrap-anywhere text-[0.8rem] leading-[1.2rem] text-foreground">{todo.content}</span>
</li>
))}
</ul>
</section>
)
}

View File

@@ -1,10 +1,9 @@
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { cleanup, render, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { clearAllPrompts, setApprovalRequest } from '@/store/prompts'
import { $activeSessionId } from '@/store/session'
import { clearDismissedToolRows } from '@/store/tool-dismiss'
import { $toolDisclosureStates } from '@/store/tool-view'
import { Thread } from './thread'
@@ -105,84 +104,6 @@ function groupedPendingMessage(): ThreadMessage {
} as ThreadMessage
}
function pendingOnlyMessage(): ThreadMessage {
return {
id: 'assistant-pending-only',
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'term-only',
toolName: 'terminal',
args: { command: 'sleep 10' },
argsText: JSON.stringify({ command: 'sleep 10' })
}
],
status: { type: 'running' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function completedOnlyMessage(): ThreadMessage {
return {
id: 'assistant-completed-only',
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'read-only',
toolName: 'read_file',
args: { path: '/etc/hosts' },
argsText: JSON.stringify({ path: '/etc/hosts' }),
result: { content: '127.0.0.1 localhost' }
}
],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function failedOnlyMessage(): ThreadMessage {
return {
id: 'assistant-failed-only',
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'term-failed',
toolName: 'terminal',
args: { command: 'exit 1' },
argsText: JSON.stringify({ command: 'exit 1' }),
isError: true,
result: { stderr: 'boom' }
}
],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function GroupHarness({ message }: { message: ThreadMessage }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [message],
@@ -201,14 +122,12 @@ beforeEach(() => {
clearAllPrompts()
$activeSessionId.set('sess-1')
$toolDisclosureStates.set({})
clearDismissedToolRows()
})
afterEach(() => {
cleanup()
clearAllPrompts()
$activeSessionId.set(null)
clearDismissedToolRows()
})
describe('flat tool list approval surfacing', () => {
@@ -236,64 +155,4 @@ describe('flat tool list approval surfacing', () => {
expect(bar?.closest('[hidden]')).toBeNull()
})
})
it('lets completed tool rows be dismissed', async () => {
const { container } = render(<GroupHarness message={completedOnlyMessage()} />)
const dismiss = await screen.findByLabelText('Dismiss')
expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(1)
fireEvent.click(dismiss)
await waitFor(() => {
expect(screen.queryByLabelText('Dismiss')).toBeNull()
})
})
it('keeps a dismissed row hidden after a remount (virtualization)', async () => {
// The thread virtualizes, so a row's component unmounts/remounts as it
// scrolls. Dismissal must persist across that — component-local state would
// forget it and the row would pop back. Simulate the remount by unmounting
// and rendering the same message fresh.
const first = render(<GroupHarness message={completedOnlyMessage()} />)
fireEvent.click(await screen.findByLabelText('Dismiss'))
await waitFor(() => {
expect(screen.queryByLabelText('Dismiss')).toBeNull()
})
first.unmount()
const { container } = render(<GroupHarness message={completedOnlyMessage()} />)
await waitFor(() => {
expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(0)
})
expect(screen.queryByLabelText('Dismiss')).toBeNull()
})
it('lets failed tool rows be dismissed', async () => {
render(<GroupHarness message={failedOnlyMessage()} />)
const dismiss = await screen.findByLabelText('Dismiss')
fireEvent.click(dismiss)
await waitFor(() => {
expect(screen.queryByLabelText('Dismiss')).toBeNull()
})
})
it('does not show dismiss for pending tool rows', async () => {
const { container } = render(<GroupHarness message={pendingOnlyMessage()} />)
await waitFor(() => {
expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(0)
})
expect(screen.queryByLabelText('Dismiss')).toBeNull()
})
})

View File

@@ -84,19 +84,6 @@ describe('PendingToolApproval', () => {
expect($approvalRequest.get()).toBeNull()
})
it('reveals the full command inline when the Command toggle is clicked', () => {
const longCommand = 'python -c "' + 'x'.repeat(400) + '"'
setRequest(longCommand)
render(<PendingToolApproval part={part('terminal')} />)
// Collapsed by default: the full command is not in the DOM yet.
expect(screen.queryByText(longCommand)).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /Command/ }))
expect(screen.getByText(longCommand)).toBeTruthy()
})
it('sends choice "deny" on Reject', async () => {
const request = mockGateway()
setRequest()

View File

@@ -16,7 +16,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { ChevronDown, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $gateway } from '@/store/gateway'
import { notifyError } from '@/store/notifications'
import { $approvalRequest, type ApprovalRequest, clearApprovalRequest } from '@/store/prompts'
@@ -61,15 +60,9 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
// "Always allow" persists the pattern to ~/.hermes/config.yaml permanently, so
// it goes through a confirm step rather than firing straight from the menu.
const [confirmAlways, setConfirmAlways] = useState(false)
// The pending tool row only shows a single truncated line of the command, and
// a pending row can't be expanded (no result yet), so the full command was
// previously only reachable via the "Always allow" modal. Let the user reveal
// it inline instead — "expand, Run" (2 clicks) rather than the modal dance.
const [showCommand, setShowCommand] = useState(false)
const busy = submitting !== null
// false when the backend won't honor a permanent allow (tirith warning) → hide "Always allow".
const allowPermanent = request.allowPermanent !== false
const hasCommand = request.command.trim().length > 0
const respond = useCallback(
async (choice: ApprovalChoice) => {
@@ -126,89 +119,70 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
}, [confirmAlways, respond])
return (
<div className="mt-1 ps-5" data-slot="tool-approval-inline">
<div className="flex items-center gap-2.5">
<div className="inline-flex h-6 items-stretch overflow-hidden rounded-md border border-primary/25 bg-primary/10 text-primary">
<Button
className="h-full gap-1 rounded-none px-2 text-xs font-medium text-primary hover:bg-primary/15 hover:text-primary"
disabled={busy}
onClick={() => void respond('once')}
size="xs"
variant="ghost"
>
{submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : copy.run}
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}
</Button>
<span aria-hidden className="w-px self-stretch bg-primary/20" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={copy.moreOptions}
className="h-full w-5 rounded-none px-0 text-primary hover:bg-primary/15 hover:text-primary"
disabled={busy}
size="xs"
variant="ghost"
>
<ChevronDown className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-44">
<DropdownMenuItem onSelect={() => void respond('session')}>{copy.allowSession}</DropdownMenuItem>
{allowPermanent && (
<DropdownMenuItem
onSelect={() => {
// Defer one tick so the menu fully unmounts before the dialog
// mounts — otherwise Radix's focus-return races the dialog and
// dismisses it via onInteractOutside.
setTimeout(() => setConfirmAlways(true), 0)
}}
>
{copy.alwaysAllowMenu}
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => void respond('deny')} variant="destructive">
{copy.reject}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="mt-1 flex items-center gap-2.5 ps-5" data-slot="tool-approval-inline">
<div className="inline-flex h-6 items-stretch overflow-hidden rounded-md border border-primary/25 bg-primary/10 text-primary">
<Button
className="h-6 gap-1.5 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground"
className="h-full gap-1 rounded-none px-2 text-xs font-medium text-primary hover:bg-primary/15 hover:text-primary"
disabled={busy}
onClick={() => void respond('deny')}
onClick={() => void respond('once')}
size="xs"
variant="ghost"
>
{submitting === 'deny' ? <Loader2 className="size-3 animate-spin" /> : copy.reject}
{submitting !== 'deny' && <span className="text-[0.625rem] opacity-55">Esc</span>}
{submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : copy.run}
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}
</Button>
{hasCommand && (
<Button
aria-expanded={showCommand}
className="h-6 gap-1 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground"
onClick={() => setShowCommand(value => !value)}
size="xs"
variant="ghost"
>
{copy.command}
<ChevronDown className={cn('size-3 transition-transform', showCommand && 'rotate-180')} />
</Button>
)}
<span aria-hidden className="w-px self-stretch bg-primary/20" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={copy.moreOptions}
className="h-full w-5 rounded-none px-0 text-primary hover:bg-primary/15 hover:text-primary"
disabled={busy}
size="xs"
variant="ghost"
>
<ChevronDown className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-44">
<DropdownMenuItem onSelect={() => void respond('session')}>{copy.allowSession}</DropdownMenuItem>
{allowPermanent && (
<DropdownMenuItem
onSelect={() => {
// Defer one tick so the menu fully unmounts before the dialog
// mounts — otherwise Radix's focus-return races the dialog and
// dismisses it via onInteractOutside.
setTimeout(() => setConfirmAlways(true), 0)
}}
>
{copy.alwaysAllowMenu}
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => void respond('deny')} variant="destructive">
{copy.reject}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{showCommand && hasCommand && (
<pre className="mt-1.5 max-h-40 overflow-auto whitespace-pre-wrap break-words rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-2.5 py-1.5 font-mono text-xs leading-snug text-foreground">
{request.command.trim()}
</pre>
)}
<Button
className="h-6 gap-1.5 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground"
disabled={busy}
onClick={() => void respond('deny')}
size="xs"
variant="ghost"
>
{submitting === 'deny' ? <Loader2 className="size-3 animate-spin" /> : copy.reject}
{submitting !== 'deny' && <span className="text-[0.625rem] opacity-55">Esc</span>}
</Button>
<Dialog onOpenChange={setConfirmAlways} open={confirmAlways}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{copy.alwaysTitle}</DialogTitle>
<DialogDescription>{copy.alwaysDescription(request.description)}</DialogDescription>
<DialogDescription>
{copy.alwaysDescription(request.description)}
</DialogDescription>
</DialogHeader>
{request.command.trim() && (

View File

@@ -12,20 +12,16 @@ import { DiffLines } from '@/components/chat/diff-lines'
import { DisclosureRow } from '@/components/chat/disclosure-row'
import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { BrailleSpinner } from '@/components/ui/braille-spinner'
import { CopyButton } from '@/components/ui/copy-button'
import { FadeText } from '@/components/ui/fade-text'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { ToolIcon } from '@/components/ui/tool-icon'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link'
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
import { $toolInlineDiffs } from '@/store/tool-diffs'
import { $toolRowDismissed, dismissToolRow } from '@/store/tool-dismiss'
import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view'
import { PendingToolApproval } from './tool-approval'
@@ -104,7 +100,7 @@ function rawTechnicalTrace(args: unknown, result: unknown): string {
function statusGlyph(status: ToolStatus, copy: ToolStatusCopy): ReactNode {
if (status === 'running') {
return (
<GlyphSpinner
<BrailleSpinner
ariaLabel={copy.statusRunning}
className="size-3.5 shrink-0 text-[0.95rem] text-(--ui-text-tertiary)"
spinner="breathe"
@@ -118,7 +114,10 @@ function statusGlyph(status: ToolStatus, copy: ToolStatusCopy): ReactNode {
if (status === 'warning') {
return (
<AlertCircle aria-label={copy.statusRecovered} className="size-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
<AlertCircle
aria-label={copy.statusRecovered}
className="size-3.5 shrink-0 text-amber-600 dark:text-amber-400"
/>
)
}
@@ -197,16 +196,13 @@ function useDisclosureOpen(disclosureId: string, fallbackOpen = false): boolean
function ToolEntry({ part }: ToolEntryProps) {
const { t } = useI18n()
const copy = t.assistant.tool
const statusCopy = t.statusStack
const messageId = useAuiState(s => s.message.id)
const messageRunning = useAuiState(selectMessageRunning)
const embedded = useContext(ToolEmbedContext)
const toolViewMode = useStore($toolViewMode)
const disclosureId = `tool-entry:${messageId}:${toolPartDisclosureId(part)}`
const dismissed = useStore($toolRowDismissed(disclosureId))
const open = useDisclosureOpen(disclosureId)
const isPending = messageRunning && part.result === undefined
const canDismiss = !isPending && !embedded
// Only animate entries that mount while their message is actively
// streaming — historical sessions mount with `messageRunning === false`,
// so they paint statically without a settle cascade. The wrapping group
@@ -289,33 +285,9 @@ function ToolEntry({ part }: ToolEntryProps) {
// the disclosure caret hard to hit. Copy now lives in the expanded body's
// top-right, where it can't fight the caret for the right edge.
const trailing =
isPending && !embedded ? <ActivityTimerText className={TOOL_HEADER_DURATION_CLASS} seconds={elapsed} /> : undefined
// Once a turn has settled, a hover/focus-revealed dismiss lets the user clear
// a completed/failed row that would otherwise sit at the tail of the chat.
// It goes in the in-flow `action` slot (not `trailing`) so it can't overlap
// the disclosure caret's hit-target — see the comment above `trailing`.
const dismissAction = canDismiss ? (
<Tip label={statusCopy.dismiss}>
<Button
aria-label={statusCopy.dismiss}
className="size-5 rounded-md text-(--ui-text-tertiary) opacity-0 transition-opacity hover:text-(--ui-text-primary) hover:opacity-100 group-hover/disclosure-row:opacity-80 group-focus-within/disclosure-row:opacity-80"
onClick={event => {
event.stopPropagation()
dismissToolRow(disclosureId)
}}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="close" size="0.75rem" />
</Button>
</Tip>
) : undefined
if (dismissed) {
return null
}
isPending && !embedded ? (
<ActivityTimerText className={TOOL_HEADER_DURATION_CLASS} seconds={elapsed} />
) : undefined
return (
<div
@@ -328,7 +300,6 @@ function ToolEntry({ part }: ToolEntryProps) {
>
<div className={cn(open && 'border-b border-(--ui-stroke-tertiary) px-2 py-1.5')}>
<DisclosureRow
action={dismissAction}
onToggle={hasExpandableContent ? () => setToolDisclosureOpen(disclosureId, !open) : undefined}
open={open}
trailing={trailing}

View File

@@ -1,31 +0,0 @@
import { cn } from '@/lib/utils'
/**
* The composer surface and everything docked to it (slash·@ popover, `?` help)
* paint ONE shared `--composer-fill` var. The state ladder (rest / scrolled /
* focused / drawer-open) lives in styles.css on `[data-slot='composer-root']`,
* so the two layers can never disagree — drawer-open forces an opaque fill via
* `:has()`, because translucent glass sampling different backdrops (thread vs
* fade gradient) renders as different colors even with identical tints.
*/
export const composerFill = 'bg-(--composer-fill)'
/** Backdrop treatment for the composer input surface. Harmless when the fill
* goes opaque (drawer open) — nothing shows through to blur. */
export const composerSurfaceGlass = cn(
'backdrop-blur-[0.75rem] backdrop-saturate-[1.12] [-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]',
'transition-[background-color] duration-150 ease-out'
)
const composerDockEdge = (edge: 'bottom' | 'top') =>
cn('border border-border/65', edge === 'top' ? 'rounded-t-2xl border-b-0' : 'rounded-b-2xl border-t-0')
/** Glassy docked card — the status stack / queue. Paints the SAME
* `--composer-fill` as the surface, so rest / scrolled / focused / drawer-open
* all match the composer by construction. */
export const composerDockCard = (edge: 'bottom' | 'top' = 'top') =>
cn(composerDockEdge(edge), composerFill, composerSurfaceGlass)
/** Fused docked card — completion drawers. Shares `--composer-fill` with the
* composer surface, which goes opaque while a drawer is open. */
export const composerFusedDockCard = (edge: 'bottom' | 'top' = 'top') => cn(composerDockEdge(edge), composerFill)

View File

@@ -14,19 +14,12 @@ import { cn } from '@/lib/utils'
// title text, NOT the full row — and reaches just past the chevron with
// `-mx-1.5 px-1.5` so it reads as a soft hit-target rather than a slab
// stretching to the message edge.
// - `trailing` overlays the right edge (absolute) and must stay
// non-interactive (e.g. a duration timer) — an opacity-0-but-clickable
// control there steals clicks from the caret. Interactive controls go in
// `action`, which lays out *in flow* at the far right so it never sits on
// top of the caret's hit-target, no matter how long the title is.
export function DisclosureRow({
action,
children,
onToggle,
open,
trailing
}: {
action?: ReactNode
children: ReactNode
onToggle?: () => void
open: boolean
@@ -62,11 +55,6 @@ export function DisclosureRow({
</span>
)}
</button>
{action && (
<span className="ml-auto flex h-(--conversation-line-height) shrink-0 items-center self-start pl-1.5">
{action}
</span>
)}
{trailing && (
<span className="absolute right-1 top-0 flex h-(--conversation-line-height) items-center">{trailing}</span>
)}

View File

@@ -0,0 +1,19 @@
'use client'
import { createContext, type ReactNode, useContext, useMemo, useState } from 'react'
type Value = {
isPending: boolean
setPending: (pending: boolean) => void
}
const Ctx = createContext<Value | null>(null)
export function GeneratedImageProvider({ children }: { children: ReactNode }) {
const [isPending, setPending] = useState(false)
const value = useMemo(() => ({ isPending, setPending }), [isPending])
return <Ctx.Provider value={value}>{children}</Ctx.Provider>
}
export const useGeneratedImageContext = () => useContext(Ctx)

View File

@@ -1,174 +0,0 @@
'use client'
import { type FC, useEffect, useState } from 'react'
import { DiffusionCanvas } from '@/components/chat/image-generation-placeholder'
import { ImageActionButton, ImageLightbox } from '@/components/chat/zoomable-image'
import { useImageDownload } from '@/hooks/use-image-download'
import { useI18n } from '@/i18n'
import { generatedImageFromResult } from '@/lib/generated-images'
import { filePathFromMediaPath, gatewayMediaDataUrl, isRemoteGateway, mediaExternalUrl, mediaName } from '@/lib/media'
import { cn } from '@/lib/utils'
// Aspect hint from the tool args sizes the frame *before* the image loads, so
// the placeholder and the resolved image occupy the same box — no layout shift.
const ASPECT_HINTS: Record<string, number> = {
landscape: 16 / 9,
square: 1,
portrait: 9 / 16
}
function hintedRatio(aspectRatio?: string): number {
return ASPECT_HINTS[String(aspectRatio ?? '').toLowerCase().trim()] ?? ASPECT_HINTS.landscape
}
function isInlineSrc(path: string): boolean {
return /^(?:https?|data):/i.test(path)
}
async function resolveImageSrc(path: string): Promise<string> {
if (isInlineSrc(path)) {
return path
}
if (window.hermesDesktop && isRemoteGateway()) {
return gatewayMediaDataUrl(path)
}
if (!window.hermesDesktop?.readFileDataUrl) {
return mediaExternalUrl(path)
}
return window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path))
}
export const GeneratedImage: FC<{ aspectRatio?: string; result?: unknown }> = ({ aspectRatio, result }) => {
const { t } = useI18n()
const copy = t.desktop
const image = result === undefined ? null : generatedImageFromResult(result)
const pending = result === undefined
const [ratio, setRatio] = useState(() => hintedRatio(aspectRatio))
const [src, setSrc] = useState(() => (image && isInlineSrc(image) ? image : ''))
const [loaded, setLoaded] = useState(false)
const [canvasGone, setCanvasGone] = useState(false)
const [failed, setFailed] = useState(false)
const [lightboxOpen, setLightboxOpen] = useState(false)
const { download, saving } = useImageDownload(src)
useEffect(() => setRatio(hintedRatio(aspectRatio)), [aspectRatio])
// Resolve the deliverable path (local read / gateway proxy / remote URL). The
// <img> stays mounted under the placeholder and only fades in once it decodes,
// so the frame keeps its hinted size and never jumps.
useEffect(() => {
let cancelled = false
setFailed(false)
setLoaded(false)
setCanvasGone(false)
setSrc(image && isInlineSrc(image) ? image : '')
if (!image || isInlineSrc(image)) {
return
}
void resolveImageSrc(image)
.then(resolved => !cancelled && setSrc(resolved))
.catch(() => !cancelled && setFailed(true))
return () => {
cancelled = true
}
}, [image])
// Completed but no usable image (generation failed): the agent's prose carries
// the explanation, so render nothing here.
if (!pending && !image) {
return null
}
if (failed && image) {
return (
<a
className="mt-2 inline-block font-semibold text-foreground underline underline-offset-4 decoration-current/20 wrap-anywhere"
href="#"
onClick={event => {
event.preventDefault()
void window.hermesDesktop?.openExternal(mediaExternalUrl(image))
}}
>
{copy.openImage}: {mediaName(image)}
</a>
)
}
return (
<>
<span
aria-label={pending ? t.assistant.tool.renderingImage : undefined}
aria-live={pending ? 'polite' : undefined}
className="group/image relative block max-w-full overflow-hidden rounded-2xl transition-[width,height] duration-300 ease-out"
data-slot="aui_generated-image"
role={pending ? 'status' : undefined}
style={{
aspectRatio: ratio,
// Width is capped so the derived height (width / ratio) never exceeds
// --image-preview-height; the box then matches the image exactly with
// no letterboxing.
width: `min(calc(var(--image-preview-height) * ${ratio}), var(--image-preview-max-width), 100%)`
}}
>
{!canvasGone && (
<div
className={cn('absolute inset-0 transition-opacity duration-500 ease-out', loaded && 'opacity-0')}
onTransitionEnd={() => loaded && setCanvasGone(true)}
>
<DiffusionCanvas />
</div>
)}
{src && (
<button
className="absolute inset-0 block size-full cursor-zoom-in"
onClick={() => setLightboxOpen(true)}
title={copy.openImage}
type="button"
>
<img
alt="Generated image"
className={cn(
'absolute inset-0 size-full object-contain opacity-0 transition-opacity duration-500 ease-out',
loaded && 'opacity-100'
)}
draggable={false}
onError={() => setFailed(true)}
onLoad={event => {
const { naturalHeight, naturalWidth } = event.currentTarget
if (naturalWidth && naturalHeight) {
setRatio(naturalWidth / naturalHeight)
}
setLoaded(true)
}}
src={src}
/>
</button>
)}
{loaded && src && (
<ImageActionButton className="group-hover/image:opacity-100" copy={copy} onClick={download} saving={saving} />
)}
</span>
{src && (
<ImageLightbox
alt="Generated image"
copy={copy}
onClick={download}
onOpenChange={setLightboxOpen}
open={lightboxOpen}
saving={saving}
src={src}
/>
)}
</>
)
}

View File

@@ -1,6 +1,7 @@
import { type FC, useCallback, useEffect, useRef } from 'react'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { useI18n } from '@/i18n'
type Rgb = { r: number; g: number; b: number }
@@ -23,26 +24,19 @@ const smoothstep = (edge0: number, edge1: number, value: number) => {
}
const parseColor = (value: string, fallback: Rgb): Rgb => {
const v = value.trim()
const hex = v.match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i)
const hex = value.trim().match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i)
if (hex) {
return { r: Number.parseInt(hex[1], 16), g: Number.parseInt(hex[2], 16), b: Number.parseInt(hex[3], 16) }
return {
r: Number.parseInt(hex[1], 16),
g: Number.parseInt(hex[2], 16),
b: Number.parseInt(hex[3], 16)
}
}
const rgb = v.match(/rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/i)
const rgb = value.trim().match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i)
if (rgb) {
return { r: Number(rgb[1]), g: Number(rgb[2]), b: Number(rgb[3]) }
}
// Chromium serialises `color-mix(in srgb, …)` as `color(srgb r g b / a)` with 01 floats.
const srgb = v.match(/color\(\s*srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/i)
return srgb
? { r: Math.round(Number(srgb[1]) * 255), g: Math.round(Number(srgb[2]) * 255), b: Math.round(Number(srgb[3]) * 255) }
: fallback
return rgb ? { r: Number(rgb[1]), g: Number(rgb[2]), b: Number(rgb[3]) } : fallback
}
const mix = (a: Rgb, b: Rgb, amount: number): Rgb => ({
@@ -88,22 +82,17 @@ const fbm = (x: number, y: number) => {
return value
}
type Theme = Record<keyof typeof FALLBACKS, Rgb>
const readTheme = () => {
const styles = getComputedStyle(document.documentElement)
const TOKENS = Object.keys(FALLBACKS) as (keyof typeof FALLBACKS)[]
// `--dt-*` resolve through `var()` chains into `color-mix()`, which
// getPropertyValue hands back verbatim — unreadable. Bouncing each token through
// a probe's `color` lets the browser compute it to a concrete color we can
// parse, so the canvas tracks the live theme instead of a hardcoded fallback.
const readTheme = (probe: HTMLElement): Theme =>
Object.fromEntries(
TOKENS.map(key => {
probe.style.color = `var(--dt-${key})`
return [key, parseColor(getComputedStyle(probe).color, FALLBACKS[key])]
})
) as Theme
return {
card: parseColor(styles.getPropertyValue('--dt-card'), FALLBACKS.card),
muted: parseColor(styles.getPropertyValue('--dt-muted'), FALLBACKS.muted),
foreground: parseColor(styles.getPropertyValue('--dt-foreground'), FALLBACKS.foreground),
primary: parseColor(styles.getPropertyValue('--dt-primary'), FALLBACKS.primary),
ring: parseColor(styles.getPropertyValue('--dt-ring'), FALLBACKS.ring)
}
}
const fitCanvas = (canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) => {
const rect = canvas.getBoundingClientRect()
@@ -118,13 +107,8 @@ const fitCanvas = (canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) =>
return { width, height }
}
const drawAsciiDiffusion = (
ctx: CanvasRenderingContext2D,
theme: Theme,
width: number,
height: number,
time: number
) => {
const drawAsciiDiffusion = (ctx: CanvasRenderingContext2D, width: number, height: number, time: number) => {
const theme = readTheme()
const bg = ctx.createLinearGradient(0, 0, width, height)
bg.addColorStop(0, rgba(mix(theme.card, theme.primary, 0.08), 1))
bg.addColorStop(0.54, rgba(mix(theme.card, theme.muted, 0.68), 1))
@@ -141,9 +125,6 @@ const drawAsciiDiffusion = (
const cellHeight = fontSize * 1.28
const cols = Math.ceil(width / cellWidth)
const rows = Math.ceil(height / cellHeight)
// Normalise both axes by the shorter side so the radial bloom stays a circle
// (not a squished ellipse) when the frame isn't landscape.
const short = Math.min(width, height)
const centerX = 0.53 + Math.sin(time * 0.055) * 0.02
const centerY = 0.5 + Math.cos(time * 0.048) * 0.02
const timestep = Math.floor(time * 1.15)
@@ -157,10 +138,10 @@ const drawAsciiDiffusion = (
for (let col = -1; col <= cols + 1; col += 1) {
const x = col * cellWidth + cellWidth * 0.5
const y = row * cellHeight + cellHeight * 0.5
const sx = (x - centerX * width) / short
const sy = (y - centerY * height) / short
const dx = sx * 1.2
const dy = sy * 0.95
const nx = x / width
const ny = y / height
const dx = (nx - centerX) * 1.2
const dy = (ny - centerY) * 0.95
const radius = Math.hypot(dx, dy)
const angle = Math.atan2(dy, dx)
@@ -171,7 +152,7 @@ const drawAsciiDiffusion = (
const contour =
Math.exp(-((Math.sin(angle * 3 + radius * 17 - time * 0.17) * 0.5 + 0.5 - radius) ** 2) / 0.016) * 0.38
const stem = Math.exp(-((sx + 0.05) ** 2 / 0.004 + (sy - 0.25) ** 2 / 0.08)) * 0.46
const stem = Math.exp(-((nx - centerX + 0.05) ** 2 / 0.004 + (ny - centerY - 0.25) ** 2 / 0.08)) * 0.46
const latent = clamp(bloom + contour + stem, 0, 1)
const staticA = hash2(col + timestep * 19, row - timestep * 11)
@@ -243,10 +224,9 @@ const drawAsciiDiffusion = (
ctx.fillRect(0, 0, width, height)
}
export const DiffusionCanvas: FC = () => {
const DiffusionCanvas: FC = () => {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const sizeRef = useRef({ width: 0, height: 0 })
const themeRef = useRef<Theme>(FALLBACKS)
const fitToContainer = useCallback(() => {
const canvas = canvasRef.current
@@ -261,28 +241,6 @@ export const DiffusionCanvas: FC = () => {
useResizeObserver(fitToContainer, canvasRef)
useEffect(() => {
const probe = document.createElement('span')
probe.style.cssText = 'position:absolute;width:0;height:0;visibility:hidden;pointer-events:none'
document.documentElement.appendChild(probe)
const sync = () => {
themeRef.current = readTheme(probe)
}
sync()
// Re-resolve when the theme repaints (`applyTheme` toggles `.dark` and
// rewrites inline custom props on the root) instead of per animation frame.
const observer = new MutationObserver(sync)
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style', 'data-hermes-mode'] })
return () => {
observer.disconnect()
probe.remove()
}
}, [])
useEffect(() => {
const canvas = canvasRef.current
const ctx = canvas?.getContext('2d')
@@ -296,7 +254,7 @@ export const DiffusionCanvas: FC = () => {
let frame = requestAnimationFrame(function draw(now) {
const { width, height } = sizeRef.current
ctx.clearRect(0, 0, width, height)
drawAsciiDiffusion(ctx, themeRef.current, width, height, now / 1000)
drawAsciiDiffusion(ctx, width, height, now / 1000)
frame = requestAnimationFrame(draw)
})
@@ -307,3 +265,15 @@ export const DiffusionCanvas: FC = () => {
return <canvas className="absolute inset-0 h-full w-full" ref={canvasRef} />
}
export const ImageGenerationPlaceholder: FC = () => {
const { t } = useI18n()
return (
<div aria-label={t.assistant.tool.renderingImage} aria-live="polite" className="w-full max-w-136 self-start" role="status">
<div className="relative h-(--image-preview-height) overflow-hidden rounded-4xl border border-border/55 shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_45%,transparent),inset_0_0_0_0.0625rem_color-mix(in_srgb,var(--dt-border)_34%,transparent),inset_0_-0.75rem_1.75rem_color-mix(in_srgb,var(--dt-primary)_5%,transparent)]">
<DiffusionCanvas />
</div>
</div>
)
}

View File

@@ -1,70 +0,0 @@
import { type ReactNode } from 'react'
import { cn } from '@/lib/utils'
interface StatusRowProps {
children: ReactNode
className?: string
/** Leading glyph slot (spinner / status dot / selection circle). */
leading?: ReactNode
/** Makes the whole row activatable (adds `cursor-pointer` + keyboard a11y).
* Trailing-slot buttons should `stopPropagation` so they don't also fire it. */
onActivate?: () => void
/** Right-aligned actions. Revealed on row hover/focus unless `trailingVisible`. */
trailing?: ReactNode
trailingVisible?: boolean
}
/**
* Shared row chrome for everything in the composer status stack — status items
* (subagents, background) AND queued prompts. Fixed height, a leading glyph
* slot, flexible content, and a trailing actions slot that reveals on hover.
* Hover background matches the session sidebar. Consumers fill the three slots;
* they never re-implement the row container.
*/
export function StatusRow({
children,
className,
leading,
onActivate,
trailing,
trailingVisible = false
}: StatusRowProps) {
return (
<div
className={cn(
'group/status-row flex min-h-6 items-center gap-2 rounded-md px-1.5 py-1 hover:bg-(--ui-row-hover-background)',
onActivate && 'cursor-pointer',
className
)}
onClick={onActivate}
onKeyDown={
onActivate
? event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onActivate()
}
}
: undefined
}
role={onActivate ? 'button' : undefined}
tabIndex={onActivate ? 0 : undefined}
>
{leading !== undefined && (
<span className="flex size-3.5 shrink-0 items-center justify-center">{leading}</span>
)}
<div className="flex min-w-0 flex-1 items-center gap-2">{children}</div>
{trailing && (
<div
className={cn(
'flex shrink-0 items-center gap-0.5',
!trailingVisible && 'opacity-0 group-hover/status-row:opacity-100 group-focus-within/status-row:opacity-100'
)}
>
{trailing}
</div>
)}
</div>
)
}

View File

@@ -1,42 +0,0 @@
import { type ReactNode, useState } from 'react'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
interface StatusSectionProps {
/** Optional right-aligned actions (text links / micro buttons). Pass
* `Button` with `size="micro"` + `variant="text"` or `"link"`. */
accessory?: ReactNode
children: ReactNode
defaultCollapsed?: boolean
/** Optional glyph between the caret and the label (e.g. a `Codicon`). */
icon?: ReactNode
label: ReactNode
}
/**
* One collapsible group inside the composer status stack. Pure chrome — header
* (caret + label) + body — styled to match the queue exactly so every status
* (queue, subagents, background) reads as one piece. The stack supplies the
* outer card and the dividers between groups; this owns only its own collapse.
*/
export function StatusSection({ accessory, children, defaultCollapsed = true, icon, label }: StatusSectionProps) {
const [collapsed, setCollapsed] = useState(defaultCollapsed)
return (
<div>
<div className="flex items-center gap-1 pr-1">
<button
className="flex min-w-0 flex-1 items-center gap-1.5 px-2 py-1 text-left text-xs font-normal text-muted-foreground/92 transition-colors hover:text-foreground/90"
onClick={() => setCollapsed(open => !open)}
type="button"
>
<DisclosureCaret className="shrink-0" open={!collapsed} size="1em" />
{icon && <span className="flex shrink-0 items-center">{icon}</span>}
<span className="truncate">{label}</span>
</button>
{accessory && <div className="flex shrink-0 items-center gap-1">{accessory}</div>}
</div>
{!collapsed && <div className="px-1 pb-0.5">{children}</div>}
</div>
)
}

View File

@@ -1,50 +0,0 @@
import { useEffect, useLayoutEffect, useRef } from 'react'
import { cn } from '@/lib/utils'
interface TerminalOutputProps {
className?: string
text: string
}
const NEAR_BOTTOM_PX = 24
/**
* Tiny read-only terminal viewer: monospace, non-wrapping (long lines scroll
* horizontally), vertical scroll past `max-h`. Jumps to the bottom on mount,
* then tails — sticking to the bottom as `text` grows, but only when the user
* is already near the bottom so scrolling up to read earlier output isn't
* interrupted.
*
* Self-contained so any surface (status rows, tool calls, inspectors) can drop
* in a stdout/stderr box without re-implementing the scroll logic.
*/
export function TerminalOutput({ className, text }: TerminalOutputProps) {
const ref = useRef<HTMLDivElement>(null)
// On open: jump straight to the latest output (no animation, before paint).
useLayoutEffect(() => {
const el = ref.current
if (el) {
el.scrollTop = el.scrollHeight
}
}, [])
// On growth: tail only when already pinned near the bottom.
useEffect(() => {
const el = ref.current
if (el && el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_PX) {
el.scrollTop = el.scrollHeight
}
}, [text])
return (
<div className={cn('max-h-16 overflow-auto overscroll-contain', className)} ref={ref}>
<pre className="w-max min-w-full font-mono text-[0.5625rem] leading-[0.85rem] whitespace-pre text-muted-foreground/70">
{text}
</pre>
</div>
)
}

View File

@@ -3,17 +3,55 @@
import { type ComponentProps, useState } from 'react'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { useImageDownload } from '@/hooks/use-image-download'
import { useI18n } from '@/i18n'
import { Download } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
function imageFilename(src?: string): string {
if (!src) {
return 'image'
}
try {
const { pathname } = new URL(src, window.location.href)
return pathname.split('/').filter(Boolean).pop() || 'image'
} catch {
return src.split(/[\\/]/).filter(Boolean).pop() || 'image'
}
}
function isMissingIpcHandler(error: unknown): boolean {
const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''
return message.includes("No handler registered for 'hermes:saveImageFromUrl'")
}
async function startBrowserDownload(src: string) {
const response = await fetch(src)
if (!response.ok) {
throw new Error(`Could not fetch image: ${response.status}`)
}
const blobUrl = URL.createObjectURL(await response.blob())
const link = document.createElement('a')
link.href = blobUrl
link.download = imageFilename(src)
link.rel = 'noopener noreferrer'
document.body.appendChild(link)
link.click()
link.remove()
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000)
}
export interface ZoomableImageProps extends ComponentProps<'img'> {
containerClassName?: string
slot?: string
}
export interface ImageActionCopy {
interface ImageActionCopy {
downloadImage: string
savingImage: string
}
@@ -21,10 +59,70 @@ export interface ImageActionCopy {
export function ZoomableImage({ className, containerClassName, src, alt, slot, ...props }: ZoomableImageProps) {
const { t } = useI18n()
const copy = t.desktop
const { download, saving } = useImageDownload(src)
const [saving, setSaving] = useState(false)
const [lightboxOpen, setLightboxOpen] = useState(false)
const canOpen = Boolean(src)
async function handleDownload() {
if (!src || saving) {
return
}
setSaving(true)
try {
if (window.hermesDesktop?.saveImageFromUrl) {
const saved = await window.hermesDesktop.saveImageFromUrl(src)
if (saved) {
notify({ kind: 'success', title: copy.imageSaved, message: imageFilename(src) })
}
return
}
await startBrowserDownload(src)
} catch (error) {
if (isMissingIpcHandler(error)) {
try {
await startBrowserDownload(src)
notify({
kind: 'info',
title: copy.downloadStarted,
message: copy.restartToUseSaveImage
})
} catch (fallbackError) {
notifyError(fallbackError, copy.restartToSaveImages)
}
return
}
notifyError(error, copy.imageDownloadFailed)
} finally {
setSaving(false)
}
}
const lightbox = src ? (
<Dialog onOpenChange={setLightboxOpen} open={lightboxOpen}>
<DialogContent
className="block w-auto max-h-[calc(100vh-12rem)] max-w-[calc(100vw-12rem)] overflow-visible border-0 bg-transparent p-0 shadow-none"
showCloseButton={false}
>
<div className="group/lightbox relative inline-block">
<img
alt={alt ?? ''}
className="block max-h-[calc(100vh-12rem)] max-w-[calc(100vw-12rem)] cursor-zoom-out select-auto rounded-lg object-contain shadow-2xl"
onClick={() => setLightboxOpen(false)}
src={src}
/>
<ImageActionButton copy={copy} onClick={handleDownload} saving={saving} variant="lightbox" />
</div>
</DialogContent>
</Dialog>
) : null
return (
<>
<span
@@ -40,79 +138,30 @@ export function ZoomableImage({ className, containerClassName, src, alt, slot, .
>
<img alt={alt ?? ''} className={className} src={src} {...props} />
</button>
{src && (
<ImageActionButton className="group-hover/image:opacity-100" copy={copy} onClick={download} saving={saving} />
)}
{src && <ImageActionButton copy={copy} onClick={handleDownload} saving={saving} variant="inline" />}
</span>
{src && (
<ImageLightbox
alt={alt}
copy={copy}
onClick={download}
onOpenChange={setLightboxOpen}
open={lightboxOpen}
saving={saving}
src={src}
/>
)}
{lightbox}
</>
)
}
export function ImageLightbox({
alt,
function ImageActionButton({
copy,
onClick,
onOpenChange,
open,
saving,
src
variant
}: {
alt?: string
copy: ImageActionCopy
onClick: () => void
onOpenChange: (open: boolean) => void
open: boolean
saving: boolean
src: string
}) {
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent
className="block w-auto max-h-[calc(100vh-12rem)] max-w-[calc(100vw-12rem)] overflow-visible border-0 bg-transparent p-0 shadow-none"
showCloseButton={false}
>
<div className="group/lightbox relative inline-block">
<img
alt={alt ?? ''}
className="block max-h-[calc(100vh-12rem)] max-w-[calc(100vw-12rem)] cursor-zoom-out select-auto rounded-lg object-contain shadow-2xl"
onClick={() => onOpenChange(false)}
src={src}
/>
<ImageActionButton className="group-hover/lightbox:opacity-100" copy={copy} onClick={onClick} saving={saving} />
</div>
</DialogContent>
</Dialog>
)
}
export function ImageActionButton({
className,
copy,
onClick,
saving
}: {
className?: string
copy: ImageActionCopy
onClick: () => void
saving: boolean
variant: 'inline' | 'lightbox'
}) {
return (
<button
aria-label={saving ? copy.savingImage : copy.downloadImage}
className={cn(
'absolute right-2 top-2 grid size-8 place-items-center rounded-full border border-border/70 bg-background/80 text-muted-foreground opacity-0 shadow-sm backdrop-blur transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 disabled:opacity-50',
className
variant === 'inline' ? 'group-hover/image:opacity-100' : 'group-hover/lightbox:opacity-100'
)}
disabled={saving}
onClick={event => {

View File

@@ -3,23 +3,23 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { $desktopBoot } from '@/store/boot'
import { $desktopOnboarding } from '@/store/onboarding'
import { setGatewayState } from '@/store/session'
import { $gatewayState, setGatewayState } from '@/store/session'
import { BootFailureOverlay } from './boot-failure-overlay'
import { GatewayConnectingOverlay } from './gateway-connecting-overlay'
// Repro for the "remote gateway → stuck on CONNECTING, no way to settings"
// report. The connecting overlay (z-1200, full-screen, pointer-events on) used
// to be shown whenever `gatewayState !== 'open' && !boot.error`. The ONLY escape
// report. The connecting overlay (z-1200, full-screen, pointer-events on) is
// shown whenever `gatewayState !== 'open' && !boot.error`. The ONLY escape
// hatch — BootFailureOverlay, which has "Use local gateway" / "Sign in" /
// "Retry" — only renders when `boot.error` is set.
//
// useGatewayBoot only calls failDesktopBoot() (which sets boot.error) when the
// INITIAL boot() throws. After the first successful connect (bootCompleted),
// any later socket drop goes through scheduleReconnect(), which loops FOREVER
// against the dead remote. So gatewayState sits at 'closed'/'error' with
// boot.error null. The fix keeps the initial-boot overlay out of post-boot
// reconnects, leaving chat/settings usable while the reconnect loop runs.
// against the dead remote and never sets boot.error. So gatewayState sits at
// 'closed'/'error' with boot.error null → CONNECTING forever, recovery overlay
// never appears, settings unreachable.
function resetStores() {
setGatewayState('idle')
@@ -75,7 +75,7 @@ describe('connecting overlay vs recovery surface', () => {
expect(isConnectingShown()).toBe(false)
})
it('post-boot socket drops do not re-cover the app with the initial CONNECTING overlay', () => {
it('REPRO: remote socket drops AFTER a successful boot → stuck on CONNECTING, no recovery, no settings', () => {
// 1. Initial boot succeeded: gateway opened, boot completed (no error).
setGatewayState('open')
const { rerender } = render(
@@ -97,14 +97,14 @@ describe('connecting overlay vs recovery surface', () => {
</>
)
// The initial-boot connecting overlay stays out of the way, so settings and
// the composer remain reachable during the reconnect loop.
expect(isConnectingShown()).toBe(false)
// The connecting overlay reappears and latches...
expect(isConnectingShown()).toBe(true)
// ...with NO recovery surface, because boot.error was never set.
expect(isRecoveryShown()).toBe(false)
// 3. Reconnect loops against the dead remote: gatewayState bounces closed
// → error → closed. Until the escalation path sets boot.error, the app
// remains usable instead of modal-blocked.
// 3. Reconnect loops forever against the dead remote: gatewayState bounces
// closed → error → closed, boot.error never gets set. The user is
// pinned on CONNECTING with no path to Settings indefinitely.
setGatewayState('error')
rerender(
<>
@@ -113,7 +113,7 @@ describe('connecting overlay vs recovery surface', () => {
</>
)
expect($desktopBoot.get().error).toBeNull()
expect(isConnectingShown()).toBe(false)
expect(isConnectingShown()).toBe(true)
expect(isRecoveryShown()).toBe(false)
})

View File

@@ -52,13 +52,7 @@ export function GatewayConnectingOverlay() {
const [tail, setTail] = useState(TAIL)
const [phase, setPhase] = useState<Phase>('live')
// The full-screen connecting overlay is for initial boot only. After a
// healthy boot, flaky networks / sleep-wake can drop the socket and flip the
// gateway state back to closed/error while the app reconnects. Do not cover
// the chat then — users should still be able to type drafts, open settings,
// and recover instead of staring at a modal CONNECTING screen.
const initialBootActive = boot.visible || boot.running || boot.progress < 100
const connecting = gatewayState !== 'open' && !boot.error && initialBootActive
const connecting = gatewayState !== 'open' && !boot.error
// Latches once we've actually shown the overlay, so the brief frame where
// gatewayState flips to "open" (connecting -> false) before the exit phase
// kicks in doesn't unmount us and cause a flash.

View File

@@ -15,29 +15,5 @@ export function HapticsProvider({ children }: { children: ReactNode }) {
return () => registerHapticTrigger(null)
}, [muted, trigger])
// web-haptics builds its AudioContext lazily inside the first trigger(), and
// the process's first AudioContext pays the CoreAudio spin-up (~850ms stall
// in profiles) — which landed on the first streamStart haptic as the first
// token painted. Open/close a throwaway context at idle so the real one
// connects to an already-warm audio service in single-digit ms.
useEffect(() => {
if (typeof requestIdleCallback !== 'function' || typeof AudioContext === 'undefined') {
return undefined
}
const id = requestIdleCallback(
() => {
try {
void new AudioContext().close().catch(() => undefined)
} catch {
// No audio device (headless CI) — nothing to warm.
}
},
{ timeout: 2000 }
)
return () => cancelIdleCallback(id)
}, [])
return <>{children}</>
}

View File

@@ -2,9 +2,9 @@ import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { BrailleSpinner } from '@/components/ui/braille-spinner'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { Switch } from '@/components/ui/switch'
import type { HermesGateway } from '@/hermes'
import { getGlobalModelOptions } from '@/hermes'
@@ -69,7 +69,9 @@ export function ModelVisibilityDialog({
next.delete(key)
// Check if this was the last real model for this provider.
const remainingForProvider = [...next].some(k => k.startsWith(`${provider.slug}::`) && !isProviderSentinel(k))
const remainingForProvider = [...next].some(
k => k.startsWith(`${provider.slug}::`) && !isProviderSentinel(k)
)
if (!remainingForProvider) {
next.add(sentinel)
@@ -108,7 +110,7 @@ export function ModelVisibilityDialog({
<div className="max-h-[55vh] overflow-y-auto pb-1">
{providers.length === 0 ? (
<div className="px-3 py-5 text-center text-xs text-muted-foreground">
{modelOptions.isPending ? <GlyphSpinner className="mx-auto text-sm" /> : copy.noAuthenticatedProviders}
{modelOptions.isPending ? <BrailleSpinner className="mx-auto text-sm" /> : copy.noAuthenticatedProviders}
</div>
) : (
providers.map(provider => {

View File

@@ -1,10 +1,8 @@
import { useEffect, useState } from 'react'
import spinners, { type BrailleSpinnerName as SpinnerName } from 'unicode-animations'
import spinners, { type BrailleSpinnerName } from 'unicode-animations'
import { cn } from '@/lib/utils'
export type { SpinnerName }
interface NormalisedSpinner {
frames: readonly string[]
interval: number
@@ -12,10 +10,10 @@ interface NormalisedSpinner {
// Some spinners ship multi-character frames. Pull the first cell so each
// frame fits in one monospace box — matches how the TUI uses them.
const FRAMES_BY_NAME: Record<SpinnerName, NormalisedSpinner> = (() => {
const out = {} as Record<SpinnerName, NormalisedSpinner>
const FRAMES_BY_NAME: Record<BrailleSpinnerName, NormalisedSpinner> = (() => {
const out = {} as Record<BrailleSpinnerName, NormalisedSpinner>
for (const name of Object.keys(spinners) as SpinnerName[]) {
for (const name of Object.keys(spinners) as BrailleSpinnerName[]) {
const raw = spinners[name]
out[name] = {
@@ -27,21 +25,21 @@ const FRAMES_BY_NAME: Record<SpinnerName, NormalisedSpinner> = (() => {
return out
})()
interface GlyphSpinnerProps {
interface BrailleSpinnerProps {
ariaLabel?: string
className?: string
spinner?: SpinnerName
spinner?: BrailleSpinnerName
}
/**
* One-char glyph spinner driven by `unicode-animations` (braille, orbit, scan,
* etc. pick any `spinner` name). Mirrors the spinner used by the Ink TUI so
* the desktop and terminal experiences read the same visually. Renders inside
* an `inline-flex` cell with `leading-none` and `items-center` so it sits
* vertically centred inside its parent's line-box.
* One-char braille spinner driven by `unicode-animations`. Mirrors the
* spinner used by the Ink TUI so the desktop and terminal experiences
* read the same visually. Renders inside an `inline-flex` cell with
* `leading-none` and `items-center` so it sits vertically centred inside
* its parent's line-box (e.g. the 1.1rem disclosure row).
*/
export function GlyphSpinner({ ariaLabel = 'Loading', className, spinner = 'braille' }: GlyphSpinnerProps) {
const spin = FRAMES_BY_NAME[spinner] ?? FRAMES_BY_NAME.braille!
export function BrailleSpinner({ ariaLabel = 'Loading', className, spinner = 'breathe' }: BrailleSpinnerProps) {
const spin = FRAMES_BY_NAME[spinner] ?? FRAMES_BY_NAME.breathe!
const [frame, setFrame] = useState(0)
useEffect(() => {

View File

@@ -4,9 +4,6 @@ import * as React from 'react'
import { cn } from '@/lib/utils'
// Text+icon actions underline the label on hover, not the glyph.
const TEXT_ACTION_ICON = '[&_.codicon]:no-underline [&_svg]:no-underline'
// Text buttons are square (no radius) and sized by padding + line-height — no
// fixed heights — so they stay snug and scale with content. Only icon buttons
// (inherently square) carry the shared 4px radius.
@@ -25,13 +22,13 @@ const buttonVariants = cva(
secondary:
'bg-(--ui-bg-quaternary) text-(--ui-text-primary) hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)',
ghost: 'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)',
link: `text-primary underline-offset-4 decoration-current/20 hover:underline ${TEXT_ACTION_ICON}`,
link: 'text-primary underline-offset-4 decoration-current/20 hover:underline',
// Boxless inline-text action (no bg/border). Quiet by default — reads as
// muted label text, underlines on hover (e.g. "Cancel", "Clear").
text: `text-muted-foreground underline-offset-4 hover:text-foreground hover:underline ${TEXT_ACTION_ICON}`,
text: 'text-muted-foreground underline-offset-4 hover:text-foreground hover:underline',
// Emphasized inline-text action: bold + always-underlined link. Use for
// the actionable affordance in a row ("Change", "Set", "Open logs", …).
textStrong: `font-semibold text-muted-foreground underline underline-offset-4 hover:text-foreground ${TEXT_ACTION_ICON}`
textStrong: 'font-semibold text-muted-foreground underline underline-offset-4 hover:text-foreground'
},
size: {
default: 'px-3 py-1.5 has-[>svg]:px-2.5',
@@ -42,9 +39,6 @@ const buttonVariants = cva(
// variants when the button must sit inline in a heading or sentence
// (replaces ad-hoc `h-auto px-0 py-0` overrides).
inline: 'h-auto gap-1 p-0 has-[>svg]:px-0',
// Status-stack headers, table footers — 12px text actions beside a label.
micro:
"h-auto gap-0.5 px-1 py-0 text-xs leading-4 font-normal has-[>svg]:px-0.5 [&_svg:not([class*='size-'])]:size-3",
icon: 'size-9 rounded-[4px]',
'icon-xs': "size-6 rounded-[4px] [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-8 rounded-[4px]',

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