Compare commits
109
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdbc4086b6 | ||
|
|
16fb573bae | ||
|
|
6f43ff5572 | ||
|
|
eed61a1251 | ||
|
|
74c5158b10 | ||
|
|
6724daa2c2 | ||
|
|
aa53a78d67 | ||
|
|
0333a99925 | ||
|
|
5acd185f7c | ||
|
|
39a35b784f | ||
|
|
2667601c05 | ||
|
|
643dc82793 | ||
|
|
e256f4aae4 | ||
|
|
cb125c2b3f | ||
|
|
a59d5e37e8 | ||
|
|
4b646bc21e | ||
|
|
62b4618e9a | ||
|
|
2abcae9678 | ||
|
|
c814d3d1dd | ||
|
|
573b964dc7 | ||
|
|
aa0798352a | ||
|
|
311ff967de | ||
|
|
bd66e7e3fb | ||
|
|
2681c5a12d | ||
|
|
fa2aba90b4 | ||
|
|
5b857201b7 | ||
|
|
905ed413d1 | ||
|
|
bea6c1c01f | ||
|
|
a5e9b17ce3 | ||
|
|
5d6c16e972 | ||
|
|
266b5a19f1 | ||
|
|
202e318cb1 | ||
|
|
2d474e39c7 | ||
|
|
2a5dc0ef3d | ||
|
|
197337cc47 | ||
|
|
8cf9d8689d | ||
|
|
b62e57b2f4 | ||
|
|
bc060c7c1c | ||
|
|
3803e5fc28 | ||
|
|
bdd3868b57 | ||
|
|
b6c7ebf028 | ||
|
|
b2bc48cd5e | ||
|
|
9cd3d8a6ac | ||
|
|
b82d2e549f | ||
|
|
b15dc58064 | ||
|
|
acd4278c8a | ||
|
|
be6713c536 | ||
|
|
77687156b4 | ||
|
|
45ceee8a32 | ||
|
|
0a7a81835b | ||
|
|
76b93869d8 | ||
|
|
a856276124 | ||
|
|
1e755ff556 | ||
|
|
7f302c91b2 | ||
|
|
18916376f1 | ||
|
|
f23a4b7bb3 | ||
|
|
bf090deed3 | ||
|
|
7d183f6497 | ||
|
|
492c402774 | ||
|
|
d62e9b7592 | ||
|
|
3cf7d43262 | ||
|
|
edc36f3a45 | ||
|
|
7c226cc57f | ||
|
|
a86b7b314b | ||
|
|
d14f6c9563 | ||
|
|
a1c6349c1f | ||
|
|
78ce91750e | ||
|
|
1a3cd3d436 | ||
|
|
9688c1a94f | ||
|
|
7e46533d9f | ||
|
|
956af7f3c3 | ||
|
|
1899c8f507 | ||
|
|
dd12a5403d | ||
|
|
8905ee6b8a | ||
|
|
5d0408d9fe | ||
|
|
aec38855b5 | ||
|
|
0595af0ad1 | ||
|
|
e90672696e | ||
|
|
bbf020e709 | ||
|
|
135fe90166 | ||
|
|
68536d4375 | ||
|
|
2fef3e2df2 | ||
|
|
691ff7c188 | ||
|
|
7a318aae22 | ||
|
|
b16e22b8f2 | ||
|
|
2e874ef879 | ||
|
|
0db5cb8e75 | ||
|
|
749b7219c4 | ||
|
|
a118b94a85 | ||
|
|
bba9b519aa | ||
|
|
9b01c4d193 | ||
|
|
fca84fe20b | ||
|
|
2714fc8396 | ||
|
|
dc467488a7 | ||
|
|
c2326bc3be | ||
|
|
331cb38e21 | ||
|
|
fa5e98facb | ||
|
|
652dd9c9f2 | ||
|
|
05b9c84ca4 | ||
|
|
6b4073648e | ||
|
|
bc3f4ed70f | ||
|
|
8c3c08c50b | ||
|
|
c61815232a | ||
|
|
1e25358a8f | ||
|
|
8044bf0206 | ||
|
|
4d68984ec7 | ||
|
|
6ff39c31ad | ||
|
|
c41a6534cf | ||
|
|
2f9d18711f |
@@ -90,7 +90,7 @@ jobs:
|
||||
# (see `_SKIP_PARTS` in scripts/run_tests_parallel.py) because each
|
||||
# shard would otherwise reach the session-scoped ``built_image``
|
||||
# fixture in ``tests/docker/conftest.py`` and start a 3-7min
|
||||
# ``docker build`` under a 180s pytest-timeout cap — guaranteed to
|
||||
# ``docker build`` — guaranteed to
|
||||
# die in fixture setup.
|
||||
#
|
||||
# Piggybacking here avoids a second image build: the smoke test
|
||||
@@ -114,7 +114,7 @@ jobs:
|
||||
run: |
|
||||
uv venv .venv --python 3.11
|
||||
source .venv/bin/activate
|
||||
# ``dev`` extra pulls in pytest, pytest-asyncio, pytest-timeout —
|
||||
# ``dev`` extra pulls in pytest, pytest-asyncio —
|
||||
# everything tests/docker/ needs. We deliberately avoid ``all``
|
||||
# here because the docker tests only drive the container via
|
||||
# subprocess and don't import hermes_agent's optional deps.
|
||||
|
||||
+19
-15
@@ -4,13 +4,13 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
- 'docs/**'
|
||||
- "**/*.md"
|
||||
- "docs/**"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
- 'docs/**'
|
||||
- "**/*.md"
|
||||
- "docs/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -30,13 +30,17 @@ jobs:
|
||||
slice: [1, 2, 3, 4, 5, 6]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Restore duration cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: test_durations.json
|
||||
# Single stable key. main always overwrites, PRs always find it.
|
||||
# main always writes a new suffix, but jobs pick the latest one with the same prefix
|
||||
# quote from https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching#cache-hits-and-misses
|
||||
# If you provide restore-keys, the cache action sequentially searches for any caches that match the list of restore-keys.
|
||||
# If there are no exact matches, the action searches for partial matches of the restore keys.
|
||||
# When the action finds a partial match, the most recent cache is restored to the path directory.
|
||||
key: test-durations
|
||||
|
||||
- name: Install ripgrep (prebuilt binary)
|
||||
@@ -54,7 +58,7 @@ jobs:
|
||||
rg --version
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
with:
|
||||
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
|
||||
# Keyed on the dependency manifests, so the cache is reused until
|
||||
@@ -115,7 +119,7 @@ jobs:
|
||||
NOUS_API_KEY: ""
|
||||
|
||||
- name: Upload per-slice durations
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-durations-slice-${{ matrix.slice }}
|
||||
path: test_durations.json
|
||||
@@ -125,11 +129,11 @@ jobs:
|
||||
# (including PRs) get balanced slicing.
|
||||
save-durations:
|
||||
needs: test
|
||||
if: always() && github.ref == 'refs/heads/main'
|
||||
if: needs.test.result == 'success' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download all slice durations
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: test-durations-slice-*
|
||||
path: durations
|
||||
@@ -149,17 +153,17 @@ jobs:
|
||||
"
|
||||
|
||||
- name: Save merged duration cache
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: test_durations.json
|
||||
key: test-durations
|
||||
key: test-durations-${{ github.run_id }}
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install ripgrep (prebuilt binary)
|
||||
run: |
|
||||
@@ -176,7 +180,7 @@ jobs:
|
||||
rg --version
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
with:
|
||||
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
|
||||
# Keyed on the dependency manifests, so the cache is reused until
|
||||
|
||||
@@ -824,6 +824,7 @@ class HermesACPAgent(acp.Agent):
|
||||
|
||||
try:
|
||||
from model_tools import get_tool_definitions
|
||||
from agent.memory_manager import inject_memory_provider_tools
|
||||
|
||||
enabled_toolsets = _expand_acp_enabled_toolsets(
|
||||
getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"],
|
||||
@@ -839,6 +840,7 @@ class HermesACPAgent(acp.Agent):
|
||||
state.agent.valid_tool_names = {
|
||||
tool["function"]["name"] for tool in state.agent.tools or []
|
||||
}
|
||||
inject_memory_provider_tools(state.agent)
|
||||
invalidate = getattr(state.agent, "_invalidate_system_prompt", None)
|
||||
if callable(invalidate):
|
||||
invalidate()
|
||||
@@ -1779,10 +1781,25 @@ class HermesACPAgent(acp.Agent):
|
||||
def _cmd_tools(self, args: str, state: SessionState) -> str:
|
||||
try:
|
||||
from model_tools import get_tool_definitions
|
||||
from types import SimpleNamespace
|
||||
from agent.memory_manager import inject_memory_provider_tools
|
||||
|
||||
toolsets = _expand_acp_enabled_toolsets(
|
||||
getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"]
|
||||
)
|
||||
tools = get_tool_definitions(enabled_toolsets=toolsets, quiet_mode=True)
|
||||
tool_view = SimpleNamespace(
|
||||
tools=list(tools or []),
|
||||
valid_tool_names={
|
||||
tool.get("function", {}).get("name")
|
||||
for tool in tools or []
|
||||
if isinstance(tool, dict)
|
||||
},
|
||||
enabled_toolsets=toolsets,
|
||||
_memory_manager=getattr(state.agent, "_memory_manager", None),
|
||||
)
|
||||
inject_memory_provider_tools(tool_view)
|
||||
tools = tool_view.tools
|
||||
if not tools:
|
||||
return "No tools available."
|
||||
lines = [f"Available tools ({len(tools)}):"]
|
||||
|
||||
+2
-32
@@ -1193,38 +1193,8 @@ def init_agent(
|
||||
_ra().logger.warning("Memory provider plugin init failed: %s", _mpe)
|
||||
agent._memory_manager = None
|
||||
|
||||
# Inject memory provider tool schemas into the tool surface.
|
||||
# Skip tools whose names already exist (plugins may register the
|
||||
# same tools via ctx.register_tool(), which lands in agent.tools
|
||||
# through _ra().get_tool_definitions()). Duplicate function names cause
|
||||
# 400 errors on providers that enforce unique names (e.g. Xiaomi
|
||||
# MiMo via Nous Portal).
|
||||
#
|
||||
# Respect the platform's enabled_toolsets configuration (#5544):
|
||||
# enabled_toolsets is None → no filter, inject (backward compat)
|
||||
# "memory" in enabled_toolsets → user opted in, inject
|
||||
# otherwise (incl. []) → user excluded memory, skip injection
|
||||
#
|
||||
# Without this gate, `platform_toolsets: telegram: []` still leaks memory
|
||||
# provider tools (fact_store, etc.) into the tool surface — a 10x latency
|
||||
# penalty on local models and a frequent trigger of tool-call loops.
|
||||
if agent._memory_manager and agent.tools is not None and (
|
||||
agent.enabled_toolsets is None or "memory" in agent.enabled_toolsets
|
||||
):
|
||||
_existing_tool_names = {
|
||||
t.get("function", {}).get("name")
|
||||
for t in agent.tools
|
||||
if isinstance(t, dict)
|
||||
}
|
||||
for _schema in agent._memory_manager.get_all_tool_schemas():
|
||||
_tname = _schema.get("name", "")
|
||||
if _tname and _tname in _existing_tool_names:
|
||||
continue # already registered via plugin path
|
||||
_wrapped = {"type": "function", "function": _schema}
|
||||
agent.tools.append(_wrapped)
|
||||
if _tname:
|
||||
agent.valid_tool_names.add(_tname)
|
||||
_existing_tool_names.add(_tname)
|
||||
from agent.memory_manager import inject_memory_provider_tools as _inject_memory_provider_tools
|
||||
_inject_memory_provider_tools(agent)
|
||||
|
||||
# Skills config: nudge interval for skill creation reminders
|
||||
agent._skill_nudge_interval = 10
|
||||
|
||||
@@ -445,6 +445,45 @@ def repair_message_sequence(agent, messages: List[Dict]) -> int:
|
||||
return repairs
|
||||
|
||||
|
||||
def repair_message_sequence_with_cursor(agent, messages: List[Dict]) -> int:
|
||||
"""Run :func:`repair_message_sequence` and keep the SessionDB flush
|
||||
cursor consistent with the compacted list (#44837).
|
||||
|
||||
``repair_message_sequence`` merges/drops messages in place, shrinking
|
||||
the list. ``_last_flushed_db_idx`` (the DB-write cursor) indexes into
|
||||
that list, so after compaction it can point past the new end — the
|
||||
turn-end flush would then skip the assistant/tool chain entirely — or
|
||||
past unflushed messages shifted to lower indexes.
|
||||
|
||||
Repair preserves object identity for surviving messages, so counting
|
||||
the survivors from the previously-flushed prefix gives the exact new
|
||||
cursor even when messages are dropped/merged at indexes *before* the
|
||||
cursor — a plain ``min()`` clamp would silently skip that many
|
||||
unflushed rows. Falls back to the clamp when no prefix snapshot is
|
||||
available.
|
||||
|
||||
Returns the number of repairs made (same as ``repair_message_sequence``).
|
||||
"""
|
||||
pre_repair_flushed_ids = None
|
||||
flush_cursor = getattr(agent, "_last_flushed_db_idx", None)
|
||||
if isinstance(flush_cursor, int) and flush_cursor > 0:
|
||||
pre_repair_flushed_ids = {id(m) for m in messages[:flush_cursor]}
|
||||
|
||||
repairs = repair_message_sequence(agent, messages)
|
||||
|
||||
if repairs > 0 and hasattr(agent, "_last_flushed_db_idx"):
|
||||
if pre_repair_flushed_ids is not None:
|
||||
agent._last_flushed_db_idx = sum(
|
||||
1 for m in messages if id(m) in pre_repair_flushed_ids
|
||||
)
|
||||
else:
|
||||
agent._last_flushed_db_idx = min(
|
||||
agent._last_flushed_db_idx, len(messages)
|
||||
)
|
||||
|
||||
return repairs
|
||||
|
||||
|
||||
|
||||
def strip_think_blocks(agent, content: str) -> str:
|
||||
"""Remove reasoning/thinking blocks from content, returning only visible text.
|
||||
@@ -579,12 +618,33 @@ def recover_with_credential_pool(
|
||||
current_provider = (getattr(agent, "provider", "") or "").strip().lower()
|
||||
pool_provider = (getattr(pool, "provider", "") or "").strip().lower()
|
||||
if current_provider and pool_provider and current_provider != pool_provider:
|
||||
_ra().logger.warning(
|
||||
"Credential pool provider mismatch: pool=%s, agent=%s — "
|
||||
"skipping pool mutation to avoid cross-provider contamination",
|
||||
pool_provider, current_provider,
|
||||
)
|
||||
return False, has_retried_429
|
||||
# Custom endpoints use two naming conventions for the SAME provider:
|
||||
# the agent carries the generic ``custom`` label while the pool is
|
||||
# keyed ``custom:<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
|
||||
|
||||
effective_reason = classified_reason
|
||||
if effective_reason is None:
|
||||
|
||||
@@ -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 = None
|
||||
explicit_base_url = runtime_base_url or None
|
||||
explicit_api_key = None
|
||||
if runtime_base_url and (main_provider == "custom" or main_provider.startswith("custom:")):
|
||||
resolved_provider = "custom"
|
||||
|
||||
+191
-23
@@ -69,6 +69,31 @@ SUMMARY_PREFIX = (
|
||||
)
|
||||
LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:"
|
||||
|
||||
# Metadata key added to context compression summary messages so that frontends
|
||||
# (CLI, Desktop, gateway, TUI) can distinguish them from real assistant/user
|
||||
# messages and filter or render them appropriately without content-prefix
|
||||
# heuristics. See https://github.com/NousResearch/hermes-agent/issues/38389
|
||||
#
|
||||
# Underscore-prefixed ON PURPOSE: the wire sanitizers
|
||||
# (agent/transports/chat_completions.py convert_messages and the summary-path
|
||||
# mirror in agent/chat_completion_helpers.py) strip every top-level message
|
||||
# key starting with "_" before the request leaves the process. Strict
|
||||
# OpenAI-compatible gateways (Fireworks, Mistral, Moonshot/Kimi, opencode-go)
|
||||
# reject payloads carrying unknown keys with "Extra inputs are not permitted",
|
||||
# poisoning every subsequent request in the session — a bare key like
|
||||
# "is_compressed_summary" would reach the wire and trip exactly that.
|
||||
COMPRESSED_SUMMARY_METADATA_KEY = "_compressed_summary"
|
||||
|
||||
# Appended to every standalone summary message (and to the merged-into-tail
|
||||
# prefix) so the model has an unambiguous "summary ends here" boundary.
|
||||
# Without it, weak models read the verbatim "## Active Task" quote as fresh
|
||||
# user input (#11475, #14521) or regurgitate an assistant-role summary as
|
||||
# their own output (#33256).
|
||||
_SUMMARY_END_MARKER = (
|
||||
"--- END OF CONTEXT SUMMARY — "
|
||||
"respond to the message below, not the summary above ---"
|
||||
)
|
||||
|
||||
# Handoff prefixes that shipped in earlier releases. A summary persisted under
|
||||
# one of these can be inherited into a resumed lineage (#35344); when it is
|
||||
# re-normalized on re-compaction we must strip the OLD prefix too, otherwise the
|
||||
@@ -146,6 +171,11 @@ _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`'\")\]}<>]+")
|
||||
@@ -1616,7 +1646,13 @@ 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):
|
||||
return text[len(prefix):].lstrip()
|
||||
text = text[len(prefix):].lstrip()
|
||||
break
|
||||
# Strip the trailing end marker too — a rehydrated handoff body that
|
||||
# keeps it would leak the boundary directive into the iterative-update
|
||||
# summarizer prompt (and the marker is re-appended on insertion anyway).
|
||||
if text.endswith(_SUMMARY_END_MARKER):
|
||||
text = text[: -len(_SUMMARY_END_MARKER)].rstrip()
|
||||
return text
|
||||
|
||||
@classmethod
|
||||
@@ -1632,6 +1668,19 @@ 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,
|
||||
@@ -1817,6 +1866,105 @@ 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]],
|
||||
@@ -1875,11 +2023,12 @@ 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 hard minimum of 3 messages
|
||||
is always protected, but the budget is allowed to exceed by up to
|
||||
1.5x to avoid cutting inside an oversized message (tool output, file
|
||||
read, etc.). If even the minimum 3 messages exceed 1.5x the budget
|
||||
the cut is placed right after the head so compression still runs.
|
||||
Token budget is the primary criterion. A bounded message-count floor
|
||||
keeps a short run of recent turns verbatim even when the budget is
|
||||
exhausted, but the budget is allowed to exceed by up to 1.5x to avoid
|
||||
cutting inside an oversized message (tool output, file read, etc.). If
|
||||
even that floor exceeds 1.5x the budget, the cut is placed right after
|
||||
the head so compression still runs.
|
||||
|
||||
Never cuts inside a tool_call/result group. Always ensures the most
|
||||
recent user message is in the tail (see ``_ensure_last_user_message_in_tail``).
|
||||
@@ -1887,8 +2036,19 @@ 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 at least 3 messages in the tail
|
||||
min_tail = min(3, n - head_end - 1) if n - head_end > 1 else 0
|
||||
# Hard minimum: always keep a bounded recent-message floor in the tail.
|
||||
# ``protect_last_n`` remains a minimum up to the cap; the cap avoids
|
||||
# preserving a whole run of bulky tool outputs on every compaction.
|
||||
available_tail = max(0, n - head_end - 1)
|
||||
min_tail_floor = max(3, min(self.protect_last_n, _MAX_TAIL_MESSAGE_FLOOR))
|
||||
# Leave at least two non-head messages available to summarize on short
|
||||
# transcripts; otherwise compression can replace a tiny middle with a
|
||||
# summary and save no messages at all.
|
||||
compressible_tail_cap = max(3, available_tail - 2)
|
||||
min_tail = (
|
||||
min(min_tail_floor, compressible_tail_cap, available_tail)
|
||||
if available_tail > 1 else 0
|
||||
)
|
||||
soft_ceiling = int(token_budget * 1.5)
|
||||
accumulated = 0
|
||||
cut_idx = n # start from beyond the end
|
||||
@@ -1960,6 +2120,13 @@ 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)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -2193,32 +2360,33 @@ 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). Append the explicit
|
||||
# end marker — the same one used in the merge-into-tail path — so
|
||||
# the model has a clear "summary above, not new input" signal.
|
||||
if not _merge_summary_into_tail and summary_role == "user":
|
||||
summary = (
|
||||
summary
|
||||
+ "\n\n--- END OF CONTEXT SUMMARY — "
|
||||
"respond to the message below, not the summary above ---"
|
||||
)
|
||||
# user request as fresh input (#11475, #14521).
|
||||
# When it lands as role="assistant", models may regurgitate the
|
||||
# summary text as their own output (#33256). In both cases, append
|
||||
# the explicit end marker so the model has a clear "summary ends
|
||||
# here, respond to the message below" signal.
|
||||
if not _merge_summary_into_tail:
|
||||
summary = summary + "\n\n" + _SUMMARY_END_MARKER
|
||||
|
||||
if not _merge_summary_into_tail:
|
||||
compressed.append({"role": summary_role, "content": summary})
|
||||
compressed.append({
|
||||
"role": summary_role,
|
||||
"content": summary,
|
||||
COMPRESSED_SUMMARY_METADATA_KEY: True,
|
||||
})
|
||||
|
||||
for i in range(compress_end, n_messages):
|
||||
msg = messages[i].copy()
|
||||
if _merge_summary_into_tail and i == compress_end:
|
||||
merged_prefix = (
|
||||
summary
|
||||
+ "\n\n--- END OF CONTEXT SUMMARY — "
|
||||
"respond to the message below, not the summary above ---\n\n"
|
||||
)
|
||||
merged_prefix = summary + "\n\n" + _SUMMARY_END_MARKER + "\n\n"
|
||||
msg["content"] = _append_text_to_content(
|
||||
msg.get("content"),
|
||||
merged_prefix,
|
||||
prepend=True,
|
||||
)
|
||||
# Mark the merged message so frontends can identify it as
|
||||
# containing a compression summary prefix.
|
||||
msg[COMPRESSED_SUMMARY_METADATA_KEY] = True
|
||||
_merge_summary_into_tail = False
|
||||
compressed.append(msg)
|
||||
|
||||
|
||||
@@ -595,7 +595,11 @@ def run_conversation(
|
||||
# landed after an orphan tool result). Most providers return
|
||||
# empty content on malformed sequences, which would otherwise
|
||||
# retrigger the empty-retry loop indefinitely.
|
||||
repaired_seq = agent._repair_message_sequence(messages)
|
||||
# repair_message_sequence_with_cursor also recomputes the SessionDB
|
||||
# flush cursor (_last_flushed_db_idx) when repair compacts the list,
|
||||
# so the turn-end flush doesn't skip the assistant/tool chain (#44837).
|
||||
from agent.agent_runtime_helpers import repair_message_sequence_with_cursor
|
||||
repaired_seq = repair_message_sequence_with_cursor(agent, messages)
|
||||
if repaired_seq > 0:
|
||||
request_logger.info(
|
||||
"Repaired %s message-alternation violations before request (session=%s)",
|
||||
@@ -2631,10 +2635,13 @@ def run_conversation(
|
||||
except Exception:
|
||||
pass
|
||||
if _genuine_nous_rate_limit:
|
||||
# Skip straight to max_retries -- the
|
||||
# top-of-loop guard will handle fallback or
|
||||
# bail cleanly.
|
||||
retry_count = max_retries
|
||||
# Re-enter the loop exactly once so the
|
||||
# top-of-loop Nous guard handles fallback or
|
||||
# bails cleanly. (Setting retry_count to
|
||||
# max_retries would make the while condition
|
||||
# false immediately and the guard would never
|
||||
# run -- no fallback, generic exhaustion error.)
|
||||
retry_count = max(0, max_retries - 1)
|
||||
continue
|
||||
# Upstream capacity 429: fall through to normal
|
||||
# retry logic. A different model (or the same
|
||||
|
||||
@@ -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 = {"parts": [{"text": joined_system}]}
|
||||
system_instruction = {"role": "system", "parts": [{"text": joined_system}]}
|
||||
return contents, system_instruction
|
||||
|
||||
|
||||
|
||||
@@ -44,6 +44,66 @@ logger = logging.getLogger(__name__)
|
||||
_SYNC_DRAIN_TIMEOUT_S = 5.0
|
||||
|
||||
|
||||
def memory_provider_tools_enabled(enabled_toolsets: Optional[List[str]]) -> bool:
|
||||
"""Return whether external memory-provider tools should be exposed."""
|
||||
if enabled_toolsets is None:
|
||||
return True
|
||||
if not enabled_toolsets:
|
||||
return False
|
||||
if "memory" in enabled_toolsets:
|
||||
return True
|
||||
|
||||
try:
|
||||
from toolsets import resolve_toolset
|
||||
|
||||
return any("memory" in resolve_toolset(name) for name in enabled_toolsets)
|
||||
except Exception:
|
||||
logger.debug("Failed to resolve enabled toolsets for memory-provider tools", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def inject_memory_provider_tools(agent: Any) -> int:
|
||||
"""Append external memory-provider tool schemas to an agent tool surface."""
|
||||
memory_manager = getattr(agent, "_memory_manager", None)
|
||||
tools = getattr(agent, "tools", None)
|
||||
if not memory_manager or tools is None:
|
||||
return 0
|
||||
|
||||
existing_tool_names = {
|
||||
tool.get("function", {}).get("name")
|
||||
for tool in tools
|
||||
if isinstance(tool, dict)
|
||||
}
|
||||
if (
|
||||
"memory" not in existing_tool_names
|
||||
and not memory_provider_tools_enabled(getattr(agent, "enabled_toolsets", None))
|
||||
):
|
||||
return 0
|
||||
|
||||
get_schemas = getattr(memory_manager, "get_all_tool_schemas", None)
|
||||
if not callable(get_schemas):
|
||||
return 0
|
||||
|
||||
valid_tool_names = getattr(agent, "valid_tool_names", None)
|
||||
if valid_tool_names is None:
|
||||
valid_tool_names = set()
|
||||
agent.valid_tool_names = valid_tool_names
|
||||
|
||||
added = 0
|
||||
for schema in get_schemas():
|
||||
if not isinstance(schema, dict):
|
||||
continue
|
||||
tool_name = schema.get("name", "")
|
||||
if not tool_name or tool_name in existing_tool_names:
|
||||
continue
|
||||
tools.append({"type": "function", "function": schema})
|
||||
valid_tool_names.add(tool_name)
|
||||
existing_tool_names.add(tool_name)
|
||||
added += 1
|
||||
|
||||
return added
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Context fencing helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -135,7 +135,14 @@ def _repair_schema(node: Any, is_schema: bool = True) -> Any:
|
||||
|
||||
def _fill_missing_type(node: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Infer a reasonable ``type`` if this schema node has none."""
|
||||
if "type" in node and node["type"] not in {None, ""}:
|
||||
node_type = node.get("type")
|
||||
if isinstance(node_type, list):
|
||||
concrete = next(
|
||||
(t for t in node_type if isinstance(t, str) and t not in {"", "null"}),
|
||||
"string",
|
||||
)
|
||||
return {**node, "type": concrete}
|
||||
if "type" in node and node_type not in {None, ""}:
|
||||
return node
|
||||
|
||||
# Heuristic: presence of ``properties`` → object, ``items`` → array, ``enum``
|
||||
|
||||
@@ -508,13 +508,16 @@ PLATFORM_HINTS = {
|
||||
),
|
||||
"telegram": (
|
||||
"You are on a text messaging communication platform, Telegram. "
|
||||
"Standard markdown is automatically converted to Telegram format. "
|
||||
"Standard Markdown is automatically converted to Telegram formatting. "
|
||||
"Supported: **bold**, *italic*, ~~strikethrough~~, ||spoiler||, "
|
||||
"`inline code`, ```code blocks```, [links](url), and ## headers. "
|
||||
"Telegram has NO table syntax — prefer bullet lists or labeled "
|
||||
"key: value pairs over pipe tables (any tables you do emit are "
|
||||
"auto-rewritten into row-group bullets, which you can produce "
|
||||
"directly for cleaner output). "
|
||||
"Telegram 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. "
|
||||
"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 "
|
||||
|
||||
@@ -22,9 +22,31 @@ 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,
|
||||
@@ -48,8 +70,11 @@ 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": _TITLE_PROMPT},
|
||||
{"role": "system", "content": prompt},
|
||||
{"role": "user", "content": f"User: {user_snippet}\n\nAssistant: {assistant_snippet}"},
|
||||
]
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
//! Driven when the installer is launched as `Hermes-Setup.exe --update` (see
|
||||
//! `AppMode` in lib.rs). The desktop app hands off to us — it exits, then we:
|
||||
//!
|
||||
//! 1. wait for the old Hermes desktop process to fully exit (so the venv
|
||||
//! shim is free; otherwise `hermes update` aborts with exit code 2),
|
||||
//! 1. wait for the old Hermes desktop process to fully exit (so both the
|
||||
//! venv shim and packaged app.asar are free; otherwise `hermes update`
|
||||
//! or repair bootstrap can race locked files),
|
||||
//! 2. run `hermes update --yes --gateway` (Python/repo update; this does NOT
|
||||
//! rebuild apps/desktop by design — see cmd_update in hermes_cli/main.py),
|
||||
//! 3. run `hermes desktop --build-only` (the rebuild step update skips),
|
||||
@@ -38,8 +39,8 @@ use crate::events::{BootstrapEvent, LogStream, StageInfo, StageState};
|
||||
/// hermes_cli/main.py (sys.exit(2)). We surface a targeted message for this.
|
||||
const UPDATE_EXIT_CONCURRENT: i32 = 2;
|
||||
|
||||
/// How long to wait for the old desktop process to release the venv shim
|
||||
/// before giving up and letting `hermes update`'s own guard decide.
|
||||
/// How long to wait for the old desktop process to release files under the
|
||||
/// install tree before giving up and letting `hermes update`'s own guard decide.
|
||||
const DESKTOP_EXIT_WAIT: Duration = Duration::from_secs(20);
|
||||
const DESKTOP_EXIT_POLL: Duration = Duration::from_millis(500);
|
||||
|
||||
@@ -150,8 +151,10 @@ async fn run_update(app: AppHandle) -> Result<()> {
|
||||
// ---- pre-step: wait for the old desktop to die -----------------------
|
||||
// The desktop exec'd us then called app.exit(), but process teardown is
|
||||
// async on Windows. If it still holds the venv shim, `hermes update`
|
||||
// aborts with exit 2. Give it a bounded window to clear.
|
||||
wait_for_venv_free(&install_root, &app).await;
|
||||
// aborts with exit 2. If it still holds the packaged app.asar,
|
||||
// install.ps1's repair/re-clone path cannot move/remove the install tree.
|
||||
// Give both handles a bounded window to clear.
|
||||
wait_for_install_locks_free(&install_root, &app, "update").await;
|
||||
|
||||
// ---- stage 1: hermes update -----------------------------------------
|
||||
// Pass --branch so `hermes update` targets the branch this installer was
|
||||
@@ -173,8 +176,8 @@ async fn run_update(app: AppHandle) -> Result<()> {
|
||||
vec!["update".into(), "--yes".into(), "--gateway".into()];
|
||||
// --force skips `hermes update`'s Windows running-exe guard (which would
|
||||
// `sys.exit(2)` and dead-end the handoff). By contract the desktop has
|
||||
// already exited and waited for the venv shim to unlock before launching
|
||||
// us, and wait_for_venv_free below force-kills any straggler — so by the
|
||||
// already exited and waited for the install locks to clear before launching
|
||||
// us, and wait_for_install_locks_free below force-kills any straggler — so by the
|
||||
// time `hermes update` runs there is no legitimate hermes.exe to protect,
|
||||
// and the guard would only produce a false "Hermes is still running" stop.
|
||||
update_args.push("--force".into());
|
||||
@@ -391,48 +394,57 @@ async fn run_update(app: AppHandle) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Poll until the venv shim is no longer locked (Windows) or a bounded timeout
|
||||
/// elapses. On non-Windows this is a short fixed grace since file locking
|
||||
/// isn't the failure mode there.
|
||||
async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) {
|
||||
let shim = venv_hermes(install_root);
|
||||
/// Poll until the venv shim AND packaged desktop app bundle are no longer locked
|
||||
/// (Windows) or a bounded timeout elapses. On non-Windows this is a short fixed
|
||||
/// grace since file locking isn't the failure mode there.
|
||||
pub(crate) async fn wait_for_install_locks_free(install_root: &Path, app: &AppHandle, stage: &str) {
|
||||
let lock_targets = install_lock_probe_paths(install_root);
|
||||
let deadline = Instant::now() + DESKTOP_EXIT_WAIT;
|
||||
|
||||
emit_log(app, Some("update"), LogStream::Stdout, "[update] waiting for Hermes to exit…");
|
||||
emit_log(app, Some(stage), LogStream::Stdout, "[handoff] waiting for Hermes to exit…");
|
||||
|
||||
loop {
|
||||
if !is_locked(&shim) {
|
||||
let locked = locked_paths(&lock_targets);
|
||||
if locked.is_empty() {
|
||||
return;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
// Last resort: a backend hermes.exe (or a grandchild it spawned)
|
||||
// is still holding the shim. The desktop should have reaped its
|
||||
// tree before handing off, but SIGTERM races / detached
|
||||
// grandchildren / AV handles can leave a straggler. Rather than
|
||||
// "proceed anyway" straight into uv's "Access is denied", force-kill
|
||||
// every hermes.exe except ourselves, then give the OS a beat to
|
||||
// unload the image.
|
||||
// Last resort: a backend hermes.exe (or the desktop Hermes.exe
|
||||
// itself) is still holding one of the update-sensitive files. The
|
||||
// desktop should have reaped its tree before handing off, but
|
||||
// SIGTERM races / detached grandchildren / AV handles can leave a
|
||||
// straggler. Rather than "proceed anyway" straight into uv's
|
||||
// "Access is denied" or install.ps1's locked app.asar failure,
|
||||
// force-kill every Hermes.exe except ourselves, then give the OS a
|
||||
// beat to unload the image.
|
||||
emit_log(
|
||||
app,
|
||||
Some("update"),
|
||||
Some(stage),
|
||||
LogStream::Stdout,
|
||||
"[update] Hermes still holding the venv shim; force-killing stragglers…",
|
||||
&format!(
|
||||
"[handoff] Hermes still holding install files ({}); force-killing stragglers…",
|
||||
format_locked_paths(&locked)
|
||||
),
|
||||
);
|
||||
force_kill_other_hermes();
|
||||
tokio::time::sleep(Duration::from_millis(800)).await;
|
||||
if !is_locked(&shim) {
|
||||
let locked_after_kill = locked_paths(&lock_targets);
|
||||
if locked_after_kill.is_empty() {
|
||||
emit_log(
|
||||
app,
|
||||
Some("update"),
|
||||
Some(stage),
|
||||
LogStream::Stdout,
|
||||
"[update] venv shim freed after force-kill",
|
||||
"[handoff] install files freed after force-kill",
|
||||
);
|
||||
} else {
|
||||
emit_log(
|
||||
app,
|
||||
Some("update"),
|
||||
Some(stage),
|
||||
LogStream::Stdout,
|
||||
"[update] venv shim still locked; proceeding (--force + quarantine will handle it)",
|
||||
&format!(
|
||||
"[handoff] install files still locked ({}); proceeding (--force + quarantine will handle it)",
|
||||
format_locked_paths(&locked_after_kill)
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -441,13 +453,44 @@ async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
fn install_lock_probe_paths(install_root: &Path) -> Vec<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 `wait_for_venv_free`,
|
||||
/// Safe w.r.t. our own update child: this runs inside the install-lock wait,
|
||||
/// which completes BEFORE we spawn `venv\Scripts\hermes.exe update`. At this
|
||||
/// point no update-driven hermes.exe exists yet, so the only hermes.exe images
|
||||
/// are stragglers from the old desktop — exactly what we want gone. (`/FI PID
|
||||
@@ -891,6 +934,29 @@ mod tests {
|
||||
assert!(!is_locked(Path::new("/nonexistent/does/not/exist/xyz")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_probe_paths_include_desktop_app_payload() {
|
||||
let root = Path::new("/x/hermes-agent");
|
||||
let probes = install_lock_probe_paths(root);
|
||||
|
||||
assert!(
|
||||
probes.iter().any(|p| p == &venv_hermes(root)),
|
||||
"venv shim remains part of the update lock probe"
|
||||
);
|
||||
assert!(
|
||||
probes.iter().any(|p| p.ends_with(Path::new("resources/app.asar"))),
|
||||
"packaged app.asar must be probed so repair/re-clone waits for the old desktop to exit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_paths_ignores_missing_payloads() {
|
||||
let root = Path::new("/nonexistent/hermes-agent");
|
||||
let probes = install_lock_probe_paths(root);
|
||||
|
||||
assert!(locked_paths(&probes).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_update_branch_from_space_or_equals_args() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
const _READY_RE = /^HERMES_DASHBOARD_READY port=(\d+)/m
|
||||
|
||||
/**
|
||||
* Watch a child process's stdout for the `HERMES_DASHBOARD_READY port=<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 }
|
||||
@@ -0,0 +1,174 @@
|
||||
'use strict'
|
||||
|
||||
// Resolve git-worktree relationships for a set of session cwds, reading git's
|
||||
// on-disk metadata directly (no `git` spawn per path):
|
||||
//
|
||||
// - A normal checkout has a `.git` DIRECTORY at its root → it's the main
|
||||
// worktree; its repo root IS that directory's parent.
|
||||
// - A linked worktree has a `.git` FILE: `gitdir: <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
|
||||
}
|
||||
@@ -35,12 +35,13 @@ const {
|
||||
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
|
||||
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
|
||||
const { adoptServedDashboardToken } = require('./dashboard-token.cjs')
|
||||
const { PortPool } = require('./port-pool.cjs')
|
||||
const { waitForDashboardPort } = require('./backend-ready.cjs')
|
||||
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
|
||||
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
|
||||
const { buildDesktopBackendEnv } = 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 {
|
||||
buildPosixCleanupScript,
|
||||
@@ -111,12 +112,6 @@ if (USER_DATA_OVERRIDE) {
|
||||
app.setPath('userData', resolvedUserData)
|
||||
}
|
||||
|
||||
const PORT_FLOOR = 9120
|
||||
const PORT_CEILING = 9199
|
||||
// In-process port reservations that close the pickPort() TOCTOU window where
|
||||
// two concurrent backend spawns could be handed the same port. See
|
||||
// port-pool.cjs for the full rationale.
|
||||
const portPool = new PortPool(PORT_FLOOR, PORT_CEILING)
|
||||
const DEV_SERVER = process.env.HERMES_DESKTOP_DEV_SERVER
|
||||
const IS_PACKAGED = app.isPackaged
|
||||
const IS_MAC = process.platform === 'darwin'
|
||||
@@ -1840,6 +1835,44 @@ async function applyUpdates(opts = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handOffWindowsBootstrapRecovery(reason) {
|
||||
if (!IS_WINDOWS || !IS_PACKAGED) return false
|
||||
|
||||
const updater = resolveUpdaterBinary()
|
||||
if (!updater) return false
|
||||
|
||||
const updateRoot = resolveUpdateRoot()
|
||||
const { branch: configuredBranch } = readDesktopUpdateConfig()
|
||||
const branch = directoryExists(path.join(updateRoot, '.git'))
|
||||
? await resolveHealedBranch(updateRoot, configuredBranch || DEFAULT_UPDATE_BRANCH)
|
||||
: configuredBranch || DEFAULT_UPDATE_BRANCH
|
||||
const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin')
|
||||
const venvHermes = path.join(venvBin, IS_WINDOWS ? 'hermes.exe' : 'hermes')
|
||||
const updaterArgs = fileExists(venvHermes) ? ['--update', '--branch', branch] : ['--repair', '--branch', branch]
|
||||
|
||||
await releaseBackendLockForUpdate(updateRoot)
|
||||
|
||||
const child = spawn(updater, updaterArgs, {
|
||||
cwd: HERMES_HOME,
|
||||
env: {
|
||||
...process.env,
|
||||
HERMES_HOME,
|
||||
PATH: [path.join(HERMES_HOME, 'node', 'bin'), venvBin, process.env.PATH].filter(Boolean).join(path.delimiter)
|
||||
},
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: false
|
||||
})
|
||||
child.unref()
|
||||
|
||||
rememberLog(`[bootstrap] handed off ${reason} recovery to updater: ${updater} ${updaterArgs.join(' ')}; exiting desktop to release app.asar`)
|
||||
setTimeout(() => {
|
||||
app.quit()
|
||||
}, 600)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Resolve the hermes CLI to drive an in-app update: prefer the venv shim in
|
||||
// the install we're updating, fall back to `hermes` on PATH.
|
||||
function resolveHermesCliBinary(updateRoot) {
|
||||
@@ -2437,6 +2470,14 @@ async function ensureRuntime(backend) {
|
||||
if (backend.kind === 'bootstrap-needed') {
|
||||
rememberLog('[bootstrap] no Hermes install found; starting first-launch bootstrap')
|
||||
|
||||
if (await handOffWindowsBootstrapRecovery('bootstrap-needed')) {
|
||||
const handoffError = new Error('Hermes recovery was handed off to Hermes Setup. The desktop will restart when recovery completes.')
|
||||
handoffError.isBootstrapFailure = true
|
||||
handoffError.bootstrapHandedOff = true
|
||||
bootstrapFailure = handoffError
|
||||
throw handoffError
|
||||
}
|
||||
|
||||
// Eagerly flip the bootstrap UI state to 'active' so the renderer
|
||||
// shows the install overlay BEFORE the runner finishes fetching the
|
||||
// manifest (which on slow networks can take tens of seconds and would
|
||||
@@ -2566,24 +2607,6 @@ async function ensureRuntime(backend) {
|
||||
return backend
|
||||
}
|
||||
|
||||
function isPortAvailable(port) {
|
||||
return new Promise(resolve => {
|
||||
const server = net.createServer()
|
||||
server.once('error', () => resolve(false))
|
||||
server.once('listening', () => {
|
||||
server.close(() => resolve(true))
|
||||
})
|
||||
server.listen(port, '127.0.0.1')
|
||||
})
|
||||
}
|
||||
|
||||
async function pickPort() {
|
||||
const port = await portPool.reserve(isPortAvailable)
|
||||
if (port === null) {
|
||||
throw new Error(`No free localhost port in ${PORT_FLOOR}-${PORT_CEILING}`)
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
function fetchJson(url, token, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -4661,25 +4684,14 @@ async function spawnPoolBackend(profile, entry) {
|
||||
}
|
||||
}
|
||||
|
||||
const port = await pickPort()
|
||||
const token = crypto.randomBytes(32).toString('base64url')
|
||||
// --profile wins over the inherited HERMES_HOME env (see _apply_profile_override
|
||||
// step 3 in hermes_cli/main.py), so the child re-homes to this profile.
|
||||
const dashboardArgs = ['--profile', profile, 'dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)]
|
||||
let backend
|
||||
let hermesCwd
|
||||
let webDist
|
||||
try {
|
||||
backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
|
||||
hermesCwd = resolveHermesCwd()
|
||||
webDist = resolveWebDist()
|
||||
} catch (error) {
|
||||
// These run before the child exists / its exit handler is attached, so a
|
||||
// throw here would otherwise leak the reservation and slowly exhaust the
|
||||
// 9120-9199 range across switch cycles in one app session.
|
||||
portPool.release(port)
|
||||
throw error
|
||||
}
|
||||
// --port 0: the OS assigns an ephemeral port; the child announces it on stdout.
|
||||
const dashboardArgs = ['--profile', profile, 'dashboard', '--no-open', '--host', '127.0.0.1', '--port', '0']
|
||||
const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
|
||||
const hermesCwd = resolveHermesCwd()
|
||||
const webDist = resolveWebDist()
|
||||
|
||||
rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`)
|
||||
|
||||
@@ -4707,7 +4719,6 @@ async function spawnPoolBackend(profile, entry) {
|
||||
})
|
||||
)
|
||||
entry.process = child
|
||||
entry.port = port
|
||||
entry.token = token
|
||||
|
||||
child.stdout.on('data', rememberLog)
|
||||
@@ -4721,13 +4732,11 @@ async function spawnPoolBackend(profile, entry) {
|
||||
child.once('error', error => {
|
||||
rememberLog(`Hermes backend for profile "${profile}" failed to start: ${error.message}`)
|
||||
backendPool.delete(profile)
|
||||
portPool.release(port)
|
||||
rejectStart?.(error)
|
||||
})
|
||||
child.once('exit', (code, signal) => {
|
||||
rememberLog(`Hermes backend for profile "${profile}" exited (${signal || code})`)
|
||||
backendPool.delete(profile)
|
||||
portPool.release(port)
|
||||
if (!ready) {
|
||||
rejectStart?.(
|
||||
new Error(`Hermes backend for profile "${profile}" exited before it became ready (${signal || code}).`)
|
||||
@@ -4735,6 +4744,10 @@ async function spawnPoolBackend(profile, entry) {
|
||||
}
|
||||
})
|
||||
|
||||
// Discover the ephemeral port the child bound to
|
||||
const port = await Promise.race([waitForDashboardPort(child), startFailed])
|
||||
entry.port = port
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
await Promise.race([waitForHermes(baseUrl, token), startFailed])
|
||||
ready = true
|
||||
@@ -4762,7 +4775,6 @@ function stopPoolBackend(profile) {
|
||||
const entry = backendPool.get(profile)
|
||||
if (!entry) return
|
||||
backendPool.delete(profile)
|
||||
if (entry.port) portPool.release(entry.port)
|
||||
if (entry.process && !entry.process.killed) {
|
||||
try {
|
||||
entry.process.kill('SIGTERM')
|
||||
@@ -4848,11 +4860,6 @@ async function startHermes() {
|
||||
}
|
||||
if (connectionPromise) return connectionPromise
|
||||
|
||||
// Hoisted so the outer .catch can release a port reserved by pickPort() when
|
||||
// a throw (e.g. ensureRuntime failing) happens before the child's exit
|
||||
// handler is attached. Stays null on the remote path (no port picked).
|
||||
let reservedPort = null
|
||||
|
||||
connectionPromise = (async () => {
|
||||
await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8)
|
||||
// Resolve for the desktop's primary profile so a per-profile remote
|
||||
@@ -4880,11 +4887,9 @@ async function startHermes() {
|
||||
}
|
||||
}
|
||||
|
||||
await advanceBootProgress('backend.port', 'Finding an open local port', 16)
|
||||
const port = await pickPort()
|
||||
reservedPort = port
|
||||
const token = crypto.randomBytes(32).toString('base64url')
|
||||
const dashboardArgs = ['dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)]
|
||||
// --port 0: the OS assigns an ephemeral port; the child announces it on stdout.
|
||||
const dashboardArgs = ['dashboard', '--no-open', '--host', '127.0.0.1', '--port', '0']
|
||||
// Pin the desktop's chosen profile via the global --profile flag. This is
|
||||
// deterministic (it wins over the sticky ~/.hermes/active_profile file) and
|
||||
// resolves HERMES_HOME the same way `hermes -p <name>` does on the CLI. An
|
||||
@@ -4951,7 +4956,6 @@ async function startHermes() {
|
||||
)
|
||||
hermesProcess = null
|
||||
connectionPromise = null
|
||||
portPool.release(port)
|
||||
sendBackendExit({ code: null, signal: null, error: error.message })
|
||||
rejectBackendStart?.(error)
|
||||
})
|
||||
@@ -4959,7 +4963,6 @@ async function startHermes() {
|
||||
rememberLog(`Hermes backend exited (${signal || code})`)
|
||||
hermesProcess = null
|
||||
connectionPromise = null
|
||||
portPool.release(port)
|
||||
sendBackendExit({ code, signal })
|
||||
if (!backendReady) {
|
||||
const message = `Hermes backend exited before it became ready (${signal || code}).`
|
||||
@@ -4980,6 +4983,10 @@ async function startHermes() {
|
||||
}
|
||||
})
|
||||
|
||||
await advanceBootProgress('backend.port', 'Waiting for Hermes backend to launch', 86)
|
||||
// Discover the ephemeral port the child bound to
|
||||
const port = await Promise.race([waitForDashboardPort(hermesProcess), backendStartFailed])
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
await advanceBootProgress('backend.wait', 'Waiting for Hermes backend to become ready', 90)
|
||||
await Promise.race([waitForHermes(baseUrl, token), backendStartFailed])
|
||||
@@ -5019,7 +5026,6 @@ async function startHermes() {
|
||||
{ allowDecrease: true }
|
||||
)
|
||||
connectionPromise = null
|
||||
portPool.release(reservedPort)
|
||||
throw error
|
||||
})
|
||||
|
||||
@@ -5995,6 +6001,8 @@ ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dir
|
||||
|
||||
ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath))
|
||||
|
||||
ipcMain.handle('hermes:fs:worktrees', async (_event, cwds) => worktreesForIpc(cwds))
|
||||
|
||||
ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
|
||||
if (!nodePty) {
|
||||
throw new Error('PTY support is unavailable. Reinstall desktop dependencies and restart Hermes.')
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* In-process port reservation pool for the desktop backend launcher.
|
||||
*
|
||||
* pickPort() probes a localhost port with a throwaway server and closes it
|
||||
* before the real bind happens in a separate Python child. Between that probe
|
||||
* and the child's bind there is a TOCTOU window: a second concurrent spawn
|
||||
* (the primary backend racing a pool backend) can be handed the SAME port, and
|
||||
* one then dies with EADDRINUSE ("address already in use" -> "Object has been
|
||||
* destroyed" boot loop). Reserving the chosen port in THIS process until the
|
||||
* child exits closes that window.
|
||||
*
|
||||
* The OS bind remains the source of truth; this only deconflicts racers inside
|
||||
* this process — it can't stop a foreign squatter, which the probe + the
|
||||
* EADDRINUSE self-heal still cover.
|
||||
*
|
||||
* The pool is dependency-injected (the availability probe is passed in) and
|
||||
* free of Electron/Node socket I/O, so it is unit-tested without real sockets
|
||||
* (see port-pool.test.cjs).
|
||||
*/
|
||||
class PortPool {
|
||||
/**
|
||||
* @param {number} floor inclusive lowest port to hand out
|
||||
* @param {number} ceiling inclusive highest port to hand out
|
||||
*/
|
||||
constructor(floor, ceiling) {
|
||||
this.floor = floor
|
||||
this.ceiling = ceiling
|
||||
this._reserved = new Set()
|
||||
}
|
||||
|
||||
/** @returns {boolean} whether `port` is currently reserved in-process. */
|
||||
has(port) {
|
||||
return this._reserved.has(port)
|
||||
}
|
||||
|
||||
/** Release a previously reserved port. No-op if it was not reserved. */
|
||||
release(port) {
|
||||
this._reserved.delete(port)
|
||||
}
|
||||
|
||||
/** Drop all reservations. */
|
||||
clear() {
|
||||
this._reserved.clear()
|
||||
}
|
||||
|
||||
/** @returns {number} count of currently reserved ports. */
|
||||
get size() {
|
||||
return this._reserved.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserve and return the lowest port in [floor, ceiling] that is neither
|
||||
* already reserved in-process nor rejected by `isAvailable(port)`, or null
|
||||
* if every port is taken. `isAvailable` may be sync (boolean) or async
|
||||
* (Promise<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 }
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* Tests for electron/port-pool.cjs.
|
||||
*
|
||||
* Run with: node --test electron/port-pool.test.cjs
|
||||
*
|
||||
* PortPool is the in-process reservation that closes the pickPort() TOCTOU
|
||||
* window. These cover selection order, skipping reserved/unavailable ports,
|
||||
* release/reuse, exhaustion, and async probes — without real sockets.
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { PortPool } = require('./port-pool.cjs')
|
||||
|
||||
const allFree = () => true
|
||||
|
||||
test('reserve returns the lowest free port and reserves it', async () => {
|
||||
const pool = new PortPool(9120, 9199)
|
||||
const port = await pool.reserve(allFree)
|
||||
assert.equal(port, 9120)
|
||||
assert.ok(pool.has(9120))
|
||||
assert.equal(pool.size, 1)
|
||||
})
|
||||
|
||||
test('reserve skips ports already reserved in-process', async () => {
|
||||
const pool = new PortPool(9120, 9199)
|
||||
const first = await pool.reserve(allFree)
|
||||
const second = await pool.reserve(allFree)
|
||||
assert.equal(first, 9120)
|
||||
assert.equal(second, 9121)
|
||||
})
|
||||
|
||||
test('reserve skips ports the probe rejects', async () => {
|
||||
const pool = new PortPool(9120, 9199)
|
||||
const busy = new Set([9120, 9121])
|
||||
const port = await pool.reserve(p => !busy.has(p))
|
||||
assert.equal(port, 9122)
|
||||
})
|
||||
|
||||
test('reserve returns null when every port is taken', async () => {
|
||||
const pool = new PortPool(9120, 9121)
|
||||
await pool.reserve(allFree)
|
||||
await pool.reserve(allFree)
|
||||
assert.equal(await pool.reserve(allFree), null)
|
||||
})
|
||||
|
||||
test('release frees a reserved port for reuse', async () => {
|
||||
const pool = new PortPool(9120, 9120)
|
||||
assert.equal(await pool.reserve(allFree), 9120)
|
||||
assert.equal(await pool.reserve(allFree), null) // exhausted
|
||||
pool.release(9120)
|
||||
assert.ok(!pool.has(9120))
|
||||
assert.equal(await pool.reserve(allFree), 9120) // reusable
|
||||
})
|
||||
|
||||
test('release is a no-op for an unreserved port', () => {
|
||||
const pool = new PortPool(9120, 9199)
|
||||
pool.release(9120)
|
||||
assert.equal(pool.size, 0)
|
||||
})
|
||||
|
||||
test('reserve awaits an async probe', async () => {
|
||||
const pool = new PortPool(9120, 9199)
|
||||
const busy = new Set([9120])
|
||||
const port = await pool.reserve(p => Promise.resolve(!busy.has(p)))
|
||||
assert.equal(port, 9121)
|
||||
})
|
||||
|
||||
test('clear drops all reservations', async () => {
|
||||
const pool = new PortPool(9120, 9199)
|
||||
await pool.reserve(allFree)
|
||||
await pool.reserve(allFree)
|
||||
assert.equal(pool.size, 2)
|
||||
pool.clear()
|
||||
assert.equal(pool.size, 0)
|
||||
})
|
||||
@@ -54,6 +54,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
getRecentLogs: () => ipcRenderer.invoke('hermes:logs:recent'),
|
||||
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
|
||||
gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath),
|
||||
worktrees: cwds => ipcRenderer.invoke('hermes:fs:worktrees', cwds),
|
||||
terminal: {
|
||||
dispose: id => ipcRenderer.invoke('hermes:terminal:dispose', id),
|
||||
resize: (id, size) => ipcRenderer.invoke('hermes:terminal:resize', id, size),
|
||||
|
||||
@@ -42,6 +42,9 @@ test('intentional or interactive desktop child processes stay documented', () =>
|
||||
const source = readElectronFile('main.cjs')
|
||||
|
||||
assert.match(source, /windowsHide: false/)
|
||||
assert.match(source, /handOffWindowsBootstrapRecovery/)
|
||||
assert.match(source, /'--repair', '--branch'/)
|
||||
assert.match(source, /'--update', '--branch'/)
|
||||
assert.match(source, /nodePty\.spawn\(command, args/)
|
||||
assert.match(source, /spawn\('cmd\.exe', \['\/c', 'start'/)
|
||||
})
|
||||
|
||||
@@ -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/port-pool.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs",
|
||||
"typecheck": "tsc -p . --noEmit",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
@@ -90,6 +90,7 @@
|
||||
"react-router-dom": "^7.17.0",
|
||||
"react-shiki": "^0.9.3",
|
||||
"remark-math": "^6.0.0",
|
||||
"remend": "^1.3.0",
|
||||
"shiki": "^4.0.2",
|
||||
"streamdown": "^2.5.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
@@ -98,6 +99,7 @@
|
||||
"unicode-animations": "^1.0.3",
|
||||
"unified": "^11.0.5",
|
||||
"unist-util-visit-parents": "^6.0.2",
|
||||
"use-stick-to-bottom": "^1.1.6",
|
||||
"vfile": "^6.0.3",
|
||||
"web-haptics": "^0.0.6"
|
||||
},
|
||||
|
||||
@@ -24,6 +24,7 @@ afterEach(cleanup)
|
||||
// state stays stale while the DOM already holds the text.
|
||||
function Harness({
|
||||
busy = false,
|
||||
disabled = false,
|
||||
queued = [],
|
||||
onSubmit,
|
||||
onQueue,
|
||||
@@ -31,6 +32,7 @@ function Harness({
|
||||
onDrain
|
||||
}: {
|
||||
busy?: boolean
|
||||
disabled?: boolean
|
||||
queued?: readonly string[]
|
||||
onSubmit: (text: string) => void
|
||||
onQueue: (text: string) => void
|
||||
@@ -52,6 +54,10 @@ function Harness({
|
||||
}
|
||||
|
||||
const submitDraft = () => {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const editor = editorRef.current
|
||||
if (editor) {
|
||||
const domText = composerPlainText(editor)
|
||||
@@ -84,6 +90,10 @@ 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()
|
||||
|
||||
@@ -186,4 +196,23 @@ 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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -43,13 +43,16 @@ import {
|
||||
import {
|
||||
$queuedPromptsBySession,
|
||||
enqueueQueuedPrompt,
|
||||
MAX_AUTO_DRAIN_ATTEMPTS,
|
||||
migrateQueuedPrompts,
|
||||
promoteQueuedPrompt,
|
||||
type QueuedPromptEntry,
|
||||
removeQueuedPrompt,
|
||||
shouldAutoDrainOnSettle,
|
||||
shouldAutoDrain,
|
||||
updateQueuedPrompt
|
||||
} from '@/store/composer-queue'
|
||||
import { $statusItemsBySession } from '@/store/composer-status'
|
||||
import { notify } from '@/store/notifications'
|
||||
import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session'
|
||||
import { $threadScrolledUp } from '@/store/thread-scroll'
|
||||
import { useTheme } from '@/themes'
|
||||
@@ -174,7 +177,6 @@ export function ChatBar({
|
||||
const queuedPromptsBySession = useStore($queuedPromptsBySession)
|
||||
const statusItemsBySession = useStore($statusItemsBySession)
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
const sessionMessages = useStore($messages)
|
||||
const activeQueueSessionKey = queueSessionKey || sessionId || null
|
||||
|
||||
const queuedPrompts = useMemo(
|
||||
@@ -197,11 +199,14 @@ export function ChatBar({
|
||||
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)
|
||||
@@ -242,6 +247,8 @@ export function ChatBar({
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const newSessionPlaceholders = t.composer.newSessionPlaceholders
|
||||
const followUpPlaceholders = t.composer.followUpPlaceholders
|
||||
const reconnecting = gatewayState === 'closed' || gatewayState === 'error'
|
||||
const inputDisabled = disabled && !reconnecting
|
||||
|
||||
// Resting placeholder: a starter for brand-new sessions, a continuation for
|
||||
// existing ones. Picked once and only re-rolled when we genuinely move to a
|
||||
@@ -272,11 +279,13 @@ export function ChatBar({
|
||||
setRestingPlaceholder(pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders))
|
||||
}, [followUpPlaceholders, newSessionPlaceholders, sessionId])
|
||||
|
||||
// When the bar is disabled it's because the gateway isn't open. Distinguish a
|
||||
// cold start ("Starting Hermes...") from a dropped connection we're trying to
|
||||
// restore (e.g. after the Mac slept) so the stuck state reads as recoverable.
|
||||
// When the transport is disabled it's because the gateway isn't open.
|
||||
// Distinguish a cold start ("Starting Hermes...") from a dropped connection
|
||||
// we're trying to restore. During reconnect, keep the textbox editable so a
|
||||
// flaky network doesn't block drafting; only submit/backend actions stay
|
||||
// disabled until the gateway is open again.
|
||||
const placeholder = disabled
|
||||
? gatewayState === 'closed' || gatewayState === 'error'
|
||||
? reconnecting
|
||||
? t.composer.placeholderReconnecting
|
||||
: t.composer.placeholderStarting
|
||||
: restingPlaceholder
|
||||
@@ -318,13 +327,13 @@ export function ChatBar({
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!disabled) {
|
||||
if (!inputDisabled) {
|
||||
focusInput()
|
||||
}
|
||||
}, [disabled, focusInput, focusKey, focusRequestId])
|
||||
}, [focusInput, focusKey, focusRequestId, inputDisabled])
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) {
|
||||
if (inputDisabled) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -344,7 +353,7 @@ export function ChatBar({
|
||||
offFocus()
|
||||
offInsert()
|
||||
}
|
||||
}, [appendExternalText, disabled])
|
||||
}, [appendExternalText, inputDisabled])
|
||||
|
||||
// Keep draftRef in sync with the assistant-ui composer state for callers
|
||||
// that read the latest text outside the React render cycle. We don't push
|
||||
@@ -866,7 +875,9 @@ export function ChatBar({
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
|
||||
const history = deriveUserHistory(sessionMessages, chatMessageText)
|
||||
// $messages is read imperatively (not subscribed) so the composer
|
||||
// doesn't re-render on every streaming delta flush.
|
||||
const history = deriveUserHistory($messages.get(), chatMessageText)
|
||||
const entry = browseBackward(sessionId, currentDraft, history)
|
||||
|
||||
if (entry !== null) {
|
||||
@@ -891,7 +902,7 @@ export function ChatBar({
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
|
||||
const history = deriveUserHistory(sessionMessages, chatMessageText)
|
||||
const history = deriveUserHistory($messages.get(), chatMessageText)
|
||||
const result = browseForward(sessionId, history)
|
||||
|
||||
if (result !== null) {
|
||||
@@ -927,6 +938,10 @@ export function ChatBar({
|
||||
const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
|
||||
const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0
|
||||
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!busy && !hasLivePayload && queuedPrompts.length > 0) {
|
||||
void drainNextQueued()
|
||||
|
||||
@@ -1325,6 +1340,7 @@ export function ChatBar({
|
||||
return false
|
||||
}
|
||||
|
||||
drainFailuresRef.current.delete(entry.id)
|
||||
removeQueuedPrompt(activeQueueSessionKey, entry.id)
|
||||
resetBrowseState(sessionId)
|
||||
|
||||
@@ -1336,16 +1352,17 @@ export function ChatBar({
|
||||
[activeQueueSessionKey, onSubmit, queuedPrompts, sessionId]
|
||||
)
|
||||
|
||||
const drainNextQueued = useCallback(
|
||||
() =>
|
||||
runDrain(entries => {
|
||||
const skip = queueEdit?.entryId
|
||||
const pickDrainHead = useCallback(
|
||||
(entries: QueuedPromptEntry[]) => {
|
||||
const skip = queueEditRef.current?.entryId
|
||||
|
||||
return skip ? entries.find(e => e.id !== skip) : entries[0]
|
||||
}),
|
||||
[queueEdit, runDrain]
|
||||
return skip ? entries.find(e => e.id !== skip) : entries[0]
|
||||
},
|
||||
[] // reads the edit id off a ref so the lock-holder always sees the latest
|
||||
)
|
||||
|
||||
const drainNextQueued = useCallback(() => runDrain(pickDrainHead), [pickDrainHead, runDrain])
|
||||
|
||||
const sendQueuedNow = useCallback(
|
||||
(id: string) => {
|
||||
if (!activeQueueSessionKey || id === queueEdit?.entryId) {
|
||||
@@ -1363,30 +1380,76 @@ export function ChatBar({
|
||||
return true
|
||||
}
|
||||
|
||||
// A manual send clears the auto-drain backoff so a stuck entry the user
|
||||
// taps gets a fresh attempt (and re-enables auto-retry on success).
|
||||
drainFailuresRef.current.delete(id)
|
||||
|
||||
return runDrain(entries => entries.find(e => e.id === id))
|
||||
},
|
||||
[activeQueueSessionKey, busy, onCancel, queueEdit, runDrain]
|
||||
)
|
||||
|
||||
// Auto-drain on busy → false (turn settled). Queued turns always flow once
|
||||
// the session is idle again — whether the turn finished naturally or the
|
||||
// user interrupted it. Interrupting to reach a queued message is the whole
|
||||
// point of the queue, so we never suppress the drain. To cancel queued
|
||||
// turns, the user deletes them from the panel.
|
||||
useEffect(() => {
|
||||
const wasBusy = previousBusyRef.current
|
||||
previousBusyRef.current = busy
|
||||
|
||||
if (
|
||||
shouldAutoDrainOnSettle({
|
||||
isBusy: busy,
|
||||
queueLength: queuedPrompts.length,
|
||||
wasBusy
|
||||
})
|
||||
) {
|
||||
void drainNextQueued()
|
||||
// Edge-independent auto-drain: send the head whenever the session is idle and
|
||||
// the queue is non-empty, bounding retries so a thrown/rejected onSubmit (e.g.
|
||||
// a stale-session 404) can't strand the entry permanently nor spin-loop. The
|
||||
// drain lock serializes sends; a remount/reconnect resets the failure counts.
|
||||
const autoDrainNext = useCallback(() => {
|
||||
if (busy || drainingQueueRef.current || !activeQueueSessionKey) {
|
||||
return
|
||||
}
|
||||
}, [busy, drainNextQueued, queuedPrompts.length])
|
||||
|
||||
const entry = pickDrainHead(queuedPrompts)
|
||||
|
||||
if (!entry || (drainFailuresRef.current.get(entry.id) ?? 0) >= MAX_AUTO_DRAIN_ATTEMPTS) {
|
||||
return
|
||||
}
|
||||
|
||||
const onFail = () => {
|
||||
const fails = (drainFailuresRef.current.get(entry.id) ?? 0) + 1
|
||||
drainFailuresRef.current.set(entry.id, fails)
|
||||
|
||||
if (fails >= MAX_AUTO_DRAIN_ATTEMPTS) {
|
||||
notify({
|
||||
id: 'composer-queue-stuck',
|
||||
kind: 'error',
|
||||
title: t.composer.queueStuckTitle,
|
||||
message: t.composer.queueStuckBody
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
void runDrain(() => entry)
|
||||
.then(sent => {
|
||||
if (!sent) {
|
||||
onFail()
|
||||
}
|
||||
})
|
||||
.catch(onFail)
|
||||
}, [activeQueueSessionKey, busy, pickDrainHead, queuedPrompts, runDrain, t])
|
||||
|
||||
// Re-key on a runtime session-id change. A stable stored id (queueSessionKey)
|
||||
// never churns, so a change there is a real session switch and must NOT
|
||||
// migrate; only the runtime-derived key (queueSessionKey falsy → key is
|
||||
// sessionId) churns on a backend bounce/resume of the same conversation.
|
||||
useEffect(() => {
|
||||
const prev = prevQueueKeyRef.current
|
||||
prevQueueKeyRef.current = activeQueueSessionKey
|
||||
|
||||
if (queueSessionKey || !prev || !activeQueueSessionKey || prev === activeQueueSessionKey) {
|
||||
return
|
||||
}
|
||||
|
||||
migrateQueuedPrompts(prev, activeQueueSessionKey)
|
||||
}, [activeQueueSessionKey, queueSessionKey])
|
||||
|
||||
// Queued turns flow whenever the session is idle — on the busy→false settle
|
||||
// edge, on mount/reconnect, and after a re-key — so a swallowed edge can't
|
||||
// strand them. To cancel queued turns, the user deletes them from the panel.
|
||||
useEffect(() => {
|
||||
if (shouldAutoDrain({ isBusy: busy, queueLength: queuedPrompts.length })) {
|
||||
autoDrainNext()
|
||||
}
|
||||
}, [autoDrainNext, busy, queuedPrompts.length])
|
||||
|
||||
// Queue-edit cleanup: on session swap the scope effect already stashed the
|
||||
// edit snapshot; only restore into the composer when still on the same scope.
|
||||
@@ -1421,6 +1484,10 @@ export function ChatBar({
|
||||
}
|
||||
|
||||
const submitDraft = () => {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
// Source the text from the DOM editor, not React state. The AUI composer
|
||||
// state (`draft`) and the derived `hasComposerPayload` lag the DOM by a
|
||||
// render, so on fast typing or IME composition the final keystroke(s) may
|
||||
@@ -1601,6 +1668,7 @@ 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"
|
||||
@@ -1611,7 +1679,7 @@ export function ChatBar({
|
||||
stacked && 'pl-3',
|
||||
stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1'
|
||||
)}
|
||||
contentEditable={!disabled}
|
||||
contentEditable={!inputDisabled}
|
||||
data-placeholder={placeholder}
|
||||
data-slot={RICH_INPUT_SLOT}
|
||||
onBlur={() => window.setTimeout(closeTrigger, 80)}
|
||||
@@ -1741,7 +1809,6 @@ export function ChatBar({
|
||||
'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)]',
|
||||
COMPOSER_DROP_FADE_CLASS,
|
||||
'group-has-data-[state=open]/composer:border-t-transparent',
|
||||
'group-data-[status-stack]/composer:border-t-transparent',
|
||||
dragActive && COMPOSER_DROP_ACTIVE_CLASS
|
||||
)}
|
||||
data-slot="composer-surface"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { StatusRow } from '@/components/chat/status-row'
|
||||
import { StatusSection } from '@/components/chat/status-section'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { ArrowUp, Pencil, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { QueuedPromptEntry } from '@/store/composer-queue'
|
||||
|
||||
@@ -38,32 +40,46 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
|
||||
isEditing && 'border-[color-mix(in_srgb,var(--dt-composer-ring)_40%,transparent)] bg-accent/25'
|
||||
)}
|
||||
key={entry.id}
|
||||
leading={
|
||||
<span aria-hidden className="size-3.5 shrink-0 rounded-full border border-foreground/35 bg-transparent" />
|
||||
}
|
||||
trailing={
|
||||
<>
|
||||
<Button
|
||||
disabled={Boolean(editingId) && !isEditing}
|
||||
onClick={() => onEdit(entry)}
|
||||
size="micro"
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
{c.queueEdit}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isEditing}
|
||||
onClick={() => onSendNow(entry.id)}
|
||||
size="micro"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
{busy ? c.queueSendNext : c.queueSend}
|
||||
</Button>
|
||||
<Button onClick={() => onDelete(entry.id)} size="micro" type="button" variant="text">
|
||||
{c.queueDelete}
|
||||
</Button>
|
||||
<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}
|
||||
|
||||
@@ -170,14 +170,22 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-full z-6 -mb-[9px] max-h-[40vh] overflow-y-auto"
|
||||
// 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. */}
|
||||
<div className={cn(composerDockCard('top'), 'mx-1 pt-0.5 pb-1')}>
|
||||
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',
|
||||
|
||||
+169
-101
@@ -35,7 +35,9 @@ import {
|
||||
$gatewayState,
|
||||
$introPersonality,
|
||||
$introSeed,
|
||||
$lastVisibleMessageIsUser,
|
||||
$messages,
|
||||
$messagesEmpty,
|
||||
$selectedStoredSessionId,
|
||||
$sessions,
|
||||
sessionPinId
|
||||
@@ -53,8 +55,9 @@ import { droppedFileInlineRefs, type SessionDragPayload, sessionInlineRef } from
|
||||
import type { ChatBarState } from './composer/types'
|
||||
import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions'
|
||||
import { useFileDropZone } from './hooks/use-file-drop-zone'
|
||||
import { ScrollToBottomButton } from './scroll-to-bottom-button'
|
||||
import { SessionActionsMenu } from './sidebar/session-actions-menu'
|
||||
import { lastVisibleMessageIsUser, threadLoadingState } from './thread-loading'
|
||||
import { threadLoadingState } from './thread-loading'
|
||||
|
||||
interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
||||
gateway: HermesGateway | null
|
||||
@@ -125,7 +128,13 @@ function ChatHeader({
|
||||
|
||||
return (
|
||||
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
|
||||
<div className={titlebarHeaderTitleClass}>
|
||||
<div
|
||||
className={titlebarHeaderTitleClass}
|
||||
style={{
|
||||
maxWidth:
|
||||
'calc(100vw - var(--titlebar-content-inset,0px) - var(--titlebar-tools-right) - var(--titlebar-tools-width) - 1.5rem)'
|
||||
}}
|
||||
>
|
||||
<SessionActionsMenu
|
||||
align="start"
|
||||
onDelete={selectedSessionId ? onDeleteSelectedSession : undefined}
|
||||
@@ -136,7 +145,7 @@ function ChatHeader({
|
||||
title={title}
|
||||
>
|
||||
<Button
|
||||
className="pointer-events-auto flex h-6 w-full 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 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]"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
@@ -149,105 +158,42 @@ function ChatHeader({
|
||||
)
|
||||
}
|
||||
|
||||
export function ChatView({
|
||||
className,
|
||||
gateway,
|
||||
onToggleSelectedPin,
|
||||
onDeleteSelectedSession,
|
||||
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,
|
||||
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)
|
||||
const messages = useStore($messages)
|
||||
const selectedSessionId = useStore($selectedStoredSessionId)
|
||||
onThreadMessagesChange,
|
||||
suppressMessages
|
||||
}: ChatRuntimeBoundaryProps) {
|
||||
const storeMessages = useStore($messages)
|
||||
const messages = suppressMessages ? NO_MESSAGES : storeMessages
|
||||
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 }[] = []
|
||||
@@ -297,6 +243,120 @@ export function ChatView({
|
||||
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
|
||||
@@ -349,7 +409,14 @@ export function ChatView({
|
||||
className="relative min-h-0 max-w-full flex-1 overflow-hidden bg-(--ui-chat-surface-background) contain-[layout_paint]"
|
||||
{...dropHandlers}
|
||||
>
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<ChatRuntimeBoundary
|
||||
busy={busy}
|
||||
onCancel={onCancel}
|
||||
onEdit={onEdit}
|
||||
onReload={onReload}
|
||||
onThreadMessagesChange={onThreadMessagesChange}
|
||||
suppressMessages={routeSessionMismatch}
|
||||
>
|
||||
<Thread
|
||||
clampToComposer={showChatBar}
|
||||
cwd={currentCwd}
|
||||
@@ -384,13 +451,14 @@ export function ChatView({
|
||||
onSteer={onSteer}
|
||||
onSubmit={onSubmit}
|
||||
onTranscribeAudio={onTranscribeAudio}
|
||||
queueSessionKey={selectedSessionId || activeSessionId}
|
||||
queueSessionKey={selectedSessionId}
|
||||
sessionId={activeSessionId}
|
||||
state={chatBarState}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</AssistantRuntimeProvider>
|
||||
</ChatRuntimeBoundary>
|
||||
{showChatBar && <ScrollToBottomButton />}
|
||||
<ChatDropOverlay kind={dragKind} />
|
||||
<ChatSwapOverlay profile={gatewaySwapTarget} />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
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([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
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]
|
||||
}
|
||||
@@ -467,6 +467,10 @@ 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" />
|
||||
|
||||
@@ -96,7 +96,9 @@ 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',
|
||||
dragging && 'z-10 cursor-grabbing opacity-60 shadow-sm',
|
||||
// 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)',
|
||||
className
|
||||
)}
|
||||
data-working={isWorking ? 'true' : undefined}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { type FC, useCallback, useMemo, useRef } from 'react'
|
||||
import { type FC, useCallback, useRef } from 'react'
|
||||
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -48,7 +48,6 @@ 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,
|
||||
@@ -101,21 +100,16 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
|
||||
)
|
||||
})
|
||||
|
||||
const list = (
|
||||
// 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}>
|
||||
<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 {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
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 repo’s 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'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,326 @@
|
||||
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
|
||||
}
|
||||
@@ -3,9 +3,14 @@ import type { ChatMessage } from '@/lib/chat-messages'
|
||||
export type ThreadLoadingState = 'response' | 'session'
|
||||
|
||||
export function lastVisibleMessageIsUser(messages: ChatMessage[]): boolean {
|
||||
const lastVisible = [...messages].reverse().find(message => !message.hidden)
|
||||
// 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'
|
||||
}
|
||||
}
|
||||
|
||||
return lastVisible?.role === 'user'
|
||||
return false
|
||||
}
|
||||
|
||||
export function threadLoadingState(
|
||||
|
||||
@@ -118,6 +118,10 @@ 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 => ({
|
||||
@@ -413,6 +417,24 @@ 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,
|
||||
|
||||
@@ -328,13 +328,19 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
||||
|
||||
const term = new Terminal({
|
||||
allowProposedApi: true,
|
||||
allowTransparency: true,
|
||||
// Opaque canvas = WebGL's crisp fast-path. allowTransparency instead bakes
|
||||
// glyphs as grayscale-alpha for compositing over a see-through canvas, which
|
||||
// reads soft on every platform; VS Code keeps it off and our surface
|
||||
// (--ui-bg-chrome) is opaque anyway, so withSurface paints it solid.
|
||||
allowTransparency: false,
|
||||
convertEol: true,
|
||||
cursorBlink: true,
|
||||
fontFamily: "'JetBrains Mono', 'Cascadia Code', 'SF Mono', Menlo, Consolas, monospace",
|
||||
fontSize: 11,
|
||||
fontWeight: '400',
|
||||
fontWeightBold: '700',
|
||||
// VS Code's terminal renders 'normal'/'bold' (400/700); we were using Medium
|
||||
// (500) as the base, which reads a touch heavy at this size.
|
||||
fontWeight: 'normal',
|
||||
fontWeightBold: 'bold',
|
||||
letterSpacing: 0,
|
||||
lineHeight: 1.12,
|
||||
// Full-screen TUIs (hermes --tui, vim) grab the mouse, so a plain drag
|
||||
@@ -617,8 +623,10 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
||||
startSession()
|
||||
}
|
||||
|
||||
// fonts.ready settles only already-requested faces; bold/italic aren't asked
|
||||
// for until styled output paints (past atlas init), so warm them up front.
|
||||
// fonts.ready settles only already-requested faces; the regular (400),
|
||||
// bold (700) and italic aren't asked for until styled output paints (past
|
||||
// atlas init), so warm them up front — otherwise the WebGL atlas bakes a
|
||||
// fallback face and the terminal renders thin until a repaint.
|
||||
const warm = document.fonts?.load
|
||||
? Promise.allSettled(['400', '700', 'italic 400'].map(v => document.fonts.load(`${v} 11px 'JetBrains Mono'`)))
|
||||
: Promise.resolve()
|
||||
|
||||
@@ -16,6 +16,11 @@ 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'
|
||||
@@ -343,7 +348,7 @@ export function useMessageStream({
|
||||
if (queued.assistant) {
|
||||
mutateStream(
|
||||
id,
|
||||
parts => appendAssistantTextPart(parts, queued.assistant),
|
||||
parts => dedupeGeneratedImageEchoesInParts(appendAssistantTextPart(parts, queued.assistant)),
|
||||
() => [assistantTextPart(queued.assistant)]
|
||||
)
|
||||
}
|
||||
@@ -507,7 +512,7 @@ export function useMessageStream({
|
||||
|
||||
mutateStream(
|
||||
sessionId,
|
||||
parts => upsertToolPart(parts, payload, phase),
|
||||
parts => dedupeGeneratedImageEchoesInParts(upsertToolPart(parts, payload, phase)),
|
||||
() => upsertToolPart([], payload, phase),
|
||||
{ pending: m => phase !== 'complete' || (m.pending ?? false) }
|
||||
)
|
||||
@@ -540,9 +545,11 @@ export function useMessageStream({
|
||||
const finalText = renderMediaTags(text).trim()
|
||||
const completionError = completionErrorText(finalText)
|
||||
const normalize = (value: string) => value.replace(/\s+/g, ' ').trim()
|
||||
const dedupeReference = normalize(finalText)
|
||||
|
||||
const replaceTextPart = (parts: ChatMessagePart[]) => {
|
||||
const visibleFinalText = stripGeneratedImageEchoes(finalText, generatedImageEchoSources(parts)).trim()
|
||||
const dedupeReference = normalize(visibleFinalText)
|
||||
|
||||
const kept = parts.filter(part => {
|
||||
if (part.type === 'text') {
|
||||
return false
|
||||
@@ -557,7 +564,7 @@ export function useMessageStream({
|
||||
return !(r && (dedupeReference.startsWith(r) || r.startsWith(dedupeReference)))
|
||||
})
|
||||
|
||||
return finalText ? [...kept, assistantTextPart(finalText)] : kept
|
||||
return visibleFinalText ? [...kept, assistantTextPart(visibleFinalText)] : kept
|
||||
}
|
||||
|
||||
const completeMessage = (message: ChatMessage): ChatMessage =>
|
||||
|
||||
@@ -325,6 +325,81 @@ describe('usePromptActions submit / queue drain semantics', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('a rejected fromQueue drain returns false (entry stays queued) and a later retry sends it', async () => {
|
||||
// A stale-session 404 must not strand the queued entry: submitPrompt returns
|
||||
// false on failure so the composer keeps it, and the edge-independent
|
||||
// auto-drain re-attempts once the session is idle again. storedSessionId is
|
||||
// null so the session.resume recovery path is skipped and the error surfaces.
|
||||
let attempt = 0
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
if (method === 'prompt.submit') {
|
||||
attempt += 1
|
||||
|
||||
if (attempt === 1) {
|
||||
throw new Error('404: {"detail":"Session not found"}')
|
||||
}
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<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)
|
||||
@@ -801,7 +876,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
calls.push(method)
|
||||
if (method === 'prompt.submit') {
|
||||
throw new Error('session busy')
|
||||
throw new Error('gateway exploded')
|
||||
}
|
||||
return {} as never
|
||||
})
|
||||
|
||||
@@ -118,10 +118,12 @@ function isSessionNotFoundError(error: unknown): boolean {
|
||||
}
|
||||
|
||||
// The gateway refuses prompt.submit while a turn is running (4009 "session
|
||||
// busy"). Edit/restore (revert) can fire mid-turn, so they interrupt first then
|
||||
// retry the submit until the cooperative interrupt has wound the turn down.
|
||||
const REWIND_INTERRUPT_TIMEOUT_MS = 6_000
|
||||
const REWIND_RETRY_INTERVAL_MS = 150
|
||||
// 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))
|
||||
@@ -129,6 +131,26 @@ function isSessionBusyError(error: unknown): boolean {
|
||||
|
||||
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(',')
|
||||
|
||||
@@ -683,7 +705,7 @@ export function usePromptActions({
|
||||
let submitErr: unknown = null
|
||||
|
||||
try {
|
||||
await requestGateway('prompt.submit', { session_id: sessionId, text })
|
||||
await withSessionBusyRetry(() => requestGateway('prompt.submit', { session_id: sessionId, text }))
|
||||
} catch (firstErr) {
|
||||
if (isSessionNotFoundError(firstErr) && selectedStoredSessionIdRef.current) {
|
||||
// Re-register the session in the gateway and get a fresh live ID.
|
||||
@@ -695,7 +717,7 @@ export function usePromptActions({
|
||||
|
||||
if (recoveredId) {
|
||||
activeSessionIdRef.current = recoveredId
|
||||
await requestGateway('prompt.submit', { session_id: recoveredId, text })
|
||||
await withSessionBusyRetry(() => requestGateway('prompt.submit', { session_id: recoveredId, text }))
|
||||
} else {
|
||||
submitErr = firstErr
|
||||
}
|
||||
@@ -714,9 +736,17 @@ export function usePromptActions({
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
releaseBusy()
|
||||
|
||||
// A queued drain that raced a not-yet-settled turn gets a transient
|
||||
// "session busy" (4009). Don't surface an error bubble/toast — the entry
|
||||
// stays queued and the composer's bounded auto-drain retries when idle.
|
||||
if (options?.fromQueue && isSessionBusyError(err)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const message = inlineErrorMessage(err, copy.promptFailed)
|
||||
|
||||
releaseBusy()
|
||||
updateSessionState(sessionId, state => ({
|
||||
...state,
|
||||
messages: [
|
||||
@@ -1452,9 +1482,8 @@ export function usePromptActions({
|
||||
// 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, then
|
||||
// retry the submit until the gateway stops reporting "session busy" — the
|
||||
// interrupt is cooperative, so the running turn takes a beat to wind down.
|
||||
// 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) {
|
||||
@@ -1465,27 +1494,13 @@ export function usePromptActions({
|
||||
}
|
||||
}
|
||||
|
||||
const deadline = Date.now() + REWIND_INTERRUPT_TIMEOUT_MS
|
||||
|
||||
for (;;) {
|
||||
try {
|
||||
await requestGateway('prompt.submit', {
|
||||
session_id: sessionId,
|
||||
text,
|
||||
...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal })
|
||||
})
|
||||
|
||||
return
|
||||
} catch (err) {
|
||||
if (isSessionBusyError(err) && Date.now() < deadline) {
|
||||
await sleep(REWIND_RETRY_INTERVAL_MS)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
throw err
|
||||
}
|
||||
}
|
||||
await withSessionBusyRetry(() =>
|
||||
requestGateway('prompt.submit', {
|
||||
session_id: sessionId,
|
||||
text,
|
||||
...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal })
|
||||
})
|
||||
)
|
||||
},
|
||||
[requestGateway]
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { MutableRefObject } from 'react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import type { NavigateFunction } from 'react-router-dom'
|
||||
|
||||
import { deleteSession, getSessionMessages, listAllProfileSessions, setSessionArchived } from '@/hermes'
|
||||
import { deleteSession, getSession, getSessionMessages, setSessionArchived } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
|
||||
import { normalizePersonalityValue } from '@/lib/chat-runtime'
|
||||
@@ -12,7 +12,7 @@ import { clearQueuedPrompts } from '@/store/composer-queue'
|
||||
import { $pinnedSessionIds } from '@/store/layout'
|
||||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
import { $activeGatewayProfile, $newChatProfile, $profiles, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
import {
|
||||
$currentCwd,
|
||||
$messages,
|
||||
@@ -236,18 +236,42 @@ 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 result = await listAllProfileSessions(500, 0, 'include', 'recent', 'all')
|
||||
const resolved = result.sessions.find(session => sessionMatchesStoredId(session, storedSessionId))
|
||||
const session = await getSession(storedSessionId)
|
||||
|
||||
if (resolved) {
|
||||
upsertResolvedSession(resolved, storedSessionId)
|
||||
}
|
||||
upsertResolvedSession(session, storedSessionId)
|
||||
|
||||
return resolved
|
||||
return session
|
||||
} catch {
|
||||
return undefined
|
||||
// Not on the active profile — fall through to the cross-profile probe.
|
||||
}
|
||||
|
||||
// Multi-profile only: probe each other profile by id (still one cheap lookup
|
||||
// each) rather than pulling every profile's recent sessions. The first hit
|
||||
// carries its owning `profile`, which routes the resume to the right backend.
|
||||
const activeKey = normalizeProfileKey($activeGatewayProfile.get())
|
||||
|
||||
const otherProfiles = $profiles
|
||||
.get()
|
||||
.map(profile => normalizeProfileKey(profile.name))
|
||||
.filter(key => key !== activeKey)
|
||||
|
||||
for (const profile of otherProfiles) {
|
||||
try {
|
||||
const session = await getSession(storedSessionId, profile)
|
||||
|
||||
upsertResolvedSession(session, storedSessionId)
|
||||
|
||||
return session
|
||||
} catch {
|
||||
// Not on this profile; try the next.
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
type SessionRuntimeStatePatch = Partial<
|
||||
@@ -523,8 +547,31 @@ export function useSessionActions({
|
||||
const isCurrentResume = () =>
|
||||
resumeRequestRef.current === requestId && selectedStoredSessionIdRef.current === storedSessionId
|
||||
|
||||
// Paint the click before the profile-resolve / gateway-swap awaits below,
|
||||
// so there's zero dead air: highlight the row instantly (the sidebar reads
|
||||
// $selectedStoredSessionId) and, for a cold target, drop the previous
|
||||
// transcript so the thread shows its loader instead of the old session
|
||||
// lingering until resume lands. A warm-cached target keeps its transcript —
|
||||
// the cached fast-path repaints it this same tick. Setting the ref here is
|
||||
// also what use-route-resume's self-heal assumes ("set synchronously at
|
||||
// resume entry").
|
||||
setFreshDraftReady(false)
|
||||
clearNotifications()
|
||||
setSelectedStoredSessionId(storedSessionId)
|
||||
selectedStoredSessionIdRef.current = storedSessionId
|
||||
|
||||
const warmRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
|
||||
|
||||
if (!warmRuntimeId || !sessionStateByRuntimeIdRef.current.get(warmRuntimeId)) {
|
||||
setActiveSessionId(null)
|
||||
activeSessionIdRef.current = null
|
||||
setMessages([])
|
||||
}
|
||||
|
||||
// Swap the single live gateway to this session's profile before any
|
||||
// gateway call (no-op when it's already on that profile / single-profile).
|
||||
// resolveStoredSession finds the row by id (cheap), so an uncached pasted
|
||||
// id loads as fast as a sidebar click instead of hanging on a list scan.
|
||||
const storedForProfile = await resolveStoredSession(storedSessionId)
|
||||
const sessionProfile = storedForProfile?.profile
|
||||
|
||||
@@ -618,10 +665,26 @@ export function useSessionActions({
|
||||
const watchWindow = isWatchWindow()
|
||||
let localSnapshot = $messages.get()
|
||||
|
||||
// REST transcript prefetch and the gateway resume RPC are independent
|
||||
// — run them concurrently so a big session's wall time is
|
||||
// max(prefetch, resume) instead of their sum. The prefetch paints the
|
||||
// transcript as soon as it lands; the RPC binds the runtime id.
|
||||
// Watch windows skip the prefetch — lazy resume attaches the live mirror.
|
||||
const prefetchPromise = watchWindow ? null : getSessionMessages(storedSessionId, sessionProfile)
|
||||
|
||||
const resumePromise = requestGateway<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 {
|
||||
// Watch windows skip REST prefetch — lazy resume attaches the live mirror.
|
||||
if (!watchWindow) {
|
||||
const storedMessages = await getSessionMessages(storedSessionId, sessionProfile)
|
||||
if (prefetchPromise) {
|
||||
const storedMessages = await prefetchPromise
|
||||
|
||||
if (isCurrentResume()) {
|
||||
localSnapshot = preserveLocalAssistantErrors(toChatMessages(storedMessages.messages), $messages.get())
|
||||
@@ -635,12 +698,7 @@ export function useSessionActions({
|
||||
// Non-fatal: gateway resume below can still hydrate the session.
|
||||
}
|
||||
|
||||
const resumed = await requestGateway<SessionResumeResponse>('session.resume', {
|
||||
session_id: storedSessionId,
|
||||
cols: 96,
|
||||
...(watchWindow ? { lazy: true } : {}),
|
||||
...(sessionProfile ? { profile: sessionProfile } : {})
|
||||
})
|
||||
const resumed = await resumePromise
|
||||
|
||||
if (!isCurrentResume()) {
|
||||
return
|
||||
@@ -648,17 +706,22 @@ export function useSessionActions({
|
||||
|
||||
const currentMessages = $messages.get()
|
||||
|
||||
const resumedMessages = preserveLocalAssistantErrors(
|
||||
reconcileResumeMessages(toChatMessages(resumed.messages), currentMessages),
|
||||
currentMessages
|
||||
)
|
||||
// Keep the local snapshot when resume would only reshuffle runtime projection.
|
||||
// Keep the local snapshot when resume would only reshuffle runtime
|
||||
// projection. When the REST prefetch already hydrated the transcript,
|
||||
// skip converting/reconciling the resume payload entirely — on a
|
||||
// 1000+-message session that second conversion plus the deep
|
||||
// equivalence compare costs over a second of main-thread time.
|
||||
const preferredMessages =
|
||||
localSnapshot.length > 0
|
||||
? localSnapshot
|
||||
: chatMessageArraysEquivalent(currentMessages, resumedMessages)
|
||||
? currentMessages
|
||||
: resumedMessages
|
||||
: (() => {
|
||||
const resumedMessages = preserveLocalAssistantErrors(
|
||||
reconcileResumeMessages(toChatMessages(resumed.messages), currentMessages),
|
||||
currentMessages
|
||||
)
|
||||
|
||||
return chatMessageArraysEquivalent(currentMessages, resumedMessages) ? currentMessages : resumedMessages
|
||||
})()
|
||||
|
||||
const messagesForView = preserveLocalAssistantErrors(preferredMessages, currentMessages)
|
||||
|
||||
|
||||
@@ -2,12 +2,22 @@
|
||||
|
||||
import { TextMessagePartProvider, useMessagePartText } from '@assistant-ui/react'
|
||||
import {
|
||||
parseMarkdownIntoBlocks,
|
||||
type StreamdownTextComponents,
|
||||
StreamdownTextPrimitive,
|
||||
type SyntaxHighlighterProps
|
||||
} from '@assistant-ui/react-streamdown'
|
||||
import { code } from '@streamdown/code'
|
||||
import { type ComponentProps, memo, type ReactNode, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
type ComponentProps,
|
||||
memo,
|
||||
type ReactNode,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
|
||||
import { PreviewAttachment } from '@/components/chat/preview-attachment'
|
||||
import { SyntaxHighlighter } from '@/components/chat/shiki-highlighter'
|
||||
@@ -26,6 +36,7 @@ import {
|
||||
mediaStreamUrl
|
||||
} from '@/lib/media'
|
||||
import { previewTargetFromMarkdownHref } from '@/lib/preview-targets'
|
||||
import { tailBoundedRemend } from '@/lib/remend-tail'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Math rendering plugin (KaTeX). Configured once at module scope — the
|
||||
@@ -42,6 +53,51 @@ import { cn } from '@/lib/utils'
|
||||
// LLM convention). The default false-setting only accepts `$$...$$`.
|
||||
const mathPlugin = createMemoizedMathPlugin({ singleDollarTextMath: true })
|
||||
|
||||
// Replaces Streamdown's `parseIncompleteMarkdown` (full-text remend per
|
||||
// flush) with a tail-bounded repair — see lib/remend-tail.ts. Must stay
|
||||
// module-scope so the prop identity is stable across renders.
|
||||
function preprocessWithTailRepair(text: string): string {
|
||||
return tailBoundedRemend(preprocessMarkdown(text))
|
||||
}
|
||||
|
||||
// Memoized block splitter. Streamdown calls `parseMarkdownIntoBlocks` (a full
|
||||
// `marked` lex of the entire message, ~1.6ms per 28KB) inside a useMemo keyed
|
||||
// on the text — but the same text is re-lexed every time a message REMOUNTS
|
||||
// (virtualizer scroll, session switch) and whenever multiple surfaces render
|
||||
// the same content (deferred + smooth reveal republish). A small module-level
|
||||
// LRU keyed by the exact source string removes all of those repeat parses
|
||||
// with zero correctness risk (same input → same output). Streaming tail
|
||||
// growth misses the cache by design (every flush is a new string) — that
|
||||
// single lex is the irreducible cost.
|
||||
const BLOCK_CACHE_MAX = 64
|
||||
const BLOCK_CACHE_MIN_LENGTH = 1024
|
||||
const blockCache = new Map<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
|
||||
@@ -241,6 +297,13 @@ function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>)
|
||||
// keeps draining its tail instead of snapping.
|
||||
const REVEAL_DRAIN_MS = 500
|
||||
const REVEAL_MAX_CHARS_PER_FRAME = 30
|
||||
// Floor between reveal commits. Each commit republishes the text context and
|
||||
// re-runs the whole Streamdown pipeline (preprocess → remend → lex → micromark
|
||||
// on the open block) over the full accumulated text — at raw rAF cadence
|
||||
// that's 60 full parses/second and was the dominant streaming cost for
|
||||
// reasoning text. ~33ms keeps the reveal visually fluid (2 frames) while
|
||||
// halving the parse work.
|
||||
const REVEAL_MIN_COMMIT_MS = 33
|
||||
|
||||
function useSmoothReveal(text: string, isRunning: boolean): string {
|
||||
const [displayed, setDisplayed] = useState(isRunning ? '' : text)
|
||||
@@ -273,10 +336,27 @@ function useSmoothReveal(text: string, isRunning: boolean): string {
|
||||
const tick = () => {
|
||||
const now = performance.now()
|
||||
const dt = now - lastTickRef.current
|
||||
|
||||
// Skip this frame if the floor hasn't elapsed — the backlog math below
|
||||
// is dt-proportional, so delayed commits reveal proportionally more.
|
||||
if (dt < REVEAL_MIN_COMMIT_MS) {
|
||||
frameRef.current = requestAnimationFrame(tick)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
lastTickRef.current = now
|
||||
|
||||
const remaining = targetRef.current.length - shownRef.current.length
|
||||
const add = Math.min(remaining, REVEAL_MAX_CHARS_PER_FRAME, Math.max(1, Math.ceil((remaining * dt) / REVEAL_DRAIN_MS)))
|
||||
|
||||
const add = Math.min(
|
||||
remaining,
|
||||
// dt-scaled so the per-commit cap stays equivalent to the old
|
||||
// per-frame cap at any commit cadence.
|
||||
Math.ceil((REVEAL_MAX_CHARS_PER_FRAME * dt) / 16.7),
|
||||
Math.max(1, Math.ceil((remaining * dt) / REVEAL_DRAIN_MS))
|
||||
)
|
||||
|
||||
shownRef.current = targetRef.current.slice(0, shownRef.current.length + add)
|
||||
setDisplayed(shownRef.current)
|
||||
|
||||
@@ -460,17 +540,20 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
|
||||
containerProps={containerProps}
|
||||
lineNumbers={false}
|
||||
mode="streaming"
|
||||
// Always auto-close incomplete fences — even during streaming.
|
||||
// Without this, an unclosed ```python ... ``` whose body contains
|
||||
// `$` (very common: shell snippets, JS template strings, dollar
|
||||
// amounts) leaks those dollars out to the math parser and they
|
||||
// get rendered as broken inline math until the closing fence
|
||||
// arrives. Shiki is independently deferred via `defer={isStreaming}`
|
||||
// on the SyntaxHighlighter component, so we don't pay code-block
|
||||
// tokenization on every token even with this set.
|
||||
parseIncompleteMarkdown
|
||||
// Incomplete-markdown repair is handled by `preprocessWithTailRepair`
|
||||
// below (tail-bounded remend) instead of Streamdown's built-in pass,
|
||||
// which re-runs remend over the ENTIRE message on every flush — ~18%
|
||||
// of streaming script time on 50KB+ messages. The repair itself stays
|
||||
// always-on (even between flushes / for completed messages): an
|
||||
// unclosed ```python ... ``` whose body contains `$` (shell snippets,
|
||||
// JS template strings, dollar amounts) would otherwise leak those
|
||||
// dollars to the math parser and render broken inline math. Shiki is
|
||||
// independently deferred via `defer={isStreaming}` on the
|
||||
// SyntaxHighlighter component.
|
||||
parseIncompleteMarkdown={false}
|
||||
parseMarkdownIntoBlocksFn={parseMarkdownIntoBlocksCached}
|
||||
plugins={plugins}
|
||||
preprocess={preprocessMarkdown}
|
||||
preprocess={preprocessWithTailRepair}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -58,9 +58,9 @@ Element.prototype.animate = function animate() {
|
||||
} as unknown as Animation
|
||||
}
|
||||
|
||||
// jsdom returns 0 for offset*; the virtualizer reads those to size its
|
||||
// jsdom returns 0 for offset*; some layout code reads those to size the
|
||||
// viewport. Fall through to client* (which tests can override) or a sane
|
||||
// default so virtualized items render.
|
||||
// default so message rows render with non-zero dimensions.
|
||||
function stubOffsetDimension(
|
||||
prop: 'offsetHeight' | 'offsetWidth',
|
||||
clientProp: 'clientHeight' | 'clientWidth',
|
||||
@@ -216,6 +216,32 @@ function assistantTodoMessage(
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function assistantImageMessage(running = false): ThreadMessage {
|
||||
return {
|
||||
id: `assistant-image-${running ? 'running' : 'done'}`,
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
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 } })
|
||||
}
|
||||
],
|
||||
status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function StreamingHarness() {
|
||||
const [messages, setMessages] = useState<ThreadMessage[]>([userMessage()])
|
||||
const [isRunning, setIsRunning] = useState(true)
|
||||
@@ -254,20 +280,6 @@ 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],
|
||||
@@ -409,222 +421,11 @@ describe('assistant-ui streaming renderer', () => {
|
||||
expect(screen.getByRole('alert').textContent).toContain('OpenRouter rejected the request (403).')
|
||||
})
|
||||
|
||||
it('does not pull the viewport back down after the user scrolls up during streaming', async () => {
|
||||
const { container } = render(<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)
|
||||
})
|
||||
// Scroll behavior (follow-at-bottom, escape-on-scroll-up, re-engage) is owned
|
||||
// by the use-stick-to-bottom library and covered by its own test suite. We
|
||||
// don't re-assert its scrollTop mechanics here — doing so in jsdom (no real
|
||||
// layout, spring animation via rAF) only produces brittle change-detector
|
||||
// tests. The rendering/streaming-content tests below remain the contract.
|
||||
|
||||
it('renders an incomplete streaming fenced code block as a code card', async () => {
|
||||
const { container } = render(<RunningMessageHarness message={assistantMessage('```ts\nconst answer = 42\n')} />)
|
||||
@@ -640,14 +441,19 @@ describe('assistant-ui streaming renderer', () => {
|
||||
it('renders an incomplete streaming reasoning fenced code block as a code card', async () => {
|
||||
const { container } = render(<RunningReasoningHarness />)
|
||||
const ui = within(container)
|
||||
const thinkingToggle = ui.getByRole('button', { name: /thinking/i })
|
||||
|
||||
fireEvent.click(ui.getByRole('button', { name: /thinking/i }))
|
||||
if (thinkingToggle.getAttribute('aria-expanded') !== 'true') {
|
||||
fireEvent.click(thinkingToggle)
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-slot="code-card"]')).toBeTruthy()
|
||||
})
|
||||
|
||||
expect(container.querySelector('[data-slot="aui_reasoning-text"]')?.textContent).toContain('const answer = 42')
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-slot="aui_reasoning-text"]')?.textContent).toContain('const answer = 42')
|
||||
})
|
||||
expect(container.textContent).not.toContain('```ts')
|
||||
})
|
||||
|
||||
@@ -700,4 +506,16 @@ describe('assistant-ui streaming renderer', () => {
|
||||
|
||||
expect(container.querySelector('[data-slot="aui_todo-hoisted"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders completed image generation results in the tool slot', async () => {
|
||||
const { container } = render(<MessageHarness message={assistantImageMessage()} />)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react'
|
||||
import {
|
||||
type ComponentProps,
|
||||
type FC,
|
||||
memo,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import { useStickToBottom } from 'use-stick-to-bottom'
|
||||
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
onScrollToBottomRequest,
|
||||
onThreadEditClose,
|
||||
onThreadEditOpen,
|
||||
resetThreadScroll,
|
||||
setThreadAtBottom
|
||||
} from '@/store/thread-scroll'
|
||||
|
||||
import { MessageRenderBoundary } from './message-render-boundary'
|
||||
|
||||
type ThreadMessageComponents = ComponentProps<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)
|
||||
@@ -1,469 +0,0 @@
|
||||
import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react'
|
||||
import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual'
|
||||
import {
|
||||
type ComponentProps,
|
||||
type FC,
|
||||
memo,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef
|
||||
} from 'react'
|
||||
|
||||
import { setMutableRef } from '@/lib/mutable-ref'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { setThreadScrolledUp } from '@/store/thread-scroll'
|
||||
|
||||
import { MessageRenderBoundary } from './message-render-boundary'
|
||||
|
||||
const ESTIMATED_ITEM_HEIGHT = 220
|
||||
const OVERSCAN = 4
|
||||
const AT_BOTTOM_THRESHOLD = 4
|
||||
|
||||
type ThreadMessageComponents = ComponentProps<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)
|
||||
}
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
MessagePrimitive,
|
||||
type ToolCallMessagePartProps,
|
||||
useAui,
|
||||
useAuiState
|
||||
useAuiState,
|
||||
useMessageRuntime
|
||||
} from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { IconPlayerStopFilled } from '@tabler/icons-react'
|
||||
@@ -52,20 +53,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 { VirtualizedThread } from '@/components/assistant-ui/thread-virtualizer'
|
||||
import { ThreadMessageList } from '@/components/assistant-ui/thread-list'
|
||||
import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool-fallback'
|
||||
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
|
||||
import { UserMessageText } from '@/components/assistant-ui/user-message-text'
|
||||
import { useElapsedSeconds } from '@/components/chat/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
||||
import { DisclosureRow } from '@/components/chat/disclosure-row'
|
||||
import { GeneratedImageProvider, useGeneratedImageContext } from '@/components/chat/generated-image-context'
|
||||
import { ImageGenerationPlaceholder } from '@/components/chat/image-generation-placeholder'
|
||||
import { GeneratedImage } from '@/components/chat/generated-image-result'
|
||||
import { Intro, type IntroProps } from '@/components/chat/intro'
|
||||
import { PreviewAttachment } from '@/components/chat/preview-attachment'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
@@ -94,13 +99,18 @@ 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
|
||||
messageText: string
|
||||
/** Lazy accessor — reads the live message text at action time. Passing the
|
||||
* text itself as a prop forces the whole footer to re-render on every
|
||||
* streaming delta flush (the text changes ~30×/s), which profiling showed
|
||||
* was a large slice of per-token script time on long transcripts. */
|
||||
getMessageText: () => string
|
||||
onBranchInNewChat?: (messageId: string) => void
|
||||
}
|
||||
|
||||
@@ -128,6 +138,28 @@ function messageContentText(content: unknown): string {
|
||||
return Array.isArray(content) ? content.map(partText).join('').trim() : ''
|
||||
}
|
||||
|
||||
// Cheap streaming-stable "does this message have visible text" check: returns
|
||||
// on the first non-whitespace text part without concatenating the whole
|
||||
// message. Used as a useAuiState selector so its boolean output stays stable
|
||||
// across token flushes (flips false→true once per turn).
|
||||
function contentHasVisibleText(content: unknown): boolean {
|
||||
if (typeof content === 'string') {
|
||||
return content.trim().length > 0
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (const part of content) {
|
||||
if (partText(part).trim().length > 0) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export const Thread: FC<{
|
||||
clampToComposer?: boolean
|
||||
cwd?: string | null
|
||||
@@ -168,18 +200,16 @@ export const Thread: FC<{
|
||||
) : undefined
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -216,20 +246,39 @@ const CenteredThreadSpinner: FC = () => {
|
||||
|
||||
const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => {
|
||||
const messageId = useAuiState(s => s.message.id)
|
||||
const content = useAuiState(s => s.message.content)
|
||||
const messageText = messageContentText(content)
|
||||
const messageRuntime = useMessageRuntime()
|
||||
|
||||
// PERF: this component must NOT subscribe to the streaming text. Every
|
||||
// selector here returns a value that stays referentially stable across
|
||||
// token flushes (booleans, status strings, '' while running), so the
|
||||
// 30 Hz delta stream only re-renders the markdown part and the tiny
|
||||
// StreamStallIndicator leaf — not the footer/preview/root subtree.
|
||||
const messageStatus = useAuiState(s => s.message.status?.type)
|
||||
const isRunning = messageStatus === 'running'
|
||||
const isPlaceholder = useAuiState(s => s.message.status?.type === 'running' && s.message.content.length === 0)
|
||||
const hasVisibleText = useAuiState(s => contentHasVisibleText(s.message.content))
|
||||
|
||||
// Preview targets only materialize once the turn completes — while running
|
||||
// the selector returns '' (stable), so per-token flushes skip the regex
|
||||
// scan and the re-render it would cause.
|
||||
const completedText = useAuiState(s =>
|
||||
s.message.status?.type === 'running' ? '' : messageContentText(s.message.content)
|
||||
)
|
||||
|
||||
const previewTargets = useMemo(() => {
|
||||
if (!messageText || !/(https?:\/\/|file:\/\/)/i.test(messageText)) {
|
||||
if (!completedText || !/(https?:\/\/|file:\/\/)/i.test(completedText)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return pickPrimaryPreviewTarget(extractPreviewTargets(messageText))
|
||||
}, [messageText])
|
||||
return pickPrimaryPreviewTarget(extractPreviewTargets(completedText))
|
||||
}, [completedText])
|
||||
|
||||
const messageStatus = useAuiState(s => s.message.status?.type)
|
||||
const isPlaceholder = messageStatus === 'running' && content.length === 0
|
||||
const enterRef = useEnterAnimation(messageStatus === 'running', `assistant-message:${messageId}`)
|
||||
const getMessageText = useCallback(
|
||||
() => messageContentText(messageRuntime.getState().content),
|
||||
[messageRuntime]
|
||||
)
|
||||
|
||||
const enterRef = useEnterAnimation(isRunning, `assistant-message:${messageId}`)
|
||||
|
||||
if (isPlaceholder) {
|
||||
return null
|
||||
@@ -240,7 +289,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
||||
className="group flex w-full min-w-0 max-w-full flex-col gap-0 self-start overflow-hidden"
|
||||
data-role="assistant"
|
||||
data-slot="aui_assistant-message-root"
|
||||
data-streaming={messageStatus === 'running' ? 'true' : undefined}
|
||||
data-streaming={isRunning ? 'true' : undefined}
|
||||
ref={enterRef}
|
||||
>
|
||||
<div
|
||||
@@ -249,7 +298,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
||||
>
|
||||
{/* Todos render in the composer status stack now, not inline. */}
|
||||
<MessagePrimitive.Parts components={MESSAGE_PARTS_COMPONENTS} />
|
||||
{messageStatus === 'running' && <StreamStallIndicator activity={`${content.length}:${messageText.length}`} />}
|
||||
{isRunning && <StreamStallIndicator />}
|
||||
{previewTargets.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{previewTargets.map(target => (
|
||||
@@ -266,8 +315,8 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
||||
</ErrorPrimitive.Root>
|
||||
</MessagePrimitive.Error>
|
||||
</div>
|
||||
{messageText.trim().length > 0 && (
|
||||
<AssistantFooter messageId={messageId} messageText={messageText} onBranchInNewChat={onBranchInNewChat} />
|
||||
{hasVisibleText && (
|
||||
<AssistantFooter getMessageText={getMessageText} messageId={messageId} onBranchInNewChat={onBranchInNewChat} />
|
||||
)}
|
||||
</MessagePrimitive.Root>
|
||||
)
|
||||
@@ -308,10 +357,28 @@ const STREAM_STALL_S = 2
|
||||
|
||||
// Tail "still thinking" indicator: the pre-first-token spinner goes away once
|
||||
// text flows, but if the stream then goes quiet mid-turn (tool think-time,
|
||||
// provider stall) nothing signals that work continues. Watch a per-render
|
||||
// provider stall) nothing signals that work continues. Watch a per-flush
|
||||
// activity signal; when it hasn't changed for STREAM_STALL_S, re-show the
|
||||
// dither + a timer counting from the last activity.
|
||||
const StreamStallIndicator: FC<{ activity: string }> = ({ activity }) => {
|
||||
//
|
||||
// Subscribes to the activity signal ITSELF (rather than taking it as a prop)
|
||||
// so that per-token updates re-render only this leaf, not the whole
|
||||
// AssistantMessage subtree.
|
||||
const StreamStallIndicator: FC = () => {
|
||||
const activity = useAuiState(s => {
|
||||
let textLength = 0
|
||||
|
||||
for (const part of s.message.content) {
|
||||
const text = (part as { text?: unknown }).text
|
||||
|
||||
if (typeof text === 'string') {
|
||||
textLength += text.length
|
||||
}
|
||||
}
|
||||
|
||||
return `${s.message.content.length}:${textLength}`
|
||||
})
|
||||
|
||||
const [stalled, setStalled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -335,21 +402,12 @@ const StreamStallIndicator: FC<{ activity: string }> = ({ activity }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const ImageGenerateTool: FC<ToolCallMessagePartProps> = ({ result }) => {
|
||||
const generatedImage = useGeneratedImageContext()
|
||||
const running = result === undefined
|
||||
|
||||
useEffect(() => {
|
||||
generatedImage?.setPending(running)
|
||||
}, [generatedImage, running])
|
||||
|
||||
if (!running) {
|
||||
return null
|
||||
}
|
||||
const ImageGenerateTool: FC<ToolCallMessagePartProps> = ({ args, result }) => {
|
||||
const aspectRatio = typeof args?.aspect_ratio === 'string' ? args.aspect_ratio : undefined
|
||||
|
||||
return (
|
||||
<div className="mt-1.5">
|
||||
<ImageGenerationPlaceholder />
|
||||
<GeneratedImage aspectRatio={aspectRatio} result={result} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -579,7 +637,7 @@ function formatMessageTimestamp(
|
||||
return SHORT_FMT.format(date)
|
||||
}
|
||||
|
||||
const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, onBranchInNewChat }) => {
|
||||
const AssistantActionBar: FC<MessageActionProps> = ({ messageId, getMessageText, onBranchInNewChat }) => {
|
||||
const { t } = useI18n()
|
||||
const copy = t.assistant.thread
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
@@ -600,7 +658,7 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, on
|
||||
)}
|
||||
data-slot="aui_msg-actions"
|
||||
>
|
||||
<CopyButton appearance="icon" buttonSize="icon" disabled={!messageText} label={copy.copy} text={messageText} />
|
||||
<CopyButton appearance="icon" buttonSize="icon" label={copy.copy} text={getMessageText} />
|
||||
<ActionBarPrimitive.Reload asChild>
|
||||
<TooltipIconButton onClick={() => triggerHaptic('submit')} tooltip={copy.refresh}>
|
||||
<Codicon name="refresh" />
|
||||
@@ -618,7 +676,7 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, on
|
||||
<GitBranchIcon />
|
||||
{copy.branchNewChat}
|
||||
</DropdownMenuItem>
|
||||
<ReadAloudItem messageId={messageId} text={messageText} />
|
||||
<ReadAloudItem getText={getMessageText} messageId={messageId} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</ActionBarPrimitive.Root>
|
||||
@@ -626,7 +684,7 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, on
|
||||
)
|
||||
}
|
||||
|
||||
const ReadAloudItem: FC<{ messageId: string; text: string }> = ({ messageId, text }) => {
|
||||
const ReadAloudItem: FC<{ getText: () => string; messageId: string }> = ({ getText, messageId }) => {
|
||||
const { t } = useI18n()
|
||||
const copy = t.assistant.thread
|
||||
const voicePlayback = useStore($voicePlayback)
|
||||
@@ -640,6 +698,8 @@ const ReadAloudItem: FC<{ messageId: string; text: string }> = ({ messageId, tex
|
||||
const Icon = isPreparing ? Loader2Icon : isSpeaking ? VolumeXIcon : Volume2Icon
|
||||
|
||||
const read = useCallback(async () => {
|
||||
const text = getText()
|
||||
|
||||
if (!text || $voicePlayback.get().status !== 'idle') {
|
||||
return
|
||||
}
|
||||
@@ -649,11 +709,11 @@ const ReadAloudItem: FC<{ messageId: string; text: string }> = ({ messageId, tex
|
||||
} catch (error) {
|
||||
notifyError(error, copy.readAloudFailed)
|
||||
}
|
||||
}, [copy.readAloudFailed, messageId, text])
|
||||
}, [copy.readAloudFailed, getText, messageId])
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
disabled={isPreparing || (!isSpeaking && (anyPlaybackActive || !text))}
|
||||
disabled={isPreparing || (!isSpeaking && anyPlaybackActive)}
|
||||
onSelect={e => {
|
||||
e.preventDefault()
|
||||
void (isSpeaking ? stopVoicePlayback() : read())
|
||||
@@ -707,15 +767,22 @@ function messageAttachmentRefs(value: unknown): string[] {
|
||||
return value.every(ref => typeof ref === 'string') ? value : EMPTY_ATTACHMENT_REFS
|
||||
}
|
||||
|
||||
function StickyHumanMessageContainer({ children }: { children: ReactNode }) {
|
||||
function StickyHumanMessageContainer({ attachments, children }: { attachments?: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<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>
|
||||
// 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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -808,8 +875,10 @@ 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(() => {
|
||||
const measureClamp = useCallback((entries: readonly ResizeObserverEntry[]) => {
|
||||
const inner = clampInnerRef.current
|
||||
const outer = inner?.parentElement
|
||||
|
||||
@@ -817,12 +886,28 @@ const UserMessage: FC<{
|
||||
return
|
||||
}
|
||||
|
||||
const styles = getComputedStyle(inner)
|
||||
const lineHeight = parseFloat(styles.lineHeight) || 1.5 * parseFloat(styles.fontSize) || 20
|
||||
const fullHeight = inner.scrollHeight
|
||||
// Prefer the size the ResizeObserver already computed — reading
|
||||
// `scrollHeight` outside RO timing forces a synchronous layout, and with
|
||||
// many user bubbles observed at once those reads interleave with the
|
||||
// style write below into a read-write-read reflow cascade.
|
||||
const entryHeight = entries.find(entry => entry.target === inner)?.borderBoxSize?.[0]?.blockSize
|
||||
const fullHeight = Math.ceil(entryHeight ?? inner.scrollHeight)
|
||||
|
||||
if (fullHeight === lastClampHeightRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
lastClampHeightRef.current = fullHeight
|
||||
|
||||
// Line-height is stable for the life of the bubble (font settings don't
|
||||
// change under it) — resolve the computed style once.
|
||||
if (!lineHeightRef.current) {
|
||||
const styles = getComputedStyle(inner)
|
||||
lineHeightRef.current = parseFloat(styles.lineHeight) || 1.5 * parseFloat(styles.fontSize) || 20
|
||||
}
|
||||
|
||||
outer.style.setProperty('--human-msg-full', `${fullHeight}px`)
|
||||
setBodyClamped(fullHeight > lineHeight * 2 + 1)
|
||||
setBodyClamped(fullHeight > lineHeightRef.current * 2 + 1)
|
||||
}, [])
|
||||
|
||||
useResizeObserver(measureClamp, clampInnerRef)
|
||||
@@ -855,29 +940,34 @@ const UserMessage: FC<{
|
||||
'border-(--ui-stroke-tertiary) hover:border-(--ui-stroke-secondary)'
|
||||
)
|
||||
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
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>
|
||||
)
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root asChild>
|
||||
<StickyHumanMessageContainer>
|
||||
<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
|
||||
}
|
||||
>
|
||||
<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">
|
||||
@@ -888,6 +978,7 @@ const UserMessage: FC<{
|
||||
aria-label={copy.editMessage}
|
||||
className={bubbleClassName}
|
||||
onClick={() => triggerHaptic('selection')}
|
||||
onPointerDown={() => notifyThreadEditOpen()}
|
||||
title={copy.editMessage}
|
||||
type="button"
|
||||
>
|
||||
@@ -1077,6 +1168,8 @@ 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
|
||||
|
||||
@@ -1330,7 +1423,10 @@ 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) {
|
||||
@@ -1599,9 +1695,8 @@ 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)] leading-(--dt-line-height) text-foreground/95 outline-none',
|
||||
'ui-prompt-input-editor__input max-h-48 w-full resize-none bg-transparent p-0 pr-7 text-[length:var(--conversation-text-font-size)] text-foreground/95 outline-none',
|
||||
'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60',
|
||||
'**:data-ref-text:cursor-default',
|
||||
expanded ? 'min-h-16' : 'min-h-[1.25rem]'
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
|
||||
import { cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { clearAllPrompts, setApprovalRequest } from '@/store/prompts'
|
||||
import { $activeSessionId } from '@/store/session'
|
||||
import { clearDismissedToolRows } from '@/store/tool-dismiss'
|
||||
import { $toolDisclosureStates } from '@/store/tool-view'
|
||||
|
||||
import { Thread } from './thread'
|
||||
@@ -104,6 +105,84 @@ function groupedPendingMessage(): ThreadMessage {
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function pendingOnlyMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-pending-only',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'term-only',
|
||||
toolName: 'terminal',
|
||||
args: { command: 'sleep 10' },
|
||||
argsText: JSON.stringify({ command: 'sleep 10' })
|
||||
}
|
||||
],
|
||||
status: { type: 'running' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function completedOnlyMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-completed-only',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'read-only',
|
||||
toolName: 'read_file',
|
||||
args: { path: '/etc/hosts' },
|
||||
argsText: JSON.stringify({ path: '/etc/hosts' }),
|
||||
result: { content: '127.0.0.1 localhost' }
|
||||
}
|
||||
],
|
||||
status: { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function failedOnlyMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-failed-only',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'term-failed',
|
||||
toolName: 'terminal',
|
||||
args: { command: 'exit 1' },
|
||||
argsText: JSON.stringify({ command: 'exit 1' }),
|
||||
isError: true,
|
||||
result: { stderr: 'boom' }
|
||||
}
|
||||
],
|
||||
status: { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function GroupHarness({ message }: { message: ThreadMessage }) {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: [message],
|
||||
@@ -122,12 +201,14 @@ beforeEach(() => {
|
||||
clearAllPrompts()
|
||||
$activeSessionId.set('sess-1')
|
||||
$toolDisclosureStates.set({})
|
||||
clearDismissedToolRows()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
clearAllPrompts()
|
||||
$activeSessionId.set(null)
|
||||
clearDismissedToolRows()
|
||||
})
|
||||
|
||||
describe('flat tool list approval surfacing', () => {
|
||||
@@ -155,4 +236,64 @@ describe('flat tool list approval surfacing', () => {
|
||||
expect(bar?.closest('[hidden]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('lets completed tool rows be dismissed', async () => {
|
||||
const { container } = render(<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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -84,6 +84,19 @@ describe('PendingToolApproval', () => {
|
||||
expect($approvalRequest.get()).toBeNull()
|
||||
})
|
||||
|
||||
it('reveals the full command inline when the Command toggle is clicked', () => {
|
||||
const longCommand = 'python -c "' + 'x'.repeat(400) + '"'
|
||||
setRequest(longCommand)
|
||||
render(<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()
|
||||
|
||||
@@ -16,6 +16,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { ChevronDown, Loader2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $approvalRequest, type ApprovalRequest, clearApprovalRequest } from '@/store/prompts'
|
||||
@@ -60,9 +61,15 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
|
||||
// "Always allow" persists the pattern to ~/.hermes/config.yaml permanently, so
|
||||
// it goes through a confirm step rather than firing straight from the menu.
|
||||
const [confirmAlways, setConfirmAlways] = useState(false)
|
||||
// The pending tool row only shows a single truncated line of the command, and
|
||||
// a pending row can't be expanded (no result yet), so the full command was
|
||||
// previously only reachable via the "Always allow" modal. Let the user reveal
|
||||
// it inline instead — "expand, Run" (2 clicks) rather than the modal dance.
|
||||
const [showCommand, setShowCommand] = useState(false)
|
||||
const busy = submitting !== null
|
||||
// false when the backend won't honor a permanent allow (tirith warning) → hide "Always allow".
|
||||
const allowPermanent = request.allowPermanent !== false
|
||||
const hasCommand = request.command.trim().length > 0
|
||||
|
||||
const respond = useCallback(
|
||||
async (choice: ApprovalChoice) => {
|
||||
@@ -119,70 +126,89 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
|
||||
}, [confirmAlways, respond])
|
||||
|
||||
return (
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<Button
|
||||
className="h-full gap-1 rounded-none px-2 text-xs font-medium text-primary hover:bg-primary/15 hover:text-primary"
|
||||
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('once')}
|
||||
onClick={() => void respond('deny')}
|
||||
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>}
|
||||
{submitting === 'deny' ? <Loader2 className="size-3 animate-spin" /> : copy.reject}
|
||||
{submitting !== 'deny' && <span className="text-[0.625rem] opacity-55">Esc</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>
|
||||
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
|
||||
<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() && (
|
||||
|
||||
@@ -12,16 +12,20 @@ import { DiffLines } from '@/components/chat/diff-lines'
|
||||
import { DisclosureRow } from '@/components/chat/disclosure-row'
|
||||
import { PreviewAttachment } from '@/components/chat/preview-attachment'
|
||||
import { ZoomableImage } from '@/components/chat/zoomable-image'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { FadeText } from '@/components/ui/fade-text'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { ToolIcon } from '@/components/ui/tool-icon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link'
|
||||
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
|
||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $toolInlineDiffs } from '@/store/tool-diffs'
|
||||
import { $toolRowDismissed, dismissToolRow } from '@/store/tool-dismiss'
|
||||
import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view'
|
||||
|
||||
import { PendingToolApproval } from './tool-approval'
|
||||
@@ -193,13 +197,16 @@ function useDisclosureOpen(disclosureId: string, fallbackOpen = false): boolean
|
||||
function ToolEntry({ part }: ToolEntryProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.assistant.tool
|
||||
const statusCopy = t.statusStack
|
||||
const messageId = useAuiState(s => s.message.id)
|
||||
const messageRunning = useAuiState(selectMessageRunning)
|
||||
const embedded = useContext(ToolEmbedContext)
|
||||
const toolViewMode = useStore($toolViewMode)
|
||||
const disclosureId = `tool-entry:${messageId}:${toolPartDisclosureId(part)}`
|
||||
const dismissed = useStore($toolRowDismissed(disclosureId))
|
||||
const open = useDisclosureOpen(disclosureId)
|
||||
const isPending = messageRunning && part.result === undefined
|
||||
const canDismiss = !isPending && !embedded
|
||||
// Only animate entries that mount while their message is actively
|
||||
// streaming — historical sessions mount with `messageRunning === false`,
|
||||
// so they paint statically without a settle cascade. The wrapping group
|
||||
@@ -282,9 +289,33 @@ function ToolEntry({ part }: ToolEntryProps) {
|
||||
// the disclosure caret hard to hit. Copy now lives in the expanded body's
|
||||
// top-right, where it can't fight the caret for the right edge.
|
||||
const trailing =
|
||||
isPending && !embedded ? (
|
||||
<ActivityTimerText className={TOOL_HEADER_DURATION_CLASS} seconds={elapsed} />
|
||||
) : undefined
|
||||
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
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -297,6 +328,7 @@ 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}
|
||||
|
||||
@@ -14,12 +14,19 @@ import { cn } from '@/lib/utils'
|
||||
// title text, NOT the full row — and reaches just past the chevron with
|
||||
// `-mx-1.5 px-1.5` so it reads as a soft hit-target rather than a slab
|
||||
// stretching to the message edge.
|
||||
// - `trailing` overlays the right edge (absolute) and must stay
|
||||
// non-interactive (e.g. a duration timer) — an opacity-0-but-clickable
|
||||
// control there steals clicks from the caret. Interactive controls go in
|
||||
// `action`, which lays out *in flow* at the far right so it never sits on
|
||||
// top of the caret's hit-target, no matter how long the title is.
|
||||
export function DisclosureRow({
|
||||
action,
|
||||
children,
|
||||
onToggle,
|
||||
open,
|
||||
trailing
|
||||
}: {
|
||||
action?: ReactNode
|
||||
children: ReactNode
|
||||
onToggle?: () => void
|
||||
open: boolean
|
||||
@@ -55,6 +62,11 @@ export function DisclosureRow({
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, type ReactNode, useContext, useMemo, useState } from 'react'
|
||||
|
||||
type Value = {
|
||||
isPending: boolean
|
||||
setPending: (pending: boolean) => void
|
||||
}
|
||||
|
||||
const Ctx = createContext<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)
|
||||
@@ -0,0 +1,174 @@
|
||||
'use client'
|
||||
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
|
||||
import { DiffusionCanvas } from '@/components/chat/image-generation-placeholder'
|
||||
import { ImageActionButton, ImageLightbox } from '@/components/chat/zoomable-image'
|
||||
import { useImageDownload } from '@/hooks/use-image-download'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { generatedImageFromResult } from '@/lib/generated-images'
|
||||
import { filePathFromMediaPath, gatewayMediaDataUrl, isRemoteGateway, mediaExternalUrl, mediaName } from '@/lib/media'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Aspect hint from the tool args sizes the frame *before* the image loads, so
|
||||
// the placeholder and the resolved image occupy the same box — no layout shift.
|
||||
const ASPECT_HINTS: Record<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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { type FC, useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
type Rgb = { r: number; g: number; b: number }
|
||||
|
||||
@@ -24,19 +23,26 @@ const smoothstep = (edge0: number, edge1: number, value: number) => {
|
||||
}
|
||||
|
||||
const parseColor = (value: string, fallback: Rgb): Rgb => {
|
||||
const hex = value.trim().match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i)
|
||||
const v = value.trim()
|
||||
|
||||
const hex = v.match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i)
|
||||
|
||||
if (hex) {
|
||||
return {
|
||||
r: Number.parseInt(hex[1], 16),
|
||||
g: Number.parseInt(hex[2], 16),
|
||||
b: Number.parseInt(hex[3], 16)
|
||||
}
|
||||
return { r: Number.parseInt(hex[1], 16), g: Number.parseInt(hex[2], 16), b: Number.parseInt(hex[3], 16) }
|
||||
}
|
||||
|
||||
const rgb = value.trim().match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i)
|
||||
const rgb = v.match(/rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/i)
|
||||
|
||||
return rgb ? { r: Number(rgb[1]), g: Number(rgb[2]), b: Number(rgb[3]) } : fallback
|
||||
if (rgb) {
|
||||
return { r: Number(rgb[1]), g: Number(rgb[2]), b: Number(rgb[3]) }
|
||||
}
|
||||
|
||||
// Chromium serialises `color-mix(in srgb, …)` as `color(srgb r g b / a)` with 0–1 floats.
|
||||
const srgb = v.match(/color\(\s*srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/i)
|
||||
|
||||
return srgb
|
||||
? { r: Math.round(Number(srgb[1]) * 255), g: Math.round(Number(srgb[2]) * 255), b: Math.round(Number(srgb[3]) * 255) }
|
||||
: fallback
|
||||
}
|
||||
|
||||
const mix = (a: Rgb, b: Rgb, amount: number): Rgb => ({
|
||||
@@ -82,17 +88,22 @@ const fbm = (x: number, y: number) => {
|
||||
return value
|
||||
}
|
||||
|
||||
const readTheme = () => {
|
||||
const styles = getComputedStyle(document.documentElement)
|
||||
type Theme = Record<keyof typeof FALLBACKS, Rgb>
|
||||
|
||||
return {
|
||||
card: parseColor(styles.getPropertyValue('--dt-card'), FALLBACKS.card),
|
||||
muted: parseColor(styles.getPropertyValue('--dt-muted'), FALLBACKS.muted),
|
||||
foreground: parseColor(styles.getPropertyValue('--dt-foreground'), FALLBACKS.foreground),
|
||||
primary: parseColor(styles.getPropertyValue('--dt-primary'), FALLBACKS.primary),
|
||||
ring: parseColor(styles.getPropertyValue('--dt-ring'), FALLBACKS.ring)
|
||||
}
|
||||
}
|
||||
const TOKENS = Object.keys(FALLBACKS) as (keyof typeof FALLBACKS)[]
|
||||
|
||||
// `--dt-*` resolve through `var()` chains into `color-mix()`, which
|
||||
// getPropertyValue hands back verbatim — unreadable. Bouncing each token through
|
||||
// a probe's `color` lets the browser compute it to a concrete color we can
|
||||
// parse, so the canvas tracks the live theme instead of a hardcoded fallback.
|
||||
const readTheme = (probe: HTMLElement): Theme =>
|
||||
Object.fromEntries(
|
||||
TOKENS.map(key => {
|
||||
probe.style.color = `var(--dt-${key})`
|
||||
|
||||
return [key, parseColor(getComputedStyle(probe).color, FALLBACKS[key])]
|
||||
})
|
||||
) as Theme
|
||||
|
||||
const fitCanvas = (canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) => {
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
@@ -107,8 +118,13 @@ const fitCanvas = (canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) =>
|
||||
return { width, height }
|
||||
}
|
||||
|
||||
const drawAsciiDiffusion = (ctx: CanvasRenderingContext2D, width: number, height: number, time: number) => {
|
||||
const theme = readTheme()
|
||||
const drawAsciiDiffusion = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
theme: Theme,
|
||||
width: number,
|
||||
height: number,
|
||||
time: number
|
||||
) => {
|
||||
const bg = ctx.createLinearGradient(0, 0, width, height)
|
||||
bg.addColorStop(0, rgba(mix(theme.card, theme.primary, 0.08), 1))
|
||||
bg.addColorStop(0.54, rgba(mix(theme.card, theme.muted, 0.68), 1))
|
||||
@@ -125,6 +141,9 @@ const drawAsciiDiffusion = (ctx: CanvasRenderingContext2D, width: number, height
|
||||
const cellHeight = fontSize * 1.28
|
||||
const cols = Math.ceil(width / cellWidth)
|
||||
const rows = Math.ceil(height / cellHeight)
|
||||
// Normalise both axes by the shorter side so the radial bloom stays a circle
|
||||
// (not a squished ellipse) when the frame isn't landscape.
|
||||
const short = Math.min(width, height)
|
||||
const centerX = 0.53 + Math.sin(time * 0.055) * 0.02
|
||||
const centerY = 0.5 + Math.cos(time * 0.048) * 0.02
|
||||
const timestep = Math.floor(time * 1.15)
|
||||
@@ -138,10 +157,10 @@ const drawAsciiDiffusion = (ctx: CanvasRenderingContext2D, width: number, height
|
||||
for (let col = -1; col <= cols + 1; col += 1) {
|
||||
const x = col * cellWidth + cellWidth * 0.5
|
||||
const y = row * cellHeight + cellHeight * 0.5
|
||||
const nx = x / width
|
||||
const ny = y / height
|
||||
const dx = (nx - centerX) * 1.2
|
||||
const dy = (ny - centerY) * 0.95
|
||||
const sx = (x - centerX * width) / short
|
||||
const sy = (y - centerY * height) / short
|
||||
const dx = sx * 1.2
|
||||
const dy = sy * 0.95
|
||||
const radius = Math.hypot(dx, dy)
|
||||
const angle = Math.atan2(dy, dx)
|
||||
|
||||
@@ -152,7 +171,7 @@ const drawAsciiDiffusion = (ctx: CanvasRenderingContext2D, width: number, height
|
||||
const contour =
|
||||
Math.exp(-((Math.sin(angle * 3 + radius * 17 - time * 0.17) * 0.5 + 0.5 - radius) ** 2) / 0.016) * 0.38
|
||||
|
||||
const stem = Math.exp(-((nx - centerX + 0.05) ** 2 / 0.004 + (ny - centerY - 0.25) ** 2 / 0.08)) * 0.46
|
||||
const stem = Math.exp(-((sx + 0.05) ** 2 / 0.004 + (sy - 0.25) ** 2 / 0.08)) * 0.46
|
||||
|
||||
const latent = clamp(bloom + contour + stem, 0, 1)
|
||||
const staticA = hash2(col + timestep * 19, row - timestep * 11)
|
||||
@@ -224,9 +243,10 @@ const drawAsciiDiffusion = (ctx: CanvasRenderingContext2D, width: number, height
|
||||
ctx.fillRect(0, 0, width, height)
|
||||
}
|
||||
|
||||
const DiffusionCanvas: FC = () => {
|
||||
export const DiffusionCanvas: FC = () => {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
const sizeRef = useRef({ width: 0, height: 0 })
|
||||
const themeRef = useRef<Theme>(FALLBACKS)
|
||||
|
||||
const fitToContainer = useCallback(() => {
|
||||
const canvas = canvasRef.current
|
||||
@@ -241,6 +261,28 @@ const DiffusionCanvas: FC = () => {
|
||||
|
||||
useResizeObserver(fitToContainer, canvasRef)
|
||||
|
||||
useEffect(() => {
|
||||
const probe = document.createElement('span')
|
||||
probe.style.cssText = 'position:absolute;width:0;height:0;visibility:hidden;pointer-events:none'
|
||||
document.documentElement.appendChild(probe)
|
||||
|
||||
const sync = () => {
|
||||
themeRef.current = readTheme(probe)
|
||||
}
|
||||
|
||||
sync()
|
||||
|
||||
// Re-resolve when the theme repaints (`applyTheme` toggles `.dark` and
|
||||
// rewrites inline custom props on the root) instead of per animation frame.
|
||||
const observer = new MutationObserver(sync)
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style', 'data-hermes-mode'] })
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
probe.remove()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
const ctx = canvas?.getContext('2d')
|
||||
@@ -254,7 +296,7 @@ const DiffusionCanvas: FC = () => {
|
||||
let frame = requestAnimationFrame(function draw(now) {
|
||||
const { width, height } = sizeRef.current
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
drawAsciiDiffusion(ctx, width, height, now / 1000)
|
||||
drawAsciiDiffusion(ctx, themeRef.current, width, height, now / 1000)
|
||||
frame = requestAnimationFrame(draw)
|
||||
})
|
||||
|
||||
@@ -265,15 +307,3 @@ const DiffusionCanvas: FC = () => {
|
||||
|
||||
return <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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,9 @@ export function StatusRow({
|
||||
role={onActivate ? 'button' : undefined}
|
||||
tabIndex={onActivate ? 0 : undefined}
|
||||
>
|
||||
<span className="flex size-3.5 shrink-0 items-center justify-center">{leading}</span>
|
||||
{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
|
||||
|
||||
@@ -3,55 +3,17 @@
|
||||
import { type ComponentProps, useState } from 'react'
|
||||
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
import { useImageDownload } from '@/hooks/use-image-download'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Download } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
function imageFilename(src?: string): string {
|
||||
if (!src) {
|
||||
return 'image'
|
||||
}
|
||||
|
||||
try {
|
||||
const { pathname } = new URL(src, window.location.href)
|
||||
|
||||
return pathname.split('/').filter(Boolean).pop() || 'image'
|
||||
} catch {
|
||||
return src.split(/[\\/]/).filter(Boolean).pop() || 'image'
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingIpcHandler(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''
|
||||
|
||||
return message.includes("No handler registered for 'hermes:saveImageFromUrl'")
|
||||
}
|
||||
|
||||
async function startBrowserDownload(src: string) {
|
||||
const response = await fetch(src)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Could not fetch image: ${response.status}`)
|
||||
}
|
||||
|
||||
const blobUrl = URL.createObjectURL(await response.blob())
|
||||
const link = document.createElement('a')
|
||||
link.href = blobUrl
|
||||
link.download = imageFilename(src)
|
||||
link.rel = 'noopener noreferrer'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000)
|
||||
}
|
||||
|
||||
export interface ZoomableImageProps extends ComponentProps<'img'> {
|
||||
containerClassName?: string
|
||||
slot?: string
|
||||
}
|
||||
|
||||
interface ImageActionCopy {
|
||||
export interface ImageActionCopy {
|
||||
downloadImage: string
|
||||
savingImage: string
|
||||
}
|
||||
@@ -59,70 +21,10 @@ interface ImageActionCopy {
|
||||
export function ZoomableImage({ className, containerClassName, src, alt, slot, ...props }: ZoomableImageProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.desktop
|
||||
const [saving, setSaving] = useState(false)
|
||||
const { download, saving } = useImageDownload(src)
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false)
|
||||
const canOpen = Boolean(src)
|
||||
|
||||
async function handleDownload() {
|
||||
if (!src || saving) {
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
|
||||
try {
|
||||
if (window.hermesDesktop?.saveImageFromUrl) {
|
||||
const saved = await window.hermesDesktop.saveImageFromUrl(src)
|
||||
|
||||
if (saved) {
|
||||
notify({ kind: 'success', title: copy.imageSaved, message: imageFilename(src) })
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await startBrowserDownload(src)
|
||||
} catch (error) {
|
||||
if (isMissingIpcHandler(error)) {
|
||||
try {
|
||||
await startBrowserDownload(src)
|
||||
notify({
|
||||
kind: 'info',
|
||||
title: copy.downloadStarted,
|
||||
message: copy.restartToUseSaveImage
|
||||
})
|
||||
} catch (fallbackError) {
|
||||
notifyError(fallbackError, copy.restartToSaveImages)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
notifyError(error, copy.imageDownloadFailed)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const lightbox = src ? (
|
||||
<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
|
||||
@@ -138,30 +40,79 @@ export function ZoomableImage({ className, containerClassName, src, alt, slot, .
|
||||
>
|
||||
<img alt={alt ?? ''} className={className} src={src} {...props} />
|
||||
</button>
|
||||
{src && <ImageActionButton copy={copy} onClick={handleDownload} saving={saving} variant="inline" />}
|
||||
{src && (
|
||||
<ImageActionButton className="group-hover/image:opacity-100" copy={copy} onClick={download} saving={saving} />
|
||||
)}
|
||||
</span>
|
||||
{lightbox}
|
||||
{src && (
|
||||
<ImageLightbox
|
||||
alt={alt}
|
||||
copy={copy}
|
||||
onClick={download}
|
||||
onOpenChange={setLightboxOpen}
|
||||
open={lightboxOpen}
|
||||
saving={saving}
|
||||
src={src}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ImageActionButton({
|
||||
export function ImageLightbox({
|
||||
alt,
|
||||
copy,
|
||||
onClick,
|
||||
onOpenChange,
|
||||
open,
|
||||
saving,
|
||||
variant
|
||||
src
|
||||
}: {
|
||||
alt?: string
|
||||
copy: ImageActionCopy
|
||||
onClick: () => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
open: boolean
|
||||
saving: boolean
|
||||
src: string
|
||||
}) {
|
||||
return (
|
||||
<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',
|
||||
variant === 'inline' ? 'group-hover/image:opacity-100' : 'group-hover/lightbox:opacity-100'
|
||||
className
|
||||
)}
|
||||
disabled={saving}
|
||||
onClick={event => {
|
||||
|
||||
@@ -3,23 +3,23 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { $desktopBoot } from '@/store/boot'
|
||||
import { $desktopOnboarding } from '@/store/onboarding'
|
||||
import { $gatewayState, setGatewayState } from '@/store/session'
|
||||
import { 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) is
|
||||
// shown whenever `gatewayState !== 'open' && !boot.error`. The ONLY escape
|
||||
// report. The connecting overlay (z-1200, full-screen, pointer-events on) used
|
||||
// to be 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 and never sets boot.error. So gatewayState sits at
|
||||
// 'closed'/'error' with boot.error null → CONNECTING forever, recovery overlay
|
||||
// never appears, settings unreachable.
|
||||
// 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.
|
||||
|
||||
function resetStores() {
|
||||
setGatewayState('idle')
|
||||
@@ -75,7 +75,7 @@ describe('connecting overlay vs recovery surface', () => {
|
||||
expect(isConnectingShown()).toBe(false)
|
||||
})
|
||||
|
||||
it('REPRO: remote socket drops AFTER a successful boot → stuck on CONNECTING, no recovery, no settings', () => {
|
||||
it('post-boot socket drops do not re-cover the app with the initial CONNECTING overlay', () => {
|
||||
// 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 connecting overlay reappears and latches...
|
||||
expect(isConnectingShown()).toBe(true)
|
||||
// ...with NO recovery surface, because boot.error was never set.
|
||||
// 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)
|
||||
expect(isRecoveryShown()).toBe(false)
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
setGatewayState('error')
|
||||
rerender(
|
||||
<>
|
||||
@@ -113,7 +113,7 @@ describe('connecting overlay vs recovery surface', () => {
|
||||
</>
|
||||
)
|
||||
expect($desktopBoot.get().error).toBeNull()
|
||||
expect(isConnectingShown()).toBe(true)
|
||||
expect(isConnectingShown()).toBe(false)
|
||||
expect(isRecoveryShown()).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
@@ -52,7 +52,13 @@ export function GatewayConnectingOverlay() {
|
||||
const [tail, setTail] = useState(TAIL)
|
||||
const [phase, setPhase] = useState<Phase>('live')
|
||||
|
||||
const connecting = gatewayState !== 'open' && !boot.error
|
||||
// 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
|
||||
// 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.
|
||||
|
||||
@@ -15,5 +15,29 @@ 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}</>
|
||||
}
|
||||
|
||||
@@ -34,14 +34,21 @@ function FadeTextImpl({ children, className, fadeWidth = '3rem', style, ...rest
|
||||
const ref = useRef<HTMLSpanElement>(null)
|
||||
const [overflowing, setOverflowing] = useState(false)
|
||||
|
||||
const measureOverflow = useCallback(() => {
|
||||
const measureOverflow = useCallback((entries: readonly ResizeObserverEntry[]) => {
|
||||
const el = ref.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
setOverflowing(el.scrollWidth - el.clientWidth > 1)
|
||||
// `clientWidth` from the RO entry when available (already computed);
|
||||
// `scrollWidth` is unavoidable — content width isn't part of the entry —
|
||||
// but inside RO timing layout is already clean so the read is cheap.
|
||||
const clientWidth = entries.find(entry => entry.target === el)?.contentRect?.width ?? el.clientWidth
|
||||
|
||||
// setState is identity-stable: React bails out when the boolean doesn't
|
||||
// change, so repeated RO fires with the same answer don't re-render.
|
||||
setOverflowing(el.scrollWidth - clientWidth > 1)
|
||||
}, [])
|
||||
|
||||
useResizeObserver(measureOverflow, ref)
|
||||
|
||||
Vendored
+16
@@ -69,6 +69,10 @@ declare global {
|
||||
getRecentLogs: () => Promise<{ path: string; lines: string[] }>
|
||||
readDir: (path: string) => Promise<HermesReadDirResult>
|
||||
gitRoot?: (path: string) => Promise<string | null>
|
||||
// Resolve git-worktree identity for a batch of session cwds, reading git's
|
||||
// on-disk metadata locally. Returns null per cwd that isn't inside a
|
||||
// checkout (or can't be read — e.g. a remote backend's path).
|
||||
worktrees?: (cwds: string[]) => Promise<Record<string, HermesWorktreeInfo | null>>
|
||||
terminal: {
|
||||
dispose: (id: string) => Promise<boolean>
|
||||
onData: (id: string, callback: (payload: string) => void) => () => void
|
||||
@@ -441,6 +445,18 @@ export interface HermesPreviewWatch {
|
||||
path: string
|
||||
}
|
||||
|
||||
export interface HermesWorktreeInfo {
|
||||
// Main repo root — the shared grouping key for a checkout and all its linked
|
||||
// worktrees.
|
||||
repoRoot: string
|
||||
// This cwd's own worktree root.
|
||||
worktreeRoot: string
|
||||
// True when this is the repo's primary checkout (.git is a directory).
|
||||
isMainWorktree: boolean
|
||||
// Current branch (or short detached-HEAD sha), null when unreadable.
|
||||
branch: null | string
|
||||
}
|
||||
|
||||
export interface HermesReadDirEntry {
|
||||
name: string
|
||||
path: string
|
||||
|
||||
@@ -210,6 +210,19 @@ export function searchSessions(query: string): Promise<SessionSearchResponse> {
|
||||
})
|
||||
}
|
||||
|
||||
// Resolves a single session row by id on one backend (the active profile, or
|
||||
// the given `profile`). The backend resolves exact ids and unique prefixes and
|
||||
// 404s when the id isn't on that profile — so a cheap by-id lookup replaces the
|
||||
// cross-profile list scan when locating an unknown id's owner.
|
||||
export function getSession(id: string, profile?: string | null): Promise<SessionInfo> {
|
||||
const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : ''
|
||||
|
||||
return window.hermesDesktop.api<SessionInfo>({
|
||||
...(profile ? { profile } : {}),
|
||||
path: `/api/sessions/${encodeURIComponent(id)}${suffix}`
|
||||
})
|
||||
}
|
||||
|
||||
// Reads another profile's transcript. For a remote profile Electron reroutes
|
||||
// this GET to the remote backend (which serves its own state.db); for a local
|
||||
// profile the primary opens that profile's state.db via ?profile=. Omit for
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
import { useI18n } from '@/i18n'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
export function imageFilename(src?: string): string {
|
||||
if (!src) {
|
||||
return 'image'
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(src, window.location.href).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)
|
||||
}
|
||||
|
||||
/** Save an image to disk via the desktop IPC bridge, falling back to a browser
|
||||
* download when the handler is unavailable (older shell / web preview). */
|
||||
export function useImageDownload(src?: string) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.desktop
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const download = useCallback(async () => {
|
||||
if (!src || saving) {
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
|
||||
try {
|
||||
if (window.hermesDesktop?.saveImageFromUrl) {
|
||||
if (await window.hermesDesktop.saveImageFromUrl(src)) {
|
||||
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)
|
||||
}
|
||||
}, [copy, saving, src])
|
||||
|
||||
return { download, saving }
|
||||
}
|
||||
@@ -1,17 +1,26 @@
|
||||
import { type RefObject, useLayoutEffect, useRef } from 'react'
|
||||
|
||||
export function useResizeObserver(onResize: () => void, ...refs: readonly RefObject<Element | null>[]) {
|
||||
/**
|
||||
* Observe element resizes. The callback receives the ResizeObserver entries
|
||||
* (empty on the initial synchronous call and in non-RO environments) so
|
||||
* callers can read the observed size off the entry instead of forcing a
|
||||
* fresh layout read.
|
||||
*/
|
||||
export function useResizeObserver(
|
||||
onResize: (entries: readonly ResizeObserverEntry[]) => void,
|
||||
...refs: readonly RefObject<Element | null>[]
|
||||
) {
|
||||
const refsRef = useRef(refs)
|
||||
refsRef.current = refs
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
onResize()
|
||||
onResize([])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => onResize())
|
||||
const observer = new ResizeObserver(entries => onResize(entries))
|
||||
let observed = false
|
||||
|
||||
for (const ref of refsRef.current) {
|
||||
@@ -31,7 +40,7 @@ export function useResizeObserver(onResize: () => void, ...refs: readonly RefObj
|
||||
return
|
||||
}
|
||||
|
||||
onResize()
|
||||
onResize([])
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [onResize])
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { uniqueCwds, type WorktreeResolver } from '@/app/chat/sidebar/workspace-groups'
|
||||
import type { HermesWorktreeInfo } from '@/global'
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { desktopFsCacheKey, desktopWorktrees } from '@/lib/desktop-fs'
|
||||
|
||||
type WorktreeMap = Record<string, HermesWorktreeInfo | null>
|
||||
|
||||
/**
|
||||
* Probe the local filesystem for the git-worktree identity of each session cwd
|
||||
* and return a resolver the grouping uses to build `parent → worktree`. Results
|
||||
* are cached per cwd (and reset when the backend connection changes), so a probe
|
||||
* runs once per directory. Unresolved cwds (probe pending, remote backend, or
|
||||
* non-git dirs) fall back to the path-name heuristic in `workspaceTreeFor`.
|
||||
*/
|
||||
export function useWorktreeInfo(sessions: SessionInfo[], enabled: boolean): WorktreeResolver {
|
||||
const [map, setMap] = useState<WorktreeMap>({})
|
||||
const cacheRef = useRef<{ data: WorktreeMap; key: string }>({ data: {}, key: '' })
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = desktopFsCacheKey()
|
||||
|
||||
if (cacheRef.current.key !== key) {
|
||||
cacheRef.current = { data: {}, key }
|
||||
setMap({})
|
||||
}
|
||||
|
||||
const missing = uniqueCwds(sessions).filter(cwd => !(cwd in cacheRef.current.data))
|
||||
|
||||
if (!missing.length) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
void desktopWorktrees(missing)
|
||||
.then(result => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
// Record every probed cwd (null when absent) so we never re-probe it.
|
||||
const next: WorktreeMap = { ...cacheRef.current.data }
|
||||
|
||||
for (const cwd of missing) {
|
||||
next[cwd] = result[cwd] ?? null
|
||||
}
|
||||
|
||||
cacheRef.current = { data: next, key }
|
||||
setMap(next)
|
||||
})
|
||||
.catch(() => {
|
||||
// Bridge unavailable / probe failed — leave cwds unresolved so the
|
||||
// heuristic fallback handles them.
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [sessions, enabled])
|
||||
|
||||
return useMemo<WorktreeResolver>(() => (cwd: string) => map[cwd], [map])
|
||||
}
|
||||
@@ -643,6 +643,7 @@ export const en: Translations = {
|
||||
back: 'Back',
|
||||
searchPlaceholder: 'Search sessions, views, and actions',
|
||||
goTo: 'Go to',
|
||||
goToSession: 'Go to session',
|
||||
commandCenter: 'Command Center',
|
||||
appearance: 'Appearance',
|
||||
settings: 'Settings',
|
||||
@@ -1210,6 +1211,8 @@ export const en: Translations = {
|
||||
queueSendNext: 'Next',
|
||||
queueSend: 'Send',
|
||||
queueDelete: 'Delete',
|
||||
queueStuckTitle: 'Queued message not sent',
|
||||
queueStuckBody: 'A queued turn kept failing to send. It is still in the queue — try sending it again.',
|
||||
previewUnavailable: 'Preview unavailable',
|
||||
previewLabel: label => `Preview ${label}`,
|
||||
couldNotPreview: label => `Could not preview ${label}`,
|
||||
@@ -1653,6 +1656,7 @@ export const en: Translations = {
|
||||
assistant: {
|
||||
thread: {
|
||||
loadingSession: 'Loading session',
|
||||
showEarlier: 'Show earlier messages',
|
||||
loadingResponse: 'Hermes is loading a response',
|
||||
thinking: 'Thinking',
|
||||
today: time => `Today, ${time}`,
|
||||
@@ -1666,6 +1670,7 @@ export const en: Translations = {
|
||||
stopReading: 'Stop reading',
|
||||
readAloud: 'Read aloud',
|
||||
editMessage: 'Edit message',
|
||||
scrollToBottom: 'Scroll to bottom',
|
||||
stop: 'Stop',
|
||||
restorePrevious: 'Restore previous checkpoint',
|
||||
restoreCheckpoint: 'Restore checkpoint',
|
||||
@@ -1682,6 +1687,7 @@ export const en: Translations = {
|
||||
gatewayDisconnected: 'Hermes gateway is not connected',
|
||||
sendFailed: 'Could not send approval response',
|
||||
run: 'Run',
|
||||
command: 'Command',
|
||||
moreOptions: 'More approval options',
|
||||
allowSession: 'Allow this session',
|
||||
alwaysAllowMenu: 'Always allow…',
|
||||
|
||||
@@ -773,6 +773,7 @@ export const ja = defineLocale({
|
||||
back: '戻る',
|
||||
searchPlaceholder: 'セッション、ビュー、アクションを検索',
|
||||
goTo: '移動',
|
||||
goToSession: 'セッションへ移動',
|
||||
commandCenter: 'コマンドセンター',
|
||||
appearance: '外観',
|
||||
settings: '設定',
|
||||
@@ -1348,6 +1349,8 @@ export const ja = defineLocale({
|
||||
queueSendNext: '次に送信',
|
||||
queueSend: '送信',
|
||||
queueDelete: '削除',
|
||||
queueStuckTitle: 'キュー内のメッセージを送信できません',
|
||||
queueStuckBody: 'キューに入れたターンの送信が繰り返し失敗しました。まだキューに残っています。もう一度送信してください。',
|
||||
previewUnavailable: 'プレビューは利用できません',
|
||||
previewLabel: label => `${label} のプレビュー`,
|
||||
couldNotPreview: label => `${label} をプレビューできませんでした`,
|
||||
@@ -1794,6 +1797,7 @@ export const ja = defineLocale({
|
||||
assistant: {
|
||||
thread: {
|
||||
loadingSession: 'セッションを読み込み中',
|
||||
showEarlier: '以前のメッセージを表示',
|
||||
loadingResponse: 'Hermes が応答を読み込み中',
|
||||
thinking: '考え中',
|
||||
today: time => `今日 ${time}`,
|
||||
@@ -1823,6 +1827,7 @@ export const ja = defineLocale({
|
||||
gatewayDisconnected: 'Hermes ゲートウェイが接続されていません',
|
||||
sendFailed: '承認応答を送信できませんでした',
|
||||
run: '実行',
|
||||
command: 'コマンド',
|
||||
moreOptions: 'その他の承認オプション',
|
||||
allowSession: 'このセッションで許可',
|
||||
alwaysAllowMenu: '常に許可…',
|
||||
|
||||
@@ -540,6 +540,7 @@ export interface Translations {
|
||||
back: string
|
||||
searchPlaceholder: string
|
||||
goTo: string
|
||||
goToSession: string
|
||||
commandCenter: string
|
||||
appearance: string
|
||||
settings: string
|
||||
@@ -925,6 +926,8 @@ export interface Translations {
|
||||
queueSendNext: string
|
||||
queueSend: string
|
||||
queueDelete: string
|
||||
queueStuckTitle: string
|
||||
queueStuckBody: string
|
||||
previewUnavailable: string
|
||||
previewLabel: (label: string) => string
|
||||
couldNotPreview: (label: string) => string
|
||||
@@ -1312,6 +1315,7 @@ export interface Translations {
|
||||
assistant: {
|
||||
thread: {
|
||||
loadingSession: string
|
||||
showEarlier: string
|
||||
loadingResponse: string
|
||||
thinking: string
|
||||
today: (time: string) => string
|
||||
@@ -1325,6 +1329,7 @@ export interface Translations {
|
||||
stopReading: string
|
||||
readAloud: string
|
||||
editMessage: string
|
||||
scrollToBottom: string
|
||||
stop: string
|
||||
restorePrevious: string
|
||||
restoreCheckpoint: string
|
||||
@@ -1341,6 +1346,7 @@ export interface Translations {
|
||||
gatewayDisconnected: string
|
||||
sendFailed: string
|
||||
run: string
|
||||
command: string
|
||||
moreOptions: string
|
||||
allowSession: string
|
||||
alwaysAllowMenu: string
|
||||
|
||||
@@ -748,6 +748,7 @@ export const zhHant = defineLocale({
|
||||
back: '返回',
|
||||
searchPlaceholder: '搜尋工作階段、檢視和動作',
|
||||
goTo: '前往',
|
||||
goToSession: '前往工作階段',
|
||||
commandCenter: '命令中心',
|
||||
appearance: '外觀',
|
||||
settings: '設定',
|
||||
@@ -1304,6 +1305,8 @@ export const zhHant = defineLocale({
|
||||
queueSendNext: '下一個',
|
||||
queueSend: '傳送',
|
||||
queueDelete: '刪除',
|
||||
queueStuckTitle: '佇列訊息未送出',
|
||||
queueStuckBody: '佇列中的對話多次傳送失敗。它仍在佇列中,請重試傳送。',
|
||||
previewUnavailable: '預覽不可用',
|
||||
previewLabel: label => `預覽 ${label}`,
|
||||
couldNotPreview: label => `無法預覽 ${label}`,
|
||||
@@ -1738,6 +1741,7 @@ export const zhHant = defineLocale({
|
||||
assistant: {
|
||||
thread: {
|
||||
loadingSession: '正在載入工作階段',
|
||||
showEarlier: '顯示較早的訊息',
|
||||
loadingResponse: 'Hermes 正在載入回覆',
|
||||
thinking: '思考中',
|
||||
today: time => `今天,${time}`,
|
||||
@@ -1767,6 +1771,7 @@ export const zhHant = defineLocale({
|
||||
gatewayDisconnected: 'Hermes 閘道未連線',
|
||||
sendFailed: '無法傳送核准回應',
|
||||
run: '執行',
|
||||
command: '指令',
|
||||
moreOptions: '更多核准選項',
|
||||
allowSession: '允許本工作階段',
|
||||
alwaysAllowMenu: '一律允許…',
|
||||
|
||||
@@ -835,6 +835,7 @@ export const zh: Translations = {
|
||||
back: '返回',
|
||||
searchPlaceholder: '搜索会话、视图与操作',
|
||||
goTo: '前往',
|
||||
goToSession: '前往会话',
|
||||
commandCenter: '命令中心',
|
||||
appearance: '外观',
|
||||
settings: '设置',
|
||||
@@ -1398,6 +1399,8 @@ export const zh: Translations = {
|
||||
queueSendNext: '下一个',
|
||||
queueSend: '发送',
|
||||
queueDelete: '删除',
|
||||
queueStuckTitle: '排队消息未发送',
|
||||
queueStuckBody: '排队的对话多次发送失败。它仍在队列中,请重试发送。',
|
||||
previewUnavailable: '预览不可用',
|
||||
previewLabel: label => `预览 ${label}`,
|
||||
couldNotPreview: label => `无法预览 ${label}`,
|
||||
@@ -1833,6 +1836,7 @@ export const zh: Translations = {
|
||||
assistant: {
|
||||
thread: {
|
||||
loadingSession: '正在加载会话',
|
||||
showEarlier: '显示更早的消息',
|
||||
loadingResponse: 'Hermes 正在加载回复',
|
||||
thinking: '思考中',
|
||||
today: time => `今天,${time}`,
|
||||
@@ -1846,6 +1850,7 @@ export const zh: Translations = {
|
||||
stopReading: '停止朗读',
|
||||
readAloud: '朗读',
|
||||
editMessage: '编辑消息',
|
||||
scrollToBottom: '滚动到底部',
|
||||
stop: '停止',
|
||||
restorePrevious: '恢复上一个检查点',
|
||||
restoreCheckpoint: '恢复检查点',
|
||||
@@ -1862,6 +1867,7 @@ export const zh: Translations = {
|
||||
gatewayDisconnected: 'Hermes 网关未连接',
|
||||
sendFailed: '无法发送审批响应',
|
||||
run: '运行',
|
||||
command: '命令',
|
||||
moreOptions: '更多审批选项',
|
||||
allowSession: '允许本会话',
|
||||
alwaysAllowMenu: '始终允许…',
|
||||
|
||||
@@ -95,6 +95,38 @@ describe('toChatMessages', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the generated image on the tool row while preserving agent prose', () => {
|
||||
const [message] = toChatMessages([
|
||||
{
|
||||
content: '',
|
||||
role: 'assistant',
|
||||
timestamp: 1,
|
||||
tool_calls: [{ id: 'img-1', function: { name: 'image_generate', arguments: '{"prompt":"draw a cat"}' } }]
|
||||
},
|
||||
{
|
||||
content: '{"success":true,"image":"https://cdn.example/cat.png"}',
|
||||
role: 'tool',
|
||||
timestamp: 2,
|
||||
tool_call_id: 'img-1',
|
||||
tool_name: 'image_generate'
|
||||
},
|
||||
{
|
||||
content: 'Here you go.\n\n',
|
||||
role: 'assistant',
|
||||
timestamp: 3
|
||||
}
|
||||
])
|
||||
|
||||
const toolPart = message.parts.find(
|
||||
(part): part is Extract<ChatMessagePart, { type: 'tool-call' }> =>
|
||||
part.type === 'tool-call' && part.toolName === 'image_generate'
|
||||
)
|
||||
|
||||
expect(toolPart?.result).toMatchObject({ image: 'https://cdn.example/cat.png', success: true })
|
||||
// The duplicated image is stripped, but the agent's words survive.
|
||||
expect(chatMessageText(message)).toBe('Here you go.')
|
||||
})
|
||||
|
||||
it('coerces non-string message content without throwing', () => {
|
||||
const [message] = toChatMessages([
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ThreadMessageLike } from '@assistant-ui/react'
|
||||
|
||||
import { dedupeGeneratedImageEchoesInParts } from '@/lib/generated-images'
|
||||
import { mediaDisplayLabel, mediaMarkdownHref } from '@/lib/media'
|
||||
import { parseTodos } from '@/lib/todos'
|
||||
import type { SessionMessage, UsageStats } from '@/types/hermes'
|
||||
@@ -811,8 +812,12 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
|
||||
})
|
||||
flushPendingTools(messages.length)
|
||||
|
||||
const withoutGeneratedImageEchoes = result.map(message =>
|
||||
message.role === 'assistant' ? { ...message, parts: dedupeGeneratedImageEchoesInParts(message.parts) } : message
|
||||
)
|
||||
|
||||
return withUniqueToolCallIds(
|
||||
result.filter(m => chatMessageText(m).trim() || m.parts.some(part => part.type !== 'text'))
|
||||
withoutGeneratedImageEchoes.filter(m => chatMessageText(m).trim() || m.parts.some(part => part.type !== 'text'))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type {
|
||||
HermesConnection,
|
||||
HermesReadDirResult,
|
||||
HermesReadFileTextResult,
|
||||
HermesSelectPathsOptions,
|
||||
HermesWorktreeInfo
|
||||
} from '@/global'
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
import type { HermesConnection, HermesReadDirResult, HermesReadFileTextResult, HermesSelectPathsOptions } from '@/global'
|
||||
|
||||
export interface DesktopFsRemotePicker {
|
||||
selectPaths: (options?: HermesSelectPathsOptions) => Promise<string[]>
|
||||
}
|
||||
@@ -75,6 +80,19 @@ export async function desktopGitRoot(path: string): Promise<string | null> {
|
||||
return result.root
|
||||
}
|
||||
|
||||
// Worktree detection runs against the LOCAL filesystem (the electron main
|
||||
// process). For a remote backend the session cwds live on another machine, so
|
||||
// we can't resolve them here — callers fall back to the path-name heuristic.
|
||||
export async function desktopWorktrees(cwds: string[]): Promise<Record<string, HermesWorktreeInfo | null>> {
|
||||
if (isDesktopFsRemoteMode()) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const desktop = bridge()
|
||||
|
||||
return desktop.worktrees ? desktop.worktrees(cwds) : {}
|
||||
}
|
||||
|
||||
export async function desktopDefaultCwd(): Promise<{ branch: string; cwd: string } | null> {
|
||||
if (!isDesktopFsRemoteMode()) {
|
||||
return null
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
dedupeGeneratedImageEchoesInParts,
|
||||
generatedImageEchoSources,
|
||||
generatedImageFromResult,
|
||||
stripGeneratedImageEchoes
|
||||
} from './generated-images'
|
||||
|
||||
describe('generatedImageFromResult', () => {
|
||||
it('prefers the host-visible image path', () => {
|
||||
expect(
|
||||
generatedImageFromResult({
|
||||
agent_visible_image: '/container/cache/cat.png',
|
||||
host_image: '/Users/me/.hermes/cache/images/cat.png',
|
||||
image: '/Users/me/.hermes/cache/images/cat.png',
|
||||
success: true
|
||||
})
|
||||
).toBe('/Users/me/.hermes/cache/images/cat.png')
|
||||
})
|
||||
|
||||
it('ignores failed image generation results', () => {
|
||||
expect(generatedImageFromResult({ image: 'https://cdn.example/cat.png', success: false })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('stripGeneratedImageEchoes', () => {
|
||||
it('removes repeated generated image markdown without removing prose', () => {
|
||||
expect(
|
||||
stripGeneratedImageEchoes('Here you go.\n\n', [
|
||||
'https://cdn.example/cat.png'
|
||||
])
|
||||
).toBe('Here you go.')
|
||||
})
|
||||
|
||||
it('removes media links for generated local image paths', () => {
|
||||
expect(
|
||||
stripGeneratedImageEchoes('Saved image: [Image: cat.png](#media:%2Ftmp%2Fcat.png)', ['/tmp/cat.png'])
|
||||
).toBe('Saved image:')
|
||||
})
|
||||
})
|
||||
|
||||
describe('generatedImageEchoSources', () => {
|
||||
it('collects every path variant the model might restate', () => {
|
||||
expect(
|
||||
generatedImageEchoSources([
|
||||
{
|
||||
result: { agent_visible_image: '/sandbox/cat.png', host_image: '/host/cat.png', image: '/host/cat.png', success: true },
|
||||
toolName: 'image_generate',
|
||||
type: 'tool-call'
|
||||
}
|
||||
])
|
||||
).toEqual(['/host/cat.png', '/sandbox/cat.png'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('dedupeGeneratedImageEchoesInParts', () => {
|
||||
it('keeps the agent prose while removing the duplicated image', () => {
|
||||
expect(
|
||||
dedupeGeneratedImageEchoesInParts([
|
||||
{ text: 'Here is your peacock!  Enjoy.', type: 'text' },
|
||||
{ result: { host_image: '/host/p.png', image: '/host/p.png', success: true }, toolName: 'image_generate', type: 'tool-call' }
|
||||
])
|
||||
).toEqual([
|
||||
{ text: 'Here is your peacock! Enjoy.', type: 'text' },
|
||||
{ result: { host_image: '/host/p.png', image: '/host/p.png', success: true }, toolName: 'image_generate', type: 'tool-call' }
|
||||
])
|
||||
})
|
||||
|
||||
it('strips a sandbox path the model restated instead of the host path', () => {
|
||||
expect(
|
||||
dedupeGeneratedImageEchoesInParts([
|
||||
{ text: '', type: 'text' },
|
||||
{
|
||||
result: { agent_visible_image: '/sandbox/cat.png', host_image: '/host/cat.png', image: '/host/cat.png', success: true },
|
||||
toolName: 'image_generate',
|
||||
type: 'tool-call'
|
||||
}
|
||||
])
|
||||
).toEqual([
|
||||
{
|
||||
result: { agent_visible_image: '/sandbox/cat.png', host_image: '/host/cat.png', image: '/host/cat.png', success: true },
|
||||
toolName: 'image_generate',
|
||||
type: 'tool-call'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves pending generations untouched so the agent prose survives', () => {
|
||||
const parts = [
|
||||
{ text: 'Another peacock, coming up!', type: 'text' },
|
||||
{ result: undefined, toolName: 'image_generate', type: 'tool-call' }
|
||||
]
|
||||
|
||||
expect(dedupeGeneratedImageEchoesInParts(parts)).toEqual(parts)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
type ToolLike = {
|
||||
result?: unknown
|
||||
toolName?: unknown
|
||||
type?: unknown
|
||||
}
|
||||
|
||||
type TextLike = {
|
||||
text?: unknown
|
||||
type?: unknown
|
||||
}
|
||||
|
||||
// Path-ish result fields the model may echo into its prose. Display prefers the
|
||||
// host path (gateway-deliverable); stripping must catch every variant so a
|
||||
// sandbox path the model restated doesn't slip through as a duplicate image.
|
||||
const DISPLAY_KEYS = ['host_image', 'image'] as const
|
||||
const ECHO_KEYS = ['host_image', 'image', 'agent_visible_image'] as const
|
||||
|
||||
function recordFromUnknown(value: unknown): Record<string, unknown> | null {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function stringFields(record: Record<string, unknown>, keys: readonly string[]): string[] {
|
||||
return keys.map(key => record[key]).filter((v): v is string => typeof v === 'string' && v.trim().length > 0)
|
||||
}
|
||||
|
||||
function regexEscape(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
function unique(values: string[]): string[] {
|
||||
return [...new Set(values.filter(Boolean))]
|
||||
}
|
||||
|
||||
function imageResult(part: ToolLike): Record<string, unknown> | null {
|
||||
if (part.type !== 'tool-call' || part.toolName !== 'image_generate') {
|
||||
return null
|
||||
}
|
||||
|
||||
const record = recordFromUnknown(part.result)
|
||||
|
||||
return record && record.success !== false ? record : null
|
||||
}
|
||||
|
||||
/** Display source for a completed `image_generate` result (host path wins). */
|
||||
export function generatedImageFromResult(result: unknown): string | null {
|
||||
const record = recordFromUnknown(result)
|
||||
|
||||
if (!record || record.success === false) {
|
||||
return null
|
||||
}
|
||||
|
||||
return stringFields(record, DISPLAY_KEYS)[0] ?? null
|
||||
}
|
||||
|
||||
/** Every path/URL a generated image might appear as in prose, for de-duping. */
|
||||
export function generatedImageEchoSources(parts: readonly ToolLike[]): string[] {
|
||||
return unique(parts.flatMap(part => stringFields(imageResult(part) ?? {}, ECHO_KEYS)))
|
||||
}
|
||||
|
||||
/** Strip a generated image out of prose so it only ever shows in the tool slot.
|
||||
* Once a generation succeeded (`sources` is non-empty) we drop every embedded
|
||||
* image and media link from that message — the model frequently restates the
|
||||
* remote URL while the result holds the local path, so matching the exact
|
||||
* source is not enough. Bare occurrences of the known paths/URLs are removed
|
||||
* too. Surrounding prose is preserved. */
|
||||
export function stripGeneratedImageEchoes(text: string, sources: readonly string[]): string {
|
||||
if (!text || sources.length === 0) {
|
||||
return text
|
||||
}
|
||||
|
||||
let next = text
|
||||
.replace(/!\[[^\]\n]*\]\([^)\n]*\)/g, '')
|
||||
.replace(/\[[^\]\n]*\]\(\s*#media:[^)\n]*\)/g, '')
|
||||
|
||||
for (const source of unique([...sources])) {
|
||||
next = next.replace(new RegExp(String.raw`(^|[\s([{])<?${regexEscape(source)}>?(?=$|[\s)\]},.!?])`, 'g'), '$1')
|
||||
}
|
||||
|
||||
return next
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.replace(/[ \t]{2,}/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/** Strip generated-image echoes from text parts, dropping any part left empty.
|
||||
* The image lives in the tool slot; prose keeps the agent's actual words. */
|
||||
export function dedupeGeneratedImageEchoesInParts<T extends TextLike & ToolLike>(parts: readonly T[]): T[] {
|
||||
const sources = generatedImageEchoSources(parts)
|
||||
|
||||
if (!sources.length) {
|
||||
return [...parts]
|
||||
}
|
||||
|
||||
return parts
|
||||
.map(part =>
|
||||
part.type === 'text' && typeof part.text === 'string'
|
||||
? { ...part, text: stripGeneratedImageEchoes(part.text, sources) }
|
||||
: part
|
||||
)
|
||||
.filter(part => part.type !== 'text' || (typeof part.text === 'string' && part.text.trim().length > 0))
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { parseMarkdownIntoBlocks } from '@assistant-ui/react-streamdown'
|
||||
import remend from 'remend'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { findRemendWindowStart, tailBoundedRemend } from './remend-tail'
|
||||
|
||||
const CORPUS = `# Heading one
|
||||
|
||||
Intro paragraph with **bold**, *italic*, \`inline code\`, and a [link](https://example.com).
|
||||
|
||||
## Code
|
||||
|
||||
\`\`\`python
|
||||
def main():
|
||||
cost = "$5"
|
||||
print(f"total: $\{cost}")
|
||||
\`\`\`
|
||||
|
||||
Some text after the fence with $x^2 + y^2$ inline math.
|
||||
|
||||
$$
|
||||
\\int_0^1 f(x) dx
|
||||
$$
|
||||
|
||||
- list item one with **bold**
|
||||
- list item two
|
||||
|
||||
| col a | col b |
|
||||
| ----- | ----- |
|
||||
| 1 | 2 |
|
||||
|
||||
~~~js
|
||||
const s = \`template \${value}\`
|
||||
~~~
|
||||
|
||||
Final paragraph with ~~strike~~ and unfinished [link text](https://exa
|
||||
`
|
||||
|
||||
/**
|
||||
* Render-equivalence oracle: full-text remend and tail-bounded remend may
|
||||
* differ in raw string output ONLY in ways that cannot affect rendering —
|
||||
* i.e. after block splitting, every block must be identical. (Streamdown
|
||||
* renders blocks independently, so block-level equality IS render equality.)
|
||||
*/
|
||||
function blocksOf(text: string): string[] {
|
||||
return parseMarkdownIntoBlocks(text)
|
||||
}
|
||||
|
||||
describe('tailBoundedRemend', () => {
|
||||
it('matches full remend block output at every streaming prefix', () => {
|
||||
for (let end = 1; end <= CORPUS.length; end++) {
|
||||
const prefix = CORPUS.slice(0, end)
|
||||
const full = blocksOf(remend(prefix))
|
||||
const tail = blocksOf(tailBoundedRemend(prefix))
|
||||
|
||||
expect(tail, `prefix length ${end}: ${JSON.stringify(prefix.slice(-60))}`).toEqual(full)
|
||||
}
|
||||
})
|
||||
|
||||
it('repairs an unclosed fence opened early in a long message', () => {
|
||||
const text = `intro\n\n\`\`\`python\n${'x = 1\n'.repeat(500)}print("$dollar")`
|
||||
const repaired = tailBoundedRemend(text)
|
||||
|
||||
expect(blocksOf(repaired)).toEqual(blocksOf(remend(text)))
|
||||
// the window must reach back to the fence opener
|
||||
expect(findRemendWindowStart(text)).toBe(text.indexOf('```python'))
|
||||
})
|
||||
|
||||
it('bounds the window to the tail paragraph when no fence is open', () => {
|
||||
const text = `para one\n\npara two\n\npara three with **bold`
|
||||
const start = findRemendWindowStart(text)
|
||||
|
||||
expect(start).toBe(text.indexOf('para three'))
|
||||
expect(tailBoundedRemend(text)).toBe(remend(text))
|
||||
})
|
||||
|
||||
it('widens the window across an open $$ math block', () => {
|
||||
const text = `before\n\n$$\n\\frac{a}{b}`
|
||||
const start = findRemendWindowStart(text)
|
||||
|
||||
expect(start).toBeLessThanOrEqual(text.indexOf('$$'))
|
||||
expect(blocksOf(tailBoundedRemend(text))).toEqual(blocksOf(remend(text)))
|
||||
})
|
||||
|
||||
it('handles closed constructs without modification', () => {
|
||||
const text = `done **bold** and \`code\`\n\n\`\`\`js\nconst a = 1\n\`\`\`\n\nlast line.`
|
||||
|
||||
expect(tailBoundedRemend(text)).toBe(text)
|
||||
})
|
||||
|
||||
it('intentionally diverges from full remend on cross-block dangling openers', () => {
|
||||
// Full remend scans the whole document and appends `**` for an opener
|
||||
// left dangling in an EARLIER block, dumping stray asterisks into the
|
||||
// unrelated tail block ("|**"). Because Streamdown splits into blocks
|
||||
// after the repair, that opener never renders as bold either way — the
|
||||
// tail-bounded result is the cleaner of the two. This test documents
|
||||
// the divergence so a future remend upgrade that changes the behavior
|
||||
// gets noticed.
|
||||
const text = `- item with **dangling\n- item two\n\n|`
|
||||
|
||||
expect(remend(text).endsWith('|**')).toBe(true)
|
||||
expect(tailBoundedRemend(text).endsWith('|')).toBe(true)
|
||||
expect(tailBoundedRemend(text).endsWith('|**')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import remend from 'remend'
|
||||
|
||||
// Tail-bounded incomplete-markdown repair.
|
||||
//
|
||||
// Streamdown's built-in `parseIncompleteMarkdown` runs `remend` over the whole
|
||||
// accumulated message on every streaming flush (~18% of script time on 50KB+
|
||||
// messages). But repairs only ever matter in the trailing block: inline
|
||||
// constructs can't cross a blank line, and Streamdown splits into blocks AFTER
|
||||
// the repair, so a dangling opener in an earlier block can't reach the tail.
|
||||
// We run `remend` on just that block instead.
|
||||
|
||||
const BACKTICK = 96 // `
|
||||
const TILDE = 126 // ~
|
||||
const SPACE = 32
|
||||
const TAB = 9
|
||||
const BACKSLASH = 92
|
||||
|
||||
const isSpace = (c: number) => c === SPACE || c === TAB
|
||||
|
||||
/**
|
||||
* Index of the last top-level block start — the char after the most recent
|
||||
* blank line that sits outside any open code fence or `$$` math block. An
|
||||
* unclosed fence/math always begins after that blank, so it stays wholly
|
||||
* inside the window without separate tracking. One cheap char pass, no regex.
|
||||
*/
|
||||
export function findRemendWindowStart(text: string): number {
|
||||
const n = text.length
|
||||
let inFence = false
|
||||
let fenceChar = 0
|
||||
let fenceRun = 0
|
||||
let inMath = false
|
||||
let boundary = 0
|
||||
let pending = -1 // a blank line, committed to `boundary` once content follows
|
||||
|
||||
for (let lineStart = 0; lineStart <= n; ) {
|
||||
let lineEnd = text.indexOf('\n', lineStart)
|
||||
|
||||
if (lineEnd === -1) {
|
||||
lineEnd = n
|
||||
}
|
||||
|
||||
let i = lineStart
|
||||
|
||||
while (i < lineEnd && isSpace(text.charCodeAt(i))) {
|
||||
i += 1
|
||||
}
|
||||
|
||||
const first = i < lineEnd ? text.charCodeAt(i) : -1
|
||||
let marker = false
|
||||
|
||||
// Fence open/close (``` or ~~~, ≤3 spaces indent).
|
||||
if ((first === BACKTICK || first === TILDE) && i - lineStart <= 3) {
|
||||
let run = i
|
||||
|
||||
while (run < lineEnd && text.charCodeAt(run) === first) {
|
||||
run += 1
|
||||
}
|
||||
|
||||
if (run - i >= 3) {
|
||||
marker = true
|
||||
|
||||
if (!inFence) {
|
||||
inFence = true
|
||||
fenceChar = first
|
||||
fenceRun = run - i
|
||||
} else if (first === fenceChar && run - i >= fenceRun && onlyWhitespace(text, run, lineEnd)) {
|
||||
inFence = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle `$$` math state on plain lines ($$ inside a fence is literal).
|
||||
if (!inFence && !marker) {
|
||||
for (let s = text.indexOf('$$', lineStart); s !== -1 && s < lineEnd - 1; s = text.indexOf('$$', s + 2)) {
|
||||
if (s === 0 || text.charCodeAt(s - 1) !== BACKSLASH) {
|
||||
inMath = !inMath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (first === -1 && !inFence && !inMath) {
|
||||
pending = lineEnd + 1
|
||||
} else if (pending !== -1) {
|
||||
boundary = pending
|
||||
pending = -1
|
||||
}
|
||||
|
||||
lineStart = lineEnd + 1
|
||||
}
|
||||
|
||||
return boundary
|
||||
}
|
||||
|
||||
function onlyWhitespace(text: string, from: number, to: number): boolean {
|
||||
for (let i = from; i < to; i += 1) {
|
||||
if (!isSpace(text.charCodeAt(i))) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function tailBoundedRemend(text: string): string {
|
||||
const start = findRemendWindowStart(text)
|
||||
|
||||
return start <= 0 ? remend(text) : text.slice(0, start) + remend(text.slice(start))
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { persistStringArray, storedStringArray } from './storage'
|
||||
|
||||
describe('string array storage', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
it('removes the key for an empty array', () => {
|
||||
window.localStorage.setItem('test.order', JSON.stringify(['a']))
|
||||
|
||||
persistStringArray('test.order', [])
|
||||
|
||||
expect(window.localStorage.getItem('test.order')).toBeNull()
|
||||
expect(storedStringArray('test.order')).toEqual([])
|
||||
})
|
||||
|
||||
it('persists non-empty arrays', () => {
|
||||
persistStringArray('test.order', ['a', 'b'])
|
||||
|
||||
expect(window.localStorage.getItem('test.order')).toBe(JSON.stringify(['a', 'b']))
|
||||
expect(storedStringArray('test.order')).toEqual(['a', 'b'])
|
||||
})
|
||||
})
|
||||
@@ -58,7 +58,11 @@ export function storedStringArray(key: string): string[] {
|
||||
|
||||
export function persistStringArray(key: string, value: string[]) {
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(value))
|
||||
if (value.length === 0) {
|
||||
window.localStorage.removeItem(key)
|
||||
} else {
|
||||
window.localStorage.setItem(key, JSON.stringify(value))
|
||||
}
|
||||
} catch {
|
||||
// Pins are a local preference; restricted storage should not break chat.
|
||||
}
|
||||
|
||||
@@ -7,9 +7,10 @@ import {
|
||||
dequeueQueuedPrompt,
|
||||
enqueueQueuedPrompt,
|
||||
getQueuedPrompts,
|
||||
migrateQueuedPrompts,
|
||||
promoteQueuedPrompt,
|
||||
removeQueuedPrompt,
|
||||
shouldAutoDrainOnSettle,
|
||||
shouldAutoDrain,
|
||||
updateQueuedPrompt,
|
||||
updateQueuedPromptText
|
||||
} from './composer-queue'
|
||||
@@ -117,32 +118,53 @@ describe('composer queue store', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldAutoDrainOnSettle', () => {
|
||||
const base = { isBusy: false, queueLength: 1, wasBusy: true }
|
||||
|
||||
it('drains the next queued prompt when a turn settles', () => {
|
||||
expect(shouldAutoDrainOnSettle(base)).toBe(true)
|
||||
describe('migrateQueuedPrompts', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.removeItem(QUEUE_STORAGE_KEY)
|
||||
$queuedPromptsBySession.set({})
|
||||
})
|
||||
|
||||
it('drains after an interrupt — the settle edge is the same', () => {
|
||||
// Interrupting to reach a queued message is the point of the queue; the
|
||||
// gateway emits the same settle whether the turn finished or was stopped.
|
||||
expect(shouldAutoDrainOnSettle(base)).toBe(true)
|
||||
it('moves entries from a dead runtime key onto the live one', () => {
|
||||
enqueueQueuedPrompt('rt-old', { attachments: [], text: 'stranded' })
|
||||
|
||||
expect(migrateQueuedPrompts('rt-old', 'rt-new')).toBe(true)
|
||||
expect(getQueuedPrompts('rt-old')).toEqual([])
|
||||
expect(getQueuedPrompts('rt-new').map(e => e.text)).toEqual(['stranded'])
|
||||
// The dead key is dropped from the store entirely.
|
||||
expect($queuedPromptsBySession.get()['rt-old']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not drain when the queue is empty', () => {
|
||||
expect(shouldAutoDrainOnSettle({ ...base, queueLength: 0 })).toBe(false)
|
||||
it('appends after existing target entries (FIFO preserved)', () => {
|
||||
enqueueQueuedPrompt('rt-new', { attachments: [], text: 'already here' })
|
||||
enqueueQueuedPrompt('rt-old', { attachments: [], text: 'migrated' })
|
||||
|
||||
migrateQueuedPrompts('rt-old', 'rt-new')
|
||||
|
||||
expect(getQueuedPrompts('rt-new').map(e => e.text)).toEqual(['already here', 'migrated'])
|
||||
})
|
||||
|
||||
it('ignores steady busy state (no true → false transition)', () => {
|
||||
expect(shouldAutoDrainOnSettle({ ...base, isBusy: true })).toBe(false)
|
||||
it('is a no-op when source is empty or keys match', () => {
|
||||
expect(migrateQueuedPrompts('rt-old', 'rt-new')).toBe(false)
|
||||
expect(migrateQueuedPrompts('rt-x', 'rt-x')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores busy entry (false → true, not a settle)', () => {
|
||||
expect(shouldAutoDrainOnSettle({ ...base, isBusy: true, wasBusy: false })).toBe(false)
|
||||
describe('shouldAutoDrain', () => {
|
||||
it('drains whenever idle with a non-empty queue', () => {
|
||||
expect(shouldAutoDrain({ isBusy: false, queueLength: 1 })).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores steady idle state (was not busy)', () => {
|
||||
expect(shouldAutoDrainOnSettle({ ...base, wasBusy: false })).toBe(false)
|
||||
it('drains on mount/reconnect with no observed busy edge', () => {
|
||||
// The whole point of dropping the edge: a remount resets the busy ref, so an
|
||||
// edge-gated drain would strand the entry. Idle + non-empty must still fire.
|
||||
expect(shouldAutoDrain({ isBusy: false, queueLength: 2 })).toBe(true)
|
||||
})
|
||||
|
||||
it('does not drain mid-turn', () => {
|
||||
expect(shouldAutoDrain({ isBusy: true, queueLength: 1 })).toBe(false)
|
||||
})
|
||||
|
||||
it('does not drain an empty queue', () => {
|
||||
expect(shouldAutoDrain({ isBusy: false, queueLength: 0 })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -209,31 +209,58 @@ export const clearQueuedPrompts = (key: string | null | undefined) => {
|
||||
writeSession(sid, [])
|
||||
}
|
||||
|
||||
/** Inputs to {@link shouldAutoDrainOnSettle}, captured at a `busy` transition. */
|
||||
export interface AutoDrainSettleInput {
|
||||
wasBusy: boolean
|
||||
/**
|
||||
* Move pending entries from a dead session key onto a live one, preserving FIFO
|
||||
* (existing target entries first, migrated entries appended). A backend bounce /
|
||||
* resume can mint a fresh runtime session id for the *same* conversation; the
|
||||
* entries enqueued under the old id would otherwise be stranded under a key
|
||||
* nothing reads anymore. No-op unless both keys resolve and differ.
|
||||
*/
|
||||
export const migrateQueuedPrompts = (
|
||||
fromKey: string | null | undefined,
|
||||
toKey: string | null | undefined
|
||||
): boolean => {
|
||||
const from = sidOf(fromKey)
|
||||
const to = sidOf(toKey)
|
||||
|
||||
if (!from || !to || from === to) {
|
||||
return false
|
||||
}
|
||||
|
||||
const pending = queueFor(from)
|
||||
|
||||
if (pending.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const next = { ...$queuedPromptsBySession.get() }
|
||||
delete next[from]
|
||||
next[to] = [...queueFor(to), ...pending]
|
||||
|
||||
$queuedPromptsBySession.set(next)
|
||||
save(next)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/** Inputs to {@link shouldAutoDrain}. */
|
||||
export interface AutoDrainInput {
|
||||
isBusy: boolean
|
||||
queueLength: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether the composer should auto-drain the next queued prompt when a
|
||||
* turn settles (busy transitions true → false).
|
||||
* Decide whether the composer should auto-drain the next queued prompt.
|
||||
*
|
||||
* Queued turns always advance 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. The
|
||||
* gateway guarantees a settle (message.complete + session.info running:false)
|
||||
* even after an interrupt, so this single edge reliably advances the queue. To
|
||||
* cancel queued turns the user deletes them from the panel.
|
||||
* Edge-independent on purpose: the queue must advance whenever the session is
|
||||
* idle and has pending entries, NOT only on an observed busy true → false edge.
|
||||
* A backend bounce / websocket reconnect remounts the composer and resets the
|
||||
* busy ref to the current value, swallowing the settle edge — an edge-gated
|
||||
* drain would then strand the entry forever. The caller's drain lock
|
||||
* (`drainingQueueRef`) serializes sends so being edge-free can't double-submit.
|
||||
*/
|
||||
export const shouldAutoDrainOnSettle = (params: AutoDrainSettleInput): boolean => {
|
||||
const { isBusy, queueLength, wasBusy } = params
|
||||
export const shouldAutoDrain = ({ isBusy, queueLength }: AutoDrainInput): boolean => !isBusy && queueLength > 0
|
||||
|
||||
// Only react to a true → false transition; ignore steady state and entry.
|
||||
if (isBusy || !wasBusy) {
|
||||
return false
|
||||
}
|
||||
|
||||
return queueLength > 0
|
||||
}
|
||||
/** Auto-drain attempts for one entry before we stop retrying and toast. The
|
||||
* entry stays queued for a manual send; a remount/reconnect resets the count. */
|
||||
export const MAX_AUTO_DRAIN_ATTEMPTS = 4
|
||||
|
||||
@@ -25,7 +25,9 @@ const SIDEBAR_AGENTS_GROUPED_STORAGE_KEY = 'hermes.desktop.agentsGroupedByWorksp
|
||||
const SIDEBAR_CRON_OPEN_STORAGE_KEY = 'hermes.desktop.sidebarCronOpen'
|
||||
const SIDEBAR_MESSAGING_OPEN_STORAGE_KEY = 'hermes.desktop.sidebarMessagingOpen'
|
||||
const SIDEBAR_SESSION_ORDER_STORAGE_KEY = 'hermes.desktop.sessionOrder'
|
||||
const SIDEBAR_SESSION_ORDER_MANUAL_STORAGE_KEY = 'hermes.desktop.sessionOrder.manual'
|
||||
const SIDEBAR_WORKSPACE_ORDER_STORAGE_KEY = 'hermes.desktop.workspaceOrder'
|
||||
const SIDEBAR_WORKSPACE_PARENT_ORDER_STORAGE_KEY = 'hermes.desktop.workspaceParentOrder'
|
||||
const PANES_FLIPPED_STORAGE_KEY = 'hermes.desktop.panesFlipped'
|
||||
|
||||
export const CHAT_SIDEBAR_PANE_ID = 'chat-sidebar'
|
||||
@@ -57,7 +59,11 @@ export const $sidebarWidth: ReadableAtom<number> = computed($paneStates, states
|
||||
|
||||
export const $pinnedSessionIds = atom(storedStringArray(SIDEBAR_PINNED_STORAGE_KEY))
|
||||
export const $sidebarSessionOrderIds = atom(storedStringArray(SIDEBAR_SESSION_ORDER_STORAGE_KEY))
|
||||
export const $sidebarSessionOrderManual = atom(storedBoolean(SIDEBAR_SESSION_ORDER_MANUAL_STORAGE_KEY, false))
|
||||
export const $sidebarWorkspaceOrderIds = atom(storedStringArray(SIDEBAR_WORKSPACE_ORDER_STORAGE_KEY))
|
||||
// Order of the top-level repo "parent" groups in the worktree tree (worktrees
|
||||
// within a parent reuse $sidebarWorkspaceOrderIds).
|
||||
export const $sidebarWorkspaceParentOrderIds = atom(storedStringArray(SIDEBAR_WORKSPACE_PARENT_ORDER_STORAGE_KEY))
|
||||
export const $sidebarPinsOpen = atom(true)
|
||||
// Set by the PaneShell hover-reveal overlay while the sidebar is collapsed; kept
|
||||
// true the whole time it's a floating overlay (not just while shown) so the
|
||||
@@ -84,7 +90,11 @@ $pinnedSessionIds.subscribe(ids => persistStringArray(SIDEBAR_PINNED_STORAGE_KEY
|
||||
$sidebarCronOpen.subscribe(open => persistBoolean(SIDEBAR_CRON_OPEN_STORAGE_KEY, open))
|
||||
$sidebarMessagingOpenIds.subscribe(ids => persistStringArray(SIDEBAR_MESSAGING_OPEN_STORAGE_KEY, [...ids]))
|
||||
$sidebarSessionOrderIds.subscribe(ids => persistStringArray(SIDEBAR_SESSION_ORDER_STORAGE_KEY, [...ids]))
|
||||
$sidebarSessionOrderManual.subscribe(manual => persistBoolean(SIDEBAR_SESSION_ORDER_MANUAL_STORAGE_KEY, manual))
|
||||
$sidebarWorkspaceOrderIds.subscribe(ids => persistStringArray(SIDEBAR_WORKSPACE_ORDER_STORAGE_KEY, [...ids]))
|
||||
$sidebarWorkspaceParentOrderIds.subscribe(ids =>
|
||||
persistStringArray(SIDEBAR_WORKSPACE_PARENT_ORDER_STORAGE_KEY, [...ids])
|
||||
)
|
||||
$sidebarAgentsGrouped.subscribe(grouped => persistBoolean(SIDEBAR_AGENTS_GROUPED_STORAGE_KEY, grouped))
|
||||
$panesFlipped.subscribe(flipped => persistBoolean(PANES_FLIPPED_STORAGE_KEY, flipped))
|
||||
|
||||
@@ -163,12 +173,24 @@ export function setSidebarSessionOrderIds(ids: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
export function setSidebarSessionOrderManual(manual: boolean) {
|
||||
if ($sidebarSessionOrderManual.get() !== manual) {
|
||||
$sidebarSessionOrderManual.set(manual)
|
||||
}
|
||||
}
|
||||
|
||||
export function setSidebarWorkspaceOrderIds(ids: string[]) {
|
||||
if (!arraysEqual($sidebarWorkspaceOrderIds.get(), ids)) {
|
||||
$sidebarWorkspaceOrderIds.set(ids)
|
||||
}
|
||||
}
|
||||
|
||||
export function setSidebarWorkspaceParentOrderIds(ids: string[]) {
|
||||
if (!arraysEqual($sidebarWorkspaceParentOrderIds.get(), ids)) {
|
||||
$sidebarWorkspaceParentOrderIds.set(ids)
|
||||
}
|
||||
}
|
||||
|
||||
export function setSidebarResizing(resizing: boolean) {
|
||||
$isSidebarResizing.set(resizing)
|
||||
}
|
||||
@@ -191,16 +213,15 @@ export function unpinSession(sessionId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function reorderPinnedSession(sessionId: string, targetIndex: number) {
|
||||
// Replace the whole pinned order at once (drag-reorder hands back the new order
|
||||
// rather than a single move). Keep only ids that are actually pinned so a stale
|
||||
// row can't smuggle an unpinned id into the store.
|
||||
export function setPinnedSessionOrder(ids: string[]) {
|
||||
const prev = $pinnedSessionIds.get()
|
||||
const pinned = new Set(prev)
|
||||
const next = ids.filter(id => pinned.has(id))
|
||||
|
||||
if (!prev.includes(sessionId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = insertUniqueId(prev, sessionId, targetIndex)
|
||||
|
||||
if (!arraysEqual(prev, next)) {
|
||||
if (next.length === prev.length && !arraysEqual(prev, next)) {
|
||||
$pinnedSessionIds.set(next)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { atom } from 'nanostores'
|
||||
import { atom, computed } from 'nanostores'
|
||||
|
||||
import { lastVisibleMessageIsUser } from '@/app/chat/thread-loading'
|
||||
import type { ContextSuggestion } from '@/app/types'
|
||||
import type { HermesConnection } from '@/global'
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
@@ -195,6 +196,15 @@ export const $workingSessionIds = atom<string[]>([])
|
||||
export const $activeSessionId = atom<string | null>(null)
|
||||
export const $selectedStoredSessionId = atom<string | null>(null)
|
||||
export const $messages = atom<ChatMessage[]>([])
|
||||
|
||||
// Streaming-stable derivations of $messages. During a token stream the array
|
||||
// is replaced ~30×/s; components that only care about coarse facts (is the
|
||||
// thread empty? is the tail a user message?) subscribe to these instead of
|
||||
// $messages so per-token flushes don't re-render them — nanostores' `computed`
|
||||
// only notifies when the derived VALUE changes.
|
||||
export const $messagesEmpty = computed($messages, messages => messages.length === 0)
|
||||
export const $lastVisibleMessageIsUser = computed($messages, lastVisibleMessageIsUser)
|
||||
|
||||
export const $freshDraftReady = atom(false)
|
||||
export const $busy = atom(false)
|
||||
export const $awaitingResponse = atom(false)
|
||||
|
||||
@@ -1,11 +1,64 @@
|
||||
import { atom } from 'nanostores'
|
||||
import { atom, type WritableAtom } from 'nanostores'
|
||||
|
||||
// "Is the thread parked at the bottom" is owned by use-stick-to-bottom inside
|
||||
// ThreadMessageList (the scroll container). That state lives only in that
|
||||
// subtree, so ThreadMessageList mirrors it into these atoms for the composer,
|
||||
// status stack, and floating jump button — all of which render OUTSIDE the thread.
|
||||
//
|
||||
// `$threadScrolledUp` dims the composer / status stack; `$threadJumpButtonVisible`
|
||||
// shows the floating jump control. Both track `!isAtBottom` today, but stay
|
||||
// separate so their thresholds can diverge again without touching consumers.
|
||||
export const $threadScrolledUp = atom(false)
|
||||
export const $threadJumpButtonVisible = atom(false)
|
||||
|
||||
export function setThreadScrolledUp(value: boolean) {
|
||||
if ($threadScrolledUp.get() === value) {
|
||||
return
|
||||
// Skip no-op writes so subscribers don't churn on every scroll tick.
|
||||
const setter = (target: WritableAtom<boolean>) => (value: boolean) => {
|
||||
if (target.get() !== value) {
|
||||
target.set(value)
|
||||
}
|
||||
|
||||
$threadScrolledUp.set(value)
|
||||
}
|
||||
|
||||
const setScrolledUp = setter($threadScrolledUp)
|
||||
const setJumpButtonVisible = setter($threadJumpButtonVisible)
|
||||
|
||||
export const setThreadAtBottom = (isAtBottom: boolean) => {
|
||||
setScrolledUp(!isAtBottom)
|
||||
setJumpButtonVisible(!isAtBottom)
|
||||
}
|
||||
|
||||
export const resetThreadScroll = () => setThreadAtBottom(true)
|
||||
|
||||
// Cross-component bridge: the jump button lives by the composer, the viewport's
|
||||
// `scrollToBottom` lives inside the thread. The bridge registers a handler; the
|
||||
// button fires it. Mirrors the composer focus/insert emitter pattern.
|
||||
const handlers = new Set<() => void>()
|
||||
|
||||
export const onScrollToBottomRequest = (handler: () => void) => {
|
||||
handlers.add(handler)
|
||||
|
||||
return () => void handlers.delete(handler)
|
||||
}
|
||||
|
||||
export const requestScrollToBottom = () => handlers.forEach(handler => handler())
|
||||
|
||||
// Inline edit grows a sticky human bubble. Fire on pointerdown so the viewport
|
||||
// escapes stick-to-bottom before focus/layout; close clears the edit flag when
|
||||
// the inline composer unmounts.
|
||||
const editOpenHandlers = new Set<() => void>()
|
||||
const editCloseHandlers = new Set<() => void>()
|
||||
|
||||
export const onThreadEditOpen = (handler: () => void) => {
|
||||
editOpenHandlers.add(handler)
|
||||
|
||||
return () => void editOpenHandlers.delete(handler)
|
||||
}
|
||||
|
||||
export const notifyThreadEditOpen = () => editOpenHandlers.forEach(handler => handler())
|
||||
|
||||
export const onThreadEditClose = (handler: () => void) => {
|
||||
editCloseHandlers.add(handler)
|
||||
|
||||
return () => void editCloseHandlers.delete(handler)
|
||||
}
|
||||
|
||||
export const notifyThreadEditClose = () => editCloseHandlers.forEach(handler => handler())
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { atom, computed, type ReadableAtom } from 'nanostores'
|
||||
|
||||
type DismissedToolRows = Record<string, true>
|
||||
|
||||
// Tool rows the user has locally hidden via a row's dismiss control. This is a
|
||||
// *view-only* hide: the underlying tool call still lives in the stored chat
|
||||
// history, but once a turn has settled the user can clear a completed/failed
|
||||
// row out of the way so it stops sitting at the tail of the conversation.
|
||||
//
|
||||
// Kept in module memory (not localStorage, unlike $toolDisclosureStates) on
|
||||
// purpose: the thread is virtualized, so a dismissed row's component unmounts
|
||||
// and remounts as it scrolls — component-local state would forget the dismissal
|
||||
// and the row would pop back. Storing it here survives those remounts for the
|
||||
// life of the app session, while a reload restores every row in place rather
|
||||
// than permanently rewriting history from a stray click.
|
||||
export const $dismissedToolRows = atom<DismissedToolRows>({})
|
||||
|
||||
const dismissedCache = new Map<string, ReadableAtom<boolean>>()
|
||||
|
||||
export function $toolRowDismissed(id: string): ReadableAtom<boolean> {
|
||||
let cached = dismissedCache.get(id)
|
||||
|
||||
if (!cached) {
|
||||
cached = computed($dismissedToolRows, rows => Boolean(rows[id]))
|
||||
dismissedCache.set(id, cached)
|
||||
}
|
||||
|
||||
return cached
|
||||
}
|
||||
|
||||
export function dismissToolRow(id: string) {
|
||||
if (!id || $dismissedToolRows.get()[id]) {
|
||||
return
|
||||
}
|
||||
|
||||
$dismissedToolRows.set({ ...$dismissedToolRows.get(), [id]: true })
|
||||
}
|
||||
|
||||
export function clearDismissedToolRows() {
|
||||
if (Object.keys($dismissedToolRows.get()).length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
$dismissedToolRows.set({})
|
||||
}
|
||||
+91
-23
@@ -419,6 +419,10 @@
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
/* App shell, not a document: the window itself never scrolls on either axis
|
||||
(panes own their own scroll). Belt to the auto-scroll axis-lock in the
|
||||
sidebar reorder DnD — nothing can drag the whole shell sideways. */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
html {
|
||||
@@ -433,7 +437,6 @@
|
||||
font-size: 0.8125rem;
|
||||
line-height: var(--dt-line-height, 1.55);
|
||||
letter-spacing: var(--dt-letter-spacing, 0);
|
||||
overflow: hidden;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
@@ -888,18 +891,25 @@ canvas {
|
||||
|
||||
[data-slot='aui_user-message-root'],
|
||||
[data-slot='aui_edit-composer-root'] {
|
||||
--human-msg-line-height: 1.3;
|
||||
font-size: var(--conversation-text-font-size);
|
||||
}
|
||||
|
||||
[data-slot='aui_user-inline-text'] {
|
||||
line-height: var(--human-msg-line-height);
|
||||
}
|
||||
|
||||
[data-slot='aui_edit-composer-root'] [data-slot='composer-rich-input'] {
|
||||
line-height: var(--human-msg-line-height);
|
||||
}
|
||||
|
||||
/* Sticky human bubbles clamp to ~2 lines with a soft bottom fade so a long
|
||||
prompt doesn't dominate the viewport. The clamp lifts on focus only (clicking
|
||||
opens the edit composer, which shows the full text) — not on hover, so the
|
||||
bubble doesn't jump as the pointer passes over it. No transition: the lift
|
||||
happens in the same click that swaps in the edit composer, so animating it
|
||||
just flashes a half-expanded bubble on the way in. */
|
||||
prompt doesn't dominate the viewport. The clamp lifts only in the edit
|
||||
composer; expanding on read-only :focus-within ran on mousedown (before the
|
||||
swap) and fought stick-to-bottom when parked at the bottom. */
|
||||
.sticky-human-clamp {
|
||||
cursor: pointer;
|
||||
max-height: calc(2 * var(--dt-line-height) * var(--conversation-text-font-size) + 0.15rem);
|
||||
max-height: calc(4 * var(--human-msg-line-height) * var(--conversation-text-font-size) + 0.15rem);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -908,25 +918,18 @@ canvas {
|
||||
mask-image: linear-gradient(to bottom, #000 55%, transparent);
|
||||
}
|
||||
|
||||
.composer-human-message:focus-within .sticky-human-clamp {
|
||||
max-height: min(var(--human-msg-full, 24rem), 24rem);
|
||||
overflow-y: auto;
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
|
||||
/* The thread renders items in natural document flow (padding spacers, not
|
||||
transforms) and @tanstack/react-virtual already adjusts scrollTop itself
|
||||
when an off-screen turn is measured and its real height differs from the
|
||||
220px estimate. The browser's native scroll anchoring (overflow-anchor:
|
||||
auto) would adjust scrollTop for that SAME size delta, so the two
|
||||
double-correct and the view lurches — most visibly on Windows mouse wheels,
|
||||
whose coarse notches mount/measure several under-estimated turns per tick.
|
||||
Opt out of native anchoring so only the virtualizer compensates. */
|
||||
/* Stick-to-bottom owns scrollTop while following. Once escaped, native anchoring
|
||||
is safe and keeps sticky human edits from shoving the viewport; data-editing
|
||||
enables that path before React swaps in the inline editor. */
|
||||
[data-slot='aui_thread-viewport'] {
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
[data-slot='aui_thread-viewport'][data-following='false'],
|
||||
[data-slot='aui_thread-viewport'][data-editing='true'] {
|
||||
overflow-anchor: auto;
|
||||
}
|
||||
|
||||
[data-slot='aui_thread-content'] {
|
||||
max-width: var(--composer-width);
|
||||
padding-inline: 1.5rem;
|
||||
@@ -1154,6 +1157,10 @@ canvas {
|
||||
margin-block-end: 0 !important;
|
||||
}
|
||||
|
||||
[data-slot='aui_assistant-message-content'] .aui-md .aui-md-table thead {
|
||||
border-bottom-color: var(--dt-border) !important;
|
||||
}
|
||||
|
||||
/* Tool / thinking blocks are scaffolding around the model's reply, so we
|
||||
keep them transparent and fade them slightly. The reading column (prose)
|
||||
stays at full strength; scaffolding lifts back to full opacity on
|
||||
@@ -1173,6 +1180,12 @@ canvas {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* A generated image is the deliverable, not scaffolding — keep it at full
|
||||
strength instead of dimming it until hover. */
|
||||
[data-slot='aui_assistant-message-content'] > [data-slot='tool-block']:has([data-slot='aui_generated-image']) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Conversation block rhythm. assistant-ui renders each range as a direct child
|
||||
of the message content with no per-part wrapper, so adjacency rules cover
|
||||
every pairing — first block needs no reset, nested tool rows are untouched.
|
||||
@@ -1255,6 +1268,62 @@ canvas {
|
||||
}
|
||||
}
|
||||
|
||||
/* Floating "jump to bottom" control (see scroll-to-bottom-button.tsx).
|
||||
Directional scale: it contracts toward 1 as it arrives (from 1.1) and keeps
|
||||
contracting to 0.9 as it leaves — always shrinking in the direction of
|
||||
travel, so the motion reads as a soft settle / recede rather than a pop. The
|
||||
X half-offset stays baked into every transform so `left-1/2` centering holds
|
||||
through the animation. */
|
||||
.thread-jump-button {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(0.3rem) scale(0.9);
|
||||
}
|
||||
|
||||
.thread-jump-button[data-state='in'] {
|
||||
animation: thread-jump-in 200ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
.thread-jump-button[data-state='out'] {
|
||||
animation: thread-jump-out 180ms ease-in forwards;
|
||||
}
|
||||
|
||||
@keyframes thread-jump-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(0.3rem) scale(1.1);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes thread-jump-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0) scale(1);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(0.3rem) scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.thread-jump-button[data-state='in'],
|
||||
.thread-jump-button[data-state='out'] {
|
||||
animation: none;
|
||||
transition: opacity 120ms linear;
|
||||
}
|
||||
|
||||
.thread-jump-button[data-state='in'] {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes code-card-stream-glow {
|
||||
from {
|
||||
border-color: color-mix(in srgb, var(--dt-ring) 18%, var(--ui-stroke-tertiary));
|
||||
@@ -1276,4 +1345,3 @@ canvas {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2827,6 +2827,53 @@ def _strip_leaked_terminal_responses(text: str) -> str:
|
||||
return cleaned
|
||||
|
||||
|
||||
def _estimate_tui_input_height(
|
||||
lines: list[str] | tuple[str, ...],
|
||||
prompt_text: str,
|
||||
terminal_columns: int,
|
||||
*,
|
||||
max_height: int = 8,
|
||||
) -> int:
|
||||
"""Estimate classic prompt_toolkit input rows using live terminal cells.
|
||||
|
||||
The TextArea prompt is injected with prompt_toolkit's BeforeInput
|
||||
processor, which means it consumes cells only on logical line 0. After a
|
||||
narrow resize, that first row can leave only one input cell beside an icon
|
||||
prompt such as ``⚔ ``, while continuation rows use the full terminal width.
|
||||
Never substitute a fake wide fallback here: under- or over-allocating the
|
||||
TextArea height leaves stale prompt/input cells visible at the bottom of the
|
||||
terminal.
|
||||
"""
|
||||
try:
|
||||
from prompt_toolkit.utils import get_cwidth
|
||||
except Exception:
|
||||
get_cwidth = lambda value: len(value or "") # type: ignore[assignment]
|
||||
|
||||
try:
|
||||
columns = int(terminal_columns or 0)
|
||||
except (TypeError, ValueError):
|
||||
columns = 0
|
||||
|
||||
columns = max(1, columns)
|
||||
prompt_width = max(0, get_cwidth(prompt_text or ""))
|
||||
|
||||
visual_lines = 0
|
||||
for index, line in enumerate(lines or [""]):
|
||||
# prompt_toolkit's TextArea injects ``prompt`` via BeforeInput, which
|
||||
# applies only to logical line 0. Wrapped continuation rows, and later
|
||||
# logical lines, use the full terminal width. Count the display cells
|
||||
# after that same transformation rather than subtracting the prompt from
|
||||
# every wrapped row.
|
||||
line_width = get_cwidth(line or "")
|
||||
display_width = line_width + (prompt_width if index == 0 else 0)
|
||||
if display_width <= 0:
|
||||
visual_lines += 1
|
||||
else:
|
||||
visual_lines += max(1, -(-display_width // columns))
|
||||
|
||||
return min(max(visual_lines, 1), max(1, int(max_height or 1)))
|
||||
|
||||
|
||||
def _collect_query_images(query: str | None, image_arg: str | None = None) -> tuple[str, list[Path]]:
|
||||
"""Collect local image attachments for single-query CLI flows."""
|
||||
message = query or ""
|
||||
@@ -3689,9 +3736,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
startup UI and ``_replay_output_history`` cannot reconstruct it
|
||||
(the banner was never added to ``_OUTPUT_HISTORY``).
|
||||
|
||||
Instead we just reset prompt_toolkit's renderer cache so the next
|
||||
incremental redraw starts from a clean slate, then let
|
||||
``original_on_resize`` recalculate layout for the new size.
|
||||
Let prompt_toolkit's own resize path run with its renderer cursor
|
||||
cache intact. Its Application._on_resize() starts with
|
||||
renderer.erase(leave_alternate_screen=False), which needs the cached
|
||||
cursor position to move back to the live prompt origin before
|
||||
erase_down(). Resetting the renderer before that erase loses the
|
||||
origin and can leave stale prompt glyphs after a narrow resize.
|
||||
|
||||
We also flag ``_status_bar_suppressed_after_resize`` so the dynamic
|
||||
status bar and input separator rules stay hidden until the next user
|
||||
@@ -3702,14 +3752,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
next prompt restores the bar cleanly.
|
||||
"""
|
||||
self._status_bar_suppressed_after_resize = True
|
||||
try:
|
||||
app.renderer.reset(leave_alternate_screen=False)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
app.invalidate()
|
||||
except Exception:
|
||||
pass
|
||||
original_on_resize()
|
||||
|
||||
def _schedule_resize_recovery(self, app, original_on_resize, delay: float = 0.12) -> None:
|
||||
@@ -12004,26 +12046,17 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
def _input_height():
|
||||
try:
|
||||
from prompt_toolkit.application import get_app
|
||||
from prompt_toolkit.utils import get_cwidth
|
||||
|
||||
doc = input_area.buffer.document
|
||||
prompt_width = max(2, get_cwidth(self._get_tui_prompt_text()))
|
||||
try:
|
||||
available_width = get_app().output.get_size().columns - prompt_width
|
||||
terminal_columns = get_app().output.get_size().columns
|
||||
except Exception:
|
||||
available_width = shutil.get_terminal_size((80, 24)).columns - prompt_width
|
||||
if available_width < 10:
|
||||
available_width = 40
|
||||
visual_lines = 0
|
||||
for line in doc.lines:
|
||||
# Each logical line takes at least 1 visual row; long lines wrap.
|
||||
# Use prompt_toolkit's cell width so CJK wide characters count as 2.
|
||||
line_width = get_cwidth(line)
|
||||
if line_width <= 0:
|
||||
visual_lines += 1
|
||||
else:
|
||||
visual_lines += max(1, -(-line_width // available_width)) # ceil division
|
||||
return min(max(visual_lines, 1), 8)
|
||||
terminal_columns = shutil.get_terminal_size((80, 24)).columns
|
||||
return _estimate_tui_input_height(
|
||||
doc.lines,
|
||||
self._get_tui_prompt_text(),
|
||||
terminal_columns,
|
||||
)
|
||||
except Exception:
|
||||
return 1
|
||||
|
||||
@@ -12765,6 +12798,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
style=style,
|
||||
full_screen=False,
|
||||
mouse_support=False,
|
||||
# The status bar contains wall-clock read-outs (live prompt elapsed
|
||||
# and idle-since-last-turn). Once a turn finishes there may be no
|
||||
# further events to invalidate the app, so prompt_toolkit would keep
|
||||
# rendering the first post-turn value (usually ``✓ 0s``) forever.
|
||||
# A low-rate refresh keeps the clock honest without reintroducing a
|
||||
# custom repaint thread or touching conversation state.
|
||||
refresh_interval=1.0,
|
||||
# Erase the live bottom chrome (status bar, input box, separator
|
||||
# rules) on exit instead of freezing a final copy into scrollback.
|
||||
# Without this, prompt_toolkit's render_as_done teardown repaints
|
||||
|
||||
+44
-20
@@ -3163,6 +3163,38 @@ class BasePlatformAdapter(ABC):
|
||||
pass
|
||||
self._typing_paused.discard(chat_id)
|
||||
|
||||
async def _stop_typing_refresh(
|
||||
self,
|
||||
chat_id: str,
|
||||
typing_task: asyncio.Task | None = None,
|
||||
*,
|
||||
timeout: float = 0.5,
|
||||
stop_attempts: int = 2,
|
||||
) -> None:
|
||||
"""Stop the refresh task and platform typing state as one operation."""
|
||||
self._typing_paused.add(chat_id)
|
||||
try:
|
||||
if typing_task is not None and not typing_task.done():
|
||||
typing_task.cancel()
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.shield(typing_task), timeout=timeout)
|
||||
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||
# The task is cancelled; don't let a slow adapter-specific
|
||||
# cleanup block response delivery or shutdown.
|
||||
pass
|
||||
if not hasattr(self, "stop_typing"):
|
||||
return
|
||||
attempts = max(1, stop_attempts)
|
||||
for attempt in range(attempts):
|
||||
try:
|
||||
await self.stop_typing(chat_id)
|
||||
except Exception:
|
||||
pass
|
||||
if attempt < attempts - 1:
|
||||
await asyncio.sleep(0)
|
||||
finally:
|
||||
self._typing_paused.discard(chat_id)
|
||||
|
||||
def pause_typing_for_chat(self, chat_id: str) -> None:
|
||||
"""Pause typing indicator for a chat (e.g. during approval waits).
|
||||
|
||||
@@ -4092,14 +4124,10 @@ class BasePlatformAdapter(ABC):
|
||||
)
|
||||
|
||||
async def _stop_typing_task() -> None:
|
||||
typing_task.cancel()
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.shield(typing_task), timeout=0.5)
|
||||
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||
# Cancellation cleanup must not block adapter shutdown. The
|
||||
# typing task is already cancelled; if the parent task is also
|
||||
# cancelling, let this message-processing task unwind now.
|
||||
pass
|
||||
await self._stop_typing_refresh(
|
||||
event.source.chat_id,
|
||||
typing_task,
|
||||
)
|
||||
|
||||
try:
|
||||
await self._run_processing_hook("on_processing_start", event)
|
||||
@@ -4486,11 +4514,6 @@ class BasePlatformAdapter(ABC):
|
||||
# callbacks may perform platform I/O; a stuck callback must not
|
||||
# leave the typing refresh task running indefinitely.
|
||||
await _stop_typing_task()
|
||||
try:
|
||||
if hasattr(self, "stop_typing"):
|
||||
await self.stop_typing(event.source.chat_id)
|
||||
except Exception:
|
||||
pass
|
||||
# Fire any one-shot post-delivery callback registered for this
|
||||
# session (e.g. deferred background-review notifications).
|
||||
#
|
||||
@@ -4524,13 +4547,14 @@ class BasePlatformAdapter(ABC):
|
||||
)
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
pass
|
||||
# Also cancel any platform-level persistent typing tasks (e.g. Discord)
|
||||
# that may have been recreated by _keep_typing after the last stop_typing()
|
||||
try:
|
||||
if hasattr(self, "stop_typing"):
|
||||
await self.stop_typing(event.source.chat_id)
|
||||
except Exception:
|
||||
pass
|
||||
# Some adapters keep platform-level typing tasks. If callback
|
||||
# work or a late refresh recreated one, make one final bounded stop
|
||||
# before releasing the session guard.
|
||||
await self._stop_typing_refresh(
|
||||
event.source.chat_id,
|
||||
None,
|
||||
stop_attempts=1,
|
||||
)
|
||||
# Final drain/release boundary: force-flush any timer that missed
|
||||
# the in-band drain before deciding whether the guard can clear.
|
||||
await self._flush_text_debounce_now(session_key)
|
||||
|
||||
+113
-1
@@ -45,10 +45,12 @@ from gateway.platforms.base import (
|
||||
ProcessingOutcome,
|
||||
SendResult,
|
||||
SUPPORTED_DOCUMENT_TYPES,
|
||||
SUPPORTED_VIDEO_TYPES,
|
||||
is_host_excluded_by_no_proxy,
|
||||
resolve_proxy_url,
|
||||
safe_url_for_log,
|
||||
cache_document_from_bytes,
|
||||
cache_video_from_bytes,
|
||||
)
|
||||
|
||||
|
||||
@@ -880,7 +882,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
# doesn't log noisy 404 "unhandled request" warnings.
|
||||
@self._app.event("file_shared")
|
||||
async def handle_file_shared(event, say):
|
||||
pass
|
||||
await self._handle_slack_file_shared(event)
|
||||
|
||||
@self._app.event("file_created")
|
||||
async def handle_file_created(event, say):
|
||||
@@ -2173,6 +2175,84 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
self._cache_assistant_thread_metadata(metadata)
|
||||
self._seed_assistant_thread_session(metadata)
|
||||
|
||||
async def _handle_slack_file_shared(self, event: dict) -> None:
|
||||
"""Fallback for Slack file shares that do not arrive as message.files.
|
||||
|
||||
Slack documents ``file_shared`` as a file-ID-only event; callers must
|
||||
fetch ``files.info`` to get the file object. Keep this intentionally
|
||||
narrow: normal image/audio/document uploads already arrive on the
|
||||
message event, but some video shares have only been observed through
|
||||
this lifecycle event.
|
||||
"""
|
||||
channel_id = event.get("channel_id") or event.get("channel") or ""
|
||||
file_id = event.get("file_id") or (event.get("file") or {}).get("id") or ""
|
||||
if not channel_id or not file_id:
|
||||
return
|
||||
|
||||
team_id = event.get("team_id") or event.get("team") or ""
|
||||
try:
|
||||
client = self._team_clients.get(team_id) if team_id else None
|
||||
info_resp = await (client or self._get_client(channel_id)).files_info(
|
||||
file=file_id
|
||||
)
|
||||
except Exception as exc:
|
||||
response = getattr(exc, "response", None)
|
||||
detail = self._describe_slack_api_error(response, file_obj={"id": file_id})
|
||||
logger.warning("[Slack] files.info error for file_shared %s: %s", file_id, detail or exc)
|
||||
return
|
||||
|
||||
if not info_resp.get("ok"):
|
||||
detail = self._describe_slack_api_error(info_resp, file_obj={"id": file_id})
|
||||
logger.warning(
|
||||
"[Slack] files.info failed for file_shared %s: %s",
|
||||
file_id,
|
||||
detail or info_resp.get("error"),
|
||||
)
|
||||
return
|
||||
|
||||
file_obj = info_resp.get("file") or {}
|
||||
if not str(file_obj.get("mimetype", "")).startswith("video/"):
|
||||
return
|
||||
|
||||
share = None
|
||||
for bucket in (file_obj.get("shares") or {}).values():
|
||||
if not isinstance(bucket, dict):
|
||||
continue
|
||||
channel_shares = bucket.get(channel_id)
|
||||
if channel_shares:
|
||||
share = channel_shares[0]
|
||||
break
|
||||
if share is None:
|
||||
for shares in bucket.values():
|
||||
if shares:
|
||||
share = shares[0]
|
||||
break
|
||||
share = share or {}
|
||||
ts = share.get("ts") or event.get("event_ts") or ""
|
||||
thread_ts = share.get("thread_ts") or ""
|
||||
|
||||
# Give Slack's normal message.file_share event a chance to arrive first.
|
||||
# If it does, _handle_slack_message records the same share ts and this
|
||||
# fallback skips instead of duplicating the user turn.
|
||||
await asyncio.sleep(0.75)
|
||||
if ts and self._dedup.is_duplicate(ts):
|
||||
return
|
||||
|
||||
fallback_event = {
|
||||
"type": "message",
|
||||
"subtype": "file_share",
|
||||
"text": "",
|
||||
"user": event.get("user_id") or file_obj.get("user", ""),
|
||||
"channel": channel_id,
|
||||
"channel_type": "im" if channel_id.startswith("D") else "channel",
|
||||
"team": team_id,
|
||||
"ts": "", # already recorded above; avoid tripping our own dedup guard
|
||||
"files": [file_obj],
|
||||
}
|
||||
if thread_ts and thread_ts != ts:
|
||||
fallback_event["thread_ts"] = thread_ts
|
||||
await self._handle_slack_message(fallback_event)
|
||||
|
||||
async def _handle_slack_message(self, event: dict) -> None:
|
||||
"""Handle an incoming Slack message event."""
|
||||
# Dedup: Slack Socket Mode can redeliver events after reconnects (#4777)
|
||||
@@ -2572,6 +2652,36 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
elif mimetype.startswith("video/") and url:
|
||||
try:
|
||||
original_filename = f.get("name", "")
|
||||
_, ext = os.path.splitext(original_filename)
|
||||
ext = ext.lower()
|
||||
if ext not in SUPPORTED_VIDEO_TYPES:
|
||||
mime_to_ext = {v: k for k, v in SUPPORTED_VIDEO_TYPES.items()}
|
||||
ext = mime_to_ext.get(
|
||||
mimetype.split(";", 1)[0].lower(), ".mp4"
|
||||
)
|
||||
|
||||
raw_bytes = await self._download_slack_file_bytes(
|
||||
url, team_id=team_id
|
||||
)
|
||||
cached_path = cache_video_from_bytes(raw_bytes, ext=ext)
|
||||
media_urls.append(cached_path)
|
||||
media_types.append(SUPPORTED_VIDEO_TYPES.get(ext, mimetype))
|
||||
logger.debug("[Slack] Cached user video: %s", cached_path)
|
||||
except Exception as e: # pragma: no cover - defensive logging
|
||||
detail = self._describe_slack_download_failure(e, file_obj=f)
|
||||
if detail:
|
||||
attachment_notices.append(detail)
|
||||
logger.warning("[Slack] %s", detail)
|
||||
else:
|
||||
logger.warning(
|
||||
"[Slack] Failed to cache video from %s: %s",
|
||||
url,
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
elif url:
|
||||
# Try to handle as a document attachment
|
||||
try:
|
||||
@@ -2666,6 +2776,8 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
if msg_type != MessageType.COMMAND and media_types:
|
||||
if any(m.startswith("image/") for m in media_types):
|
||||
msg_type = MessageType.PHOTO
|
||||
elif any(m.startswith("video/") for m in media_types):
|
||||
msg_type = MessageType.VIDEO
|
||||
elif any(m.startswith("audio/") for m in media_types):
|
||||
msg_type = MessageType.VOICE
|
||||
else:
|
||||
|
||||
@@ -9,6 +9,7 @@ Uses python-telegram-bot library for:
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -347,6 +348,9 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
# Telegram message limits
|
||||
MAX_MESSAGE_LENGTH = 4096
|
||||
supports_code_blocks = True # Telegram MarkdownV2 renders fenced code blocks
|
||||
# Bot API 10.1 Rich Messages cap the raw markdown/html text at 32,768
|
||||
# UTF-8 bytes. Content above this is sent via the legacy chunking path.
|
||||
RICH_MESSAGE_MAX_BYTES = 32768
|
||||
# Threshold for detecting Telegram client-side message splits.
|
||||
# When a chunk is near this limit, a continuation is almost certain.
|
||||
_SPLIT_THRESHOLD = 4000
|
||||
@@ -412,6 +416,13 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
self._mention_patterns = self._compile_mention_patterns()
|
||||
self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first'
|
||||
self._disable_link_previews: bool = self._coerce_bool_extra("disable_link_previews", False)
|
||||
# Bot API 10.1 Rich Messages: send final replies via sendRichMessage
|
||||
# with the raw agent markdown so tables/task lists/etc. render natively.
|
||||
# Latched off after a capability failure on sendRichMessage /
|
||||
# sendRichMessageDraft (e.g. older python-telegram-bot without the
|
||||
# endpoint) so later sends skip the doomed rich attempt entirely.
|
||||
self._rich_send_disabled: bool = False
|
||||
self._rich_draft_disabled: bool = False
|
||||
# Buffer rapid/album photo updates so Telegram image bursts are handled
|
||||
# as a single MessageEvent instead of self-interrupting multiple turns.
|
||||
self._media_batch_delay_seconds = float(os.getenv("HERMES_TELEGRAM_MEDIA_BATCH_DELAY_SECONDS", "0.8"))
|
||||
@@ -902,6 +913,267 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
return {"link_preview_options": LinkPreviewOptions(is_disabled=True)}
|
||||
return {"disable_web_page_preview": True}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Bot API 10.1 Rich Messages (sendRichMessage)
|
||||
#
|
||||
# Final / new-message replies opportunistically use sendRichMessage with
|
||||
# the RAW agent markdown so richer constructs (tables, task lists,
|
||||
# collapsible details, math, ...) render natively. The legacy MarkdownV2
|
||||
# send() path stays as the fallback for unsupported/oversized content and
|
||||
# older PTB/clients. Streaming edits/drafts are intentionally untouched —
|
||||
# Telegram exposes no rich-edit method.
|
||||
# ------------------------------------------------------------------
|
||||
def _content_fits_rich_limits(self, content: str) -> bool:
|
||||
"""Cheap pre-check for the one hard rich limit we can count locally.
|
||||
|
||||
Only the 32,768 UTF-8 byte text cap is enforced here. Other Bot API
|
||||
rich limits (500 blocks, 16 nesting levels, 20 table columns, ...) are
|
||||
not pre-counted; if exceeded Telegram returns a BadRequest, which
|
||||
:meth:`_is_rich_fallback_error` classifies as permanent so the send
|
||||
degrades to the legacy chunking path.
|
||||
"""
|
||||
return len(content.encode("utf-8")) <= self.RICH_MESSAGE_MAX_BYTES
|
||||
|
||||
def _bot_supports_rich(self) -> bool:
|
||||
"""True when the bound bot can issue raw ``sendRichMessage`` calls.
|
||||
|
||||
Gates on ``do_api_request`` being an *async* callable. The real
|
||||
``telegram.Bot.do_api_request`` is a coroutine function; test doubles
|
||||
that opt into rich set it to an ``AsyncMock`` (also a coroutine
|
||||
function). Plain ``MagicMock`` bots expose a *sync* auto-child and
|
||||
``SimpleNamespace`` bots lack the attribute entirely — both resolve to
|
||||
``False`` here, so the legacy path is used unchanged.
|
||||
"""
|
||||
return inspect.iscoroutinefunction(getattr(self._bot, "do_api_request", None))
|
||||
|
||||
def _should_attempt_rich(self, content: str) -> bool:
|
||||
return bool(
|
||||
not getattr(self, "_rich_send_disabled", False)
|
||||
and content
|
||||
and content.strip()
|
||||
and self._content_fits_rich_limits(content)
|
||||
and self._bot_supports_rich()
|
||||
)
|
||||
|
||||
def _rich_message_payload(
|
||||
self, content: str, *, skip_entity_detection: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Build the ``InputRichMessage`` object from RAW markdown.
|
||||
|
||||
Never pass ``format_message(content)`` here — that converts to
|
||||
MarkdownV2 and would escape/destroy rich syntax like table pipes.
|
||||
"""
|
||||
payload: Dict[str, Any] = {"markdown": content}
|
||||
if skip_entity_detection:
|
||||
payload["skip_entity_detection"] = True
|
||||
return payload
|
||||
|
||||
def _is_rich_capability_error(self, exc: Exception) -> bool:
|
||||
"""True ⇒ the rich endpoint itself is unavailable (old PTB/server).
|
||||
|
||||
These latch rich off for the rest of the adapter's life — retrying is
|
||||
pointless and would cost a failed roundtrip on every send. Per-message
|
||||
rejections (BadRequest from a parser/limit issue) are NOT capability
|
||||
errors: the next message may be fine.
|
||||
"""
|
||||
if isinstance(exc, (AttributeError, TypeError, NotImplementedError)):
|
||||
return True
|
||||
if getattr(exc, "error_code", None) == 404:
|
||||
return True
|
||||
s = str(exc).lower()
|
||||
if ("method" in s and "not found" in s) or "no such method" in s:
|
||||
return True
|
||||
if "unsupported" in s or "not implemented" in s:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _is_rich_fallback_error(self, exc: Exception) -> bool:
|
||||
"""True ⇒ permanent/capability error ⇒ safe to fall back to legacy.
|
||||
|
||||
Conservative on purpose: only clearly-permanent failures (BadRequest,
|
||||
capability errors, unknown/unsupported endpoint) qualify. Everything
|
||||
else is treated as transient — the rich request may have reached
|
||||
Telegram, so we must NOT legacy-resend and risk a duplicate.
|
||||
"""
|
||||
if self._is_bad_request_error(exc):
|
||||
return True
|
||||
return self._is_rich_capability_error(exc)
|
||||
|
||||
def _compute_single_send_routing(
|
||||
self,
|
||||
chat_id: str,
|
||||
reply_to: Optional[str],
|
||||
metadata: Optional[Dict[str, Any]],
|
||||
thread_id: Optional[str],
|
||||
) -> Optional[tuple]:
|
||||
"""Routing for a single (rich) send — mirrors send()'s index-0 block.
|
||||
|
||||
Returns ``(reply_to_id, thread_kwargs)``, or ``None`` to signal "skip
|
||||
rich, let the legacy path handle it" — used for the DM-topic fail-loud
|
||||
case so the legacy path stays the single source of the refuse result.
|
||||
"""
|
||||
metadata_reply_to = self._metadata_reply_to_message_id(metadata)
|
||||
private_dm_topic_send = self._is_private_dm_topic_send(chat_id, thread_id, metadata)
|
||||
dm_topic_reply_to_off = (
|
||||
private_dm_topic_send
|
||||
and self._reply_to_mode == "off"
|
||||
and bool(metadata and metadata.get("telegram_dm_topic_reply_fallback"))
|
||||
)
|
||||
reply_to_source = reply_to or (
|
||||
str(metadata_reply_to)
|
||||
if private_dm_topic_send and metadata_reply_to is not None
|
||||
else None
|
||||
)
|
||||
if private_dm_topic_send:
|
||||
should_thread = reply_to_source is not None and self._reply_to_mode != "off"
|
||||
else:
|
||||
should_thread = self._should_thread_reply(reply_to_source, 0)
|
||||
reply_to_id = int(reply_to_source) if should_thread and reply_to_source else None
|
||||
if private_dm_topic_send and reply_to_id is None and not dm_topic_reply_to_off:
|
||||
# Refusing to send outside the requested DM topic — defer to the
|
||||
# legacy path, which returns the canonical fail-loud SendResult.
|
||||
return None
|
||||
thread_kwargs = self._thread_kwargs_for_send(
|
||||
chat_id,
|
||||
thread_id,
|
||||
metadata,
|
||||
reply_to_message_id=reply_to_id,
|
||||
reply_to_mode=self._reply_to_mode,
|
||||
)
|
||||
return reply_to_id, thread_kwargs
|
||||
|
||||
async def _try_send_rich(
|
||||
self,
|
||||
chat_id: str,
|
||||
content: str,
|
||||
reply_to: Optional[str],
|
||||
metadata: Optional[Dict[str, Any]],
|
||||
) -> Optional[SendResult]:
|
||||
"""Attempt a single ``sendRichMessage`` send.
|
||||
|
||||
Returns a :class:`SendResult` (success, or a transient failure that the
|
||||
caller must NOT legacy-resend), or ``None`` to signal "fall back to the
|
||||
legacy MarkdownV2 path" (permanent/capability error or DM-topic skip).
|
||||
"""
|
||||
thread_id = self._metadata_thread_id(metadata)
|
||||
routing = self._compute_single_send_routing(chat_id, reply_to, metadata, thread_id)
|
||||
if routing is None:
|
||||
return None
|
||||
reply_to_id, thread_kwargs = routing
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"chat_id": int(chat_id),
|
||||
"rich_message": self._rich_message_payload(content),
|
||||
}
|
||||
# Only forward non-None routing keys: when direct_messages_topic_id is
|
||||
# present _thread_kwargs_for_send pairs it with message_thread_id=None,
|
||||
# which must not be sent as a stray field on the raw endpoint.
|
||||
payload.update({k: v for k, v in thread_kwargs.items() if v is not None})
|
||||
payload.update(self._notification_kwargs(metadata))
|
||||
if reply_to_id is not None:
|
||||
# Spec: sendRichMessage takes reply_parameters (ReplyParameters
|
||||
# object), NOT the legacy reply_to_message_id scalar. Unknown
|
||||
# params are silently ignored by the Bot API, so the scalar would
|
||||
# quietly drop the reply anchor instead of erroring.
|
||||
payload["reply_parameters"] = {"message_id": reply_to_id}
|
||||
|
||||
try:
|
||||
msg = await self._bot.do_api_request(
|
||||
"sendRichMessage", api_kwargs=payload, return_type=Message
|
||||
)
|
||||
except Exception as exc:
|
||||
if self._is_rich_fallback_error(exc):
|
||||
if self._is_rich_capability_error(exc):
|
||||
# Endpoint missing (old PTB/server) — latch rich off so
|
||||
# every later send doesn't pay a doomed extra roundtrip.
|
||||
self._rich_send_disabled = True
|
||||
logger.debug(
|
||||
"[%s] sendRichMessage rejected (%s) — falling back to MarkdownV2",
|
||||
self.name, exc,
|
||||
)
|
||||
return None
|
||||
# Transient / network / unknown: the request may have reached
|
||||
# Telegram. Do NOT legacy-resend (duplicate risk); surface a
|
||||
# failure with retry semantics mirroring the legacy send() except.
|
||||
err_str = str(exc).lower()
|
||||
try:
|
||||
from telegram.error import TimedOut as _TimedOut
|
||||
except (ImportError, AttributeError):
|
||||
_TimedOut = None
|
||||
is_timeout = (_TimedOut and isinstance(exc, _TimedOut)) or "timed out" in err_str
|
||||
is_connect_timeout = self._looks_like_connect_timeout(exc)
|
||||
logger.warning(
|
||||
"[%s] sendRichMessage transient failure (no legacy resend): %s",
|
||||
self.name, exc,
|
||||
)
|
||||
return SendResult(
|
||||
success=False,
|
||||
error=str(exc),
|
||||
retryable=(is_connect_timeout or not is_timeout),
|
||||
)
|
||||
|
||||
message_id = None
|
||||
if isinstance(msg, dict):
|
||||
message_id = msg.get("message_id")
|
||||
if message_id is None:
|
||||
message_id = msg.get("result", {}).get("message_id")
|
||||
else:
|
||||
message_id = getattr(msg, "message_id", None)
|
||||
return SendResult(
|
||||
success=True,
|
||||
message_id=str(message_id) if message_id is not None else None,
|
||||
)
|
||||
|
||||
def _should_attempt_rich_draft(self, content: str) -> bool:
|
||||
return bool(
|
||||
not getattr(self, "_rich_send_disabled", False)
|
||||
and not getattr(self, "_rich_draft_disabled", False)
|
||||
and content
|
||||
and content.strip()
|
||||
and self._content_fits_rich_limits(content)
|
||||
and self._bot_supports_rich()
|
||||
)
|
||||
|
||||
async def _try_send_rich_draft(
|
||||
self,
|
||||
chat_id: str,
|
||||
draft_id: int,
|
||||
content: str,
|
||||
metadata: Optional[Dict[str, Any]],
|
||||
) -> bool:
|
||||
"""Emit one ``sendRichMessageDraft`` preview frame; True on success.
|
||||
|
||||
Draft frames are ephemeral and overwritten by the next frame / the
|
||||
final ``sendRichMessage``, so a duplicate or lost rich draft is
|
||||
harmless — any failure simply returns False and the caller renders the
|
||||
legacy plain-text draft. A permanent/capability failure additionally
|
||||
latches ``_rich_draft_disabled`` so later frames skip the rich attempt.
|
||||
"""
|
||||
payload: Dict[str, Any] = {
|
||||
"chat_id": int(chat_id),
|
||||
"draft_id": int(draft_id),
|
||||
"rich_message": self._rich_message_payload(content),
|
||||
}
|
||||
thread_id = self._metadata_thread_id(metadata)
|
||||
if thread_id is not None:
|
||||
payload["message_thread_id"] = int(thread_id)
|
||||
try:
|
||||
ok = await self._bot.do_api_request("sendRichMessageDraft", api_kwargs=payload)
|
||||
return bool(ok)
|
||||
except Exception as exc:
|
||||
if self._is_rich_capability_error(exc):
|
||||
self._rich_draft_disabled = True
|
||||
logger.debug(
|
||||
"[%s] sendRichMessageDraft unsupported (%s) — using legacy drafts",
|
||||
self.name, exc,
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"[%s] sendRichMessageDraft transient failure (%s) — legacy draft this frame",
|
||||
self.name, exc,
|
||||
)
|
||||
return False
|
||||
|
||||
async def _drain_polling_connections(self) -> None:
|
||||
"""Reset the httpx connection pool used for getUpdates polling.
|
||||
|
||||
@@ -1869,6 +2141,22 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
return SendResult(success=True, message_id=None)
|
||||
|
||||
try:
|
||||
# Bot API 10.1 rich fast-path: send the raw agent markdown via
|
||||
# sendRichMessage so tables/task lists/etc. render natively. Falls
|
||||
# through to the legacy MarkdownV2 path on permanent/capability
|
||||
# errors or DM-topic routing skips; returns directly on success or
|
||||
# on a transient failure (which must NOT be legacy-resent).
|
||||
if self._should_attempt_rich(content):
|
||||
rich_result = await self._try_send_rich(chat_id, content, reply_to, metadata)
|
||||
if rich_result is not None:
|
||||
if rich_result.success:
|
||||
# Re-trigger typing like the legacy success path does.
|
||||
try:
|
||||
await self.send_typing(chat_id, metadata=metadata)
|
||||
except Exception:
|
||||
pass # Typing failures are non-fatal
|
||||
return rich_result
|
||||
|
||||
# Format and split message if needed
|
||||
formatted = self.format_message(content)
|
||||
chunks = self.truncate_message(
|
||||
@@ -2550,17 +2838,30 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
content: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Stream a partial message via Telegram's native sendMessageDraft.
|
||||
"""Stream a partial message via Telegram's native draft API.
|
||||
|
||||
The Bot API animates the preview when the same ``draft_id`` is reused
|
||||
across consecutive calls in the same chat. When the response
|
||||
finishes, the caller sends the final text via the normal ``send``
|
||||
path; the draft preview clears naturally on the client (Telegram has
|
||||
no Bot API to "promote" a draft to a real message — the final
|
||||
``sendMessage`` is what the user receives in their history).
|
||||
Uses ``sendRichMessageDraft`` (Bot API 10.1) with the raw markdown when
|
||||
rich messages are enabled and supported, otherwise the plain-text
|
||||
``sendMessageDraft``. The Bot API animates the preview when the same
|
||||
``draft_id`` is reused across consecutive calls in the same chat. When
|
||||
the response finishes, the caller sends the final text via the normal
|
||||
``send`` path; the draft preview clears naturally on the client
|
||||
(Telegram has no Bot API to "promote" a draft to a real message — the
|
||||
final ``sendMessage``/``sendRichMessage`` is what the user receives in
|
||||
their history).
|
||||
"""
|
||||
if not self._bot:
|
||||
return SendResult(success=False, error="not_connected")
|
||||
|
||||
# Rich draft fast-path (Bot API 10.1 sendRichMessageDraft): render the
|
||||
# streaming preview with the same raw markdown the final
|
||||
# sendRichMessage will persist, so the animated draft matches the final
|
||||
# message. Any failure degrades to the legacy plain-text draft below.
|
||||
if self._should_attempt_rich_draft(content):
|
||||
if await self._try_send_rich_draft(chat_id, draft_id, content, metadata):
|
||||
# Drafts have no message_id; report success without one.
|
||||
return SendResult(success=True, message_id=None)
|
||||
|
||||
if not hasattr(self._bot, "send_message_draft"):
|
||||
return SendResult(success=False, error="api_unavailable")
|
||||
|
||||
|
||||
+99
-59
@@ -1420,6 +1420,8 @@ def _build_media_placeholder(event) -> str:
|
||||
parts.append(f"[User sent an image: {url}]")
|
||||
elif mtype.startswith("audio/"):
|
||||
parts.append(f"[User sent audio: {url}]")
|
||||
elif mtype.startswith("video/") or getattr(event, "message_type", None) == MessageType.VIDEO:
|
||||
parts.append(f"[User sent a video: {url}]")
|
||||
else:
|
||||
parts.append(f"[User sent a file: {url}]")
|
||||
return "\n".join(parts)
|
||||
@@ -7637,6 +7639,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
# Declare at outer scope so the audio-file-paths handling block below
|
||||
# remains safe when ``event.media_urls`` is empty (no inner block runs).
|
||||
audio_file_paths: list[str] = []
|
||||
video_paths: list[str] = []
|
||||
|
||||
if event.media_urls:
|
||||
image_paths = []
|
||||
@@ -7654,6 +7657,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
and event.message_type not in {MessageType.AUDIO, MessageType.DOCUMENT}
|
||||
):
|
||||
audio_paths.append(path)
|
||||
if mtype.startswith("video/") or event.message_type == MessageType.VIDEO:
|
||||
video_paths.append(path)
|
||||
|
||||
if image_paths:
|
||||
# Decide routing: native (attach pixels) vs text (vision_analyze
|
||||
@@ -7752,6 +7757,25 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
)
|
||||
message_text = f"{_note}\n\n{message_text}"
|
||||
|
||||
if video_paths:
|
||||
from tools.credential_files import to_agent_visible_cache_path as _to_agent_path
|
||||
for _vpath in video_paths:
|
||||
_basename = os.path.basename(_vpath)
|
||||
_parts = _basename.split("_", 2)
|
||||
_display = _parts[2] if len(_parts) >= 3 else _basename
|
||||
_display = re.sub(r'[^\w.\- ]', '_', _display)
|
||||
_agent_path = _to_agent_path(_vpath)
|
||||
_note = (
|
||||
f"[The user sent a video attachment: '{_display}'. "
|
||||
f"It is saved at: {_agent_path}. "
|
||||
f"Its content is not inlined here. If the user's request involves "
|
||||
f"what the video contains, inspect or process it yourself — for "
|
||||
f"example by passing the path to a video analysis or media tool — "
|
||||
f"instead of asking the user to describe it. Only ask what to do "
|
||||
f"with it if their intent is genuinely unclear.]"
|
||||
)
|
||||
message_text = f"{_note}\n\n{message_text}"
|
||||
|
||||
if event.media_urls and event.message_type == MessageType.DOCUMENT:
|
||||
import mimetypes as _mimetypes
|
||||
from tools.credential_files import to_agent_visible_cache_path
|
||||
@@ -8791,12 +8815,29 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
"Auto-resetting session %s after compression exhaustion.",
|
||||
session_entry.session_id,
|
||||
)
|
||||
self.session_store.reset_session(session_key)
|
||||
new_entry = self.session_store.reset_session(session_key)
|
||||
self._evict_cached_agent(session_key)
|
||||
self._session_model_overrides.pop(session_key, None)
|
||||
self._set_session_reasoning_override(session_key, None)
|
||||
if hasattr(self, "_pending_model_notes"):
|
||||
self._pending_model_notes.pop(session_key, None)
|
||||
if new_entry is not None:
|
||||
# Drop the stale reference to the bloated compressed child and
|
||||
# re-point the Telegram topic binding at the fresh session.
|
||||
# Compression rotated session_entry.session_id to the oversized
|
||||
# compressed child earlier this turn (the agent-result sync
|
||||
# above), and that _sync also rewrote the (chat_id, thread_id)
|
||||
# -> bloated-child binding. reset_session swaps in a clean,
|
||||
# parentless session, but without re-syncing the binding the
|
||||
# next inbound message in this topic gets switch_session'd back
|
||||
# onto the bloated child by the binding-heal walk, reloads the
|
||||
# oversized transcript, and re-triggers compression exhaustion
|
||||
# forever (#35809 — regression of the #9893/#10063 auto-reset).
|
||||
# No-op on non-topic lanes.
|
||||
session_entry = new_entry
|
||||
self._sync_telegram_topic_binding(
|
||||
source, session_entry, reason="compression-exhausted-reset",
|
||||
)
|
||||
response = (response or "") + (
|
||||
"\n\n🔄 Session auto-reset — the conversation exceeded the "
|
||||
"maximum context size and could not be compressed further. "
|
||||
@@ -14541,6 +14582,61 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
_context_length = getattr(_agent.context_compressor, "context_length", 0) or 0
|
||||
_resolved_model = getattr(_agent, "model", None) if _agent else None
|
||||
|
||||
# Sync session_id immediately after run_conversation(). Compression
|
||||
# can rotate before a follow-up model call fails; the failure return
|
||||
# below must still point the gateway at the compressed child.
|
||||
agent = agent_holder[0]
|
||||
_session_was_split = False
|
||||
agent_session_id = getattr(agent, 'session_id', session_id) if agent else session_id
|
||||
if agent and session_key and agent_session_id != session_id:
|
||||
_session_was_split = True
|
||||
logger.info(
|
||||
"Session split detected: %s → %s (compression)",
|
||||
session_id, agent_session_id,
|
||||
)
|
||||
entry = self.session_store._entries.get(session_key)
|
||||
if entry:
|
||||
entry.session_id = agent_session_id
|
||||
self.session_store._save()
|
||||
|
||||
# If this is a Telegram DM and source.thread_id was lost during
|
||||
# the session split (synthetic / recovered event), restore it
|
||||
# from the binding so _thread_metadata_for_source produces the
|
||||
# correct message_thread_id instead of routing to the General
|
||||
# thread. Failure here is non-fatal — we log and continue;
|
||||
# worst case the message lands in General, which is the
|
||||
# pre-fix behaviour.
|
||||
if (
|
||||
getattr(source, "platform", None) == Platform.TELEGRAM
|
||||
and getattr(source, "chat_type", None) == "dm"
|
||||
and getattr(source, "thread_id", None) is None
|
||||
and self._session_db is not None
|
||||
):
|
||||
try:
|
||||
_binding = self._session_db.get_telegram_topic_binding_by_session(
|
||||
session_id=agent_session_id,
|
||||
)
|
||||
if _binding and _binding.get("thread_id"):
|
||||
source.thread_id = str(_binding["thread_id"])
|
||||
logger.debug(
|
||||
"Restored source.thread_id=%s from binding after session split %s → %s",
|
||||
source.thread_id,
|
||||
session_id,
|
||||
agent_session_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to restore thread_id from binding after session split",
|
||||
exc_info=True,
|
||||
)
|
||||
if entry:
|
||||
self._sync_telegram_topic_binding(
|
||||
source, entry, reason="agent-run-compression",
|
||||
)
|
||||
|
||||
effective_session_id = agent_session_id
|
||||
_effective_history_offset = 0 if _session_was_split else len(agent_history)
|
||||
|
||||
if not final_response:
|
||||
error_msg = f"⚠️ {result['error']}" if result.get("error") else ""
|
||||
return {
|
||||
@@ -14555,7 +14651,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
"error": result.get("error"),
|
||||
"compression_exhausted": result.get("compression_exhausted", False),
|
||||
"tools": tools_holder[0] or [],
|
||||
"history_offset": len(agent_history),
|
||||
"history_offset": _effective_history_offset,
|
||||
"session_id": effective_session_id,
|
||||
"last_prompt_tokens": _last_prompt_toks,
|
||||
"input_tokens": _input_toks,
|
||||
"output_tokens": _output_toks,
|
||||
@@ -14601,63 +14698,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
unique_tags.insert(0, "[[audio_as_voice]]")
|
||||
final_response = final_response + "\n" + "\n".join(unique_tags)
|
||||
|
||||
# Sync session_id: the agent may have created a new session during
|
||||
# mid-run context compression (_compress_context splits sessions).
|
||||
# If so, update the session store entry so the NEXT message loads
|
||||
# the compressed transcript, not the stale pre-compression one.
|
||||
agent = agent_holder[0]
|
||||
_session_was_split = False
|
||||
if agent and session_key and hasattr(agent, 'session_id') and agent.session_id != session_id:
|
||||
_session_was_split = True
|
||||
logger.info(
|
||||
"Session split detected: %s → %s (compression)",
|
||||
session_id, agent.session_id,
|
||||
)
|
||||
entry = self.session_store._entries.get(session_key)
|
||||
if entry:
|
||||
entry.session_id = agent.session_id
|
||||
self.session_store._save()
|
||||
|
||||
# If this is a Telegram DM and source.thread_id was lost during
|
||||
# the session split (synthetic / recovered event), restore it
|
||||
# from the binding so _thread_metadata_for_source produces the
|
||||
# correct message_thread_id instead of routing to the General
|
||||
# thread. Failure here is non-fatal — we log and continue;
|
||||
# worst case the message lands in General, which is the
|
||||
# pre-fix behaviour.
|
||||
if (
|
||||
getattr(source, "platform", None) == Platform.TELEGRAM
|
||||
and getattr(source, "chat_type", None) == "dm"
|
||||
and getattr(source, "thread_id", None) is None
|
||||
and self._session_db is not None
|
||||
):
|
||||
try:
|
||||
_binding = self._session_db.get_telegram_topic_binding_by_session(
|
||||
session_id=agent.session_id,
|
||||
)
|
||||
if _binding and _binding.get("thread_id"):
|
||||
source.thread_id = str(_binding["thread_id"])
|
||||
logger.debug(
|
||||
"Restored source.thread_id=%s from binding after session split %s → %s",
|
||||
source.thread_id,
|
||||
session_id,
|
||||
agent.session_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to restore thread_id from binding after session split",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
effective_session_id = getattr(agent, 'session_id', session_id) if agent else session_id
|
||||
|
||||
# When compression created a new session, the messages list was
|
||||
# shortened. Using the original history offset would produce an
|
||||
# empty new_messages slice, causing the gateway to write only a
|
||||
# user/assistant pair — losing the compressed summary and tail.
|
||||
# Reset to 0 so the gateway writes ALL compressed messages.
|
||||
_effective_history_offset = 0 if _session_was_split else len(agent_history)
|
||||
|
||||
# Auto-generate session title after first exchange (non-blocking)
|
||||
if final_response and self._session_db:
|
||||
try:
|
||||
|
||||
@@ -1311,6 +1311,22 @@ class GatewayStreamConsumer:
|
||||
self._flood_strikes = 0
|
||||
return True
|
||||
else:
|
||||
if (
|
||||
finalize
|
||||
and is_turn_final
|
||||
and self.cfg.cursor
|
||||
and self._last_sent_text.endswith(self.cfg.cursor)
|
||||
and self._visible_prefix() == text
|
||||
):
|
||||
# The final clean-up edit failed, but the complete
|
||||
# answer is already visible from the last streaming
|
||||
# frame (usually with only the cursor still stuck on
|
||||
# screen). Mark the content delivered so the
|
||||
# gateway suppresses its normal full final send;
|
||||
# otherwise users see the same long answer twice
|
||||
# when Telegram/Discord rate-limit this cosmetic
|
||||
# final edit (#36965, #25349).
|
||||
self._final_content_delivered = True
|
||||
raw_response = getattr(result, "raw_response", None)
|
||||
if isinstance(raw_response, dict) and raw_response.get("partial_overflow"):
|
||||
# Telegram edited/sent one or more overflow chunks,
|
||||
|
||||
@@ -213,6 +213,13 @@ def build_top_level_parser():
|
||||
default=False,
|
||||
help="Skip auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills",
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--safe-mode",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Troubleshooting mode: disable ALL customizations — user config, AGENTS.md/memory injection, plugins, and MCP servers (implies --ignore-user-config and --ignore-rules)",
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--tui",
|
||||
@@ -366,6 +373,13 @@ def build_top_level_parser():
|
||||
default=argparse.SUPPRESS,
|
||||
help="Skip auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills. Combine with --ignore-user-config for a fully isolated run.",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--safe-mode",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Troubleshooting mode: disable ALL customizations — user config, AGENTS.md/memory injection, plugins, and MCP servers (implies --ignore-user-config and --ignore-rules). Use to isolate whether a problem comes from your setup or from Hermes itself.",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--source",
|
||||
default=None,
|
||||
|
||||
+69
-7
@@ -3524,6 +3524,22 @@ def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None, label:
|
||||
_save_auth_store(auth_store)
|
||||
|
||||
|
||||
def _recover_codex_tokens_from_cli(reason: str) -> Optional[Dict[str, str]]:
|
||||
"""Adopt a valid Codex CLI token pair into Hermes auth, if available."""
|
||||
imported = _import_codex_cli_tokens()
|
||||
# Require BOTH tokens before adopting: persisting a payload without a
|
||||
# usable refresh_token would only break the next refresh cycle.
|
||||
if not (
|
||||
imported
|
||||
and str(imported.get("access_token", "") or "").strip()
|
||||
and str(imported.get("refresh_token", "") or "").strip()
|
||||
):
|
||||
return None
|
||||
logger.info("Codex auth recovered from Codex CLI auth.json (%s).", reason)
|
||||
_save_codex_tokens(imported)
|
||||
return dict(imported)
|
||||
|
||||
|
||||
def refresh_codex_oauth_pure(
|
||||
access_token: str,
|
||||
refresh_token: str,
|
||||
@@ -3660,11 +3676,34 @@ def _refresh_codex_auth_tokens(
|
||||
|
||||
Saves the new tokens to Hermes auth store automatically.
|
||||
"""
|
||||
refreshed = refresh_codex_oauth_pure(
|
||||
str(tokens.get("access_token", "") or ""),
|
||||
str(tokens.get("refresh_token", "") or ""),
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
try:
|
||||
refreshed = refresh_codex_oauth_pure(
|
||||
str(tokens.get("access_token", "") or ""),
|
||||
str(tokens.get("refresh_token", "") or ""),
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
except AuthError as exc:
|
||||
# Self-heal cross-store refresh_token rotation. Hermes keeps its OWN
|
||||
# Codex OAuth token (per profile + top-level), separate from the Codex
|
||||
# CLI's ~/.codex/auth.json. OAuth refresh_tokens are single-use, so when
|
||||
# the Codex CLI (or another Hermes process) rotates the shared token,
|
||||
# this frozen copy's refresh_token goes stale and the refresh fails with
|
||||
# a relogin-required error (invalid_grant / refresh_token_reused / 401).
|
||||
# Before surfacing that as a hard 401 to the turn, adopt the canonical
|
||||
# fresh token from ~/.codex/auth.json (the Codex CLI keeps it current) so
|
||||
# idle profiles / desktop sessions recover automatically instead of
|
||||
# 401'ing until a manual re-auth. Transient failures (e.g. 429 quota)
|
||||
# keep relogin_required=False — the stored token is still valid there, so
|
||||
# we never self-heal those and re-raise unchanged.
|
||||
if not getattr(exc, "relogin_required", False):
|
||||
raise
|
||||
imported = _recover_codex_tokens_from_cli(
|
||||
f"refresh_token rejected: {getattr(exc, 'code', None) or 'auth_error'}"
|
||||
)
|
||||
if not imported:
|
||||
raise
|
||||
return imported
|
||||
|
||||
updated_tokens = dict(tokens)
|
||||
updated_tokens["access_token"] = refreshed["access_token"]
|
||||
updated_tokens["refresh_token"] = refreshed["refresh_token"]
|
||||
@@ -3724,9 +3763,25 @@ def resolve_codex_runtime_credentials(
|
||||
HTTP 401 ``Missing Authentication header`` from the wire instead of a usable
|
||||
credential. See issue #32992.
|
||||
"""
|
||||
read_error: Optional[AuthError] = None
|
||||
try:
|
||||
data = _read_codex_tokens()
|
||||
except AuthError:
|
||||
except AuthError as exc:
|
||||
read_error = exc
|
||||
if getattr(exc, "relogin_required", False) and getattr(exc, "code", None) in {
|
||||
"codex_auth_missing_access_token",
|
||||
"codex_auth_missing_refresh_token",
|
||||
"codex_auth_invalid_shape",
|
||||
}:
|
||||
imported = _recover_codex_tokens_from_cli(str(getattr(exc, "code", None) or "auth_error"))
|
||||
if imported:
|
||||
data = {"tokens": imported, "last_refresh": imported.get("last_refresh")}
|
||||
else:
|
||||
data = None
|
||||
else:
|
||||
data = None
|
||||
|
||||
if data is None:
|
||||
pool_token = _pool_codex_access_token()
|
||||
if pool_token:
|
||||
base_url = (
|
||||
@@ -3741,7 +3796,14 @@ def resolve_codex_runtime_credentials(
|
||||
"last_refresh": None,
|
||||
"auth_mode": "chatgpt",
|
||||
}
|
||||
raise
|
||||
if read_error is not None:
|
||||
raise read_error
|
||||
raise AuthError(
|
||||
"No Codex credentials stored. Run `hermes auth` to authenticate.",
|
||||
provider="openai-codex",
|
||||
code="codex_auth_missing",
|
||||
relogin_required=True,
|
||||
)
|
||||
|
||||
tokens = dict(data["tokens"])
|
||||
access_token = str(tokens.get("access_token", "") or "").strip()
|
||||
|
||||
@@ -1309,6 +1309,7 @@ DEFAULT_CONFIG = {
|
||||
"api_key": "",
|
||||
"timeout": 30,
|
||||
"extra_body": {},
|
||||
"language": "",
|
||||
},
|
||||
"tts_audio_tags": {
|
||||
"provider": "auto",
|
||||
@@ -1760,10 +1761,11 @@ DEFAULT_CONFIG = {
|
||||
"inherit_mcp_toolsets": True,
|
||||
"max_iterations": 50, # per-subagent iteration cap (each subagent gets its own budget,
|
||||
# independent of the parent's max_iterations)
|
||||
"child_timeout_seconds": 600, # wall-clock timeout for each child agent (floor 30s,
|
||||
# no ceiling). High-reasoning models on large tasks
|
||||
# (e.g. gpt-5.5 xhigh, opus-4.6) need generous budgets;
|
||||
# raise if children time out before producing output.
|
||||
"child_timeout_seconds": 0, # optional wall-clock cap per child agent. 0 (default)
|
||||
# = no timeout: children fail only from real errors
|
||||
# (API, tools, iteration budget), never a delegation
|
||||
# stopwatch. Set a positive number of seconds
|
||||
# (floor 30s) to enforce a hard cap.
|
||||
"reasoning_effort": "", # reasoning effort for subagents: "xhigh", "high", "medium",
|
||||
# "low", "minimal", "none" (empty = inherit parent's level)
|
||||
"max_concurrent_children": 3, # max parallel children per batch; floor of 1 enforced, no ceiling
|
||||
|
||||
+29
-3
@@ -1543,8 +1543,14 @@ def run_doctor(args):
|
||||
total = critical + high + moderate
|
||||
# Determine a scoped fix command for the remediation hint.
|
||||
if audit_extra and audit_extra[0] == "--workspace":
|
||||
fix_scope = " ".join(audit_extra)
|
||||
fix_cmd = f"cd {npm_dir} && npm audit fix {fix_scope}"
|
||||
# Detection (`npm audit --workspace <name>`) is read-only and
|
||||
# safe, but `npm audit fix --workspace <name>` crashes on
|
||||
# current npm with "Cannot read properties of null (reading
|
||||
# 'edgesOut')" — an arborist bug with workspace-filtered
|
||||
# audit fix. The root-level `npm audit fix` can crash on the
|
||||
# same tree with "isDescendantOf", so do not hand the user a
|
||||
# manual fix command for these build-tool advisories.
|
||||
fix_cmd = None
|
||||
elif audit_extra == ["--workspaces=false"]:
|
||||
fix_cmd = f"cd {npm_dir} && npm audit fix --workspaces=false"
|
||||
else:
|
||||
@@ -1552,10 +1558,30 @@ def run_doctor(args):
|
||||
if total == 0:
|
||||
check_ok(f"{label} deps", "(no known vulnerabilities)")
|
||||
elif critical > 0 or high > 0:
|
||||
if fix_cmd:
|
||||
vuln_detail = (
|
||||
f"{critical} critical, {high} high, {moderate} moderate — run: {fix_cmd}"
|
||||
)
|
||||
else:
|
||||
vuln_detail = (
|
||||
f"{critical} critical, {high} high, {moderate} moderate — "
|
||||
"build-tool advisory; clears via lockfile bump"
|
||||
)
|
||||
check_warn(
|
||||
f"{label} deps",
|
||||
f"({critical} critical, {high} high, {moderate} moderate — run: {fix_cmd})"
|
||||
f"({vuln_detail})"
|
||||
)
|
||||
if audit_extra and audit_extra[0] == "--workspace":
|
||||
# The web/ui-tui workspace advisories are in build-time
|
||||
# tooling (esbuild/vite, etc.), not runtime code that ships
|
||||
# to users. Manual npm remediation may error with a known
|
||||
# arborist crash (edgesOut / isDescendantOf) on this monorepo
|
||||
# tree — in that case it is an npm bug, not a Hermes one.
|
||||
check_info(
|
||||
" ^ build-time tooling (not runtime); if manual npm remediation "
|
||||
"errors with an arborist crash it's a known npm bug — clears "
|
||||
"via a lockfile bump"
|
||||
)
|
||||
issues.append(
|
||||
f"{label} has {total} npm "
|
||||
f"{'vulnerability' if total == 1 else 'vulnerabilities'}"
|
||||
|
||||
@@ -6675,6 +6675,40 @@ def _worker_terminal_timeout_env(
|
||||
return str(desired)
|
||||
|
||||
|
||||
def _resolve_worker_cli_toolsets(hermes_home: Optional[str]) -> Optional[list[str]]:
|
||||
"""Return the assigned profile's effective CLI toolsets for a worker.
|
||||
|
||||
Dispatcher-spawned workers are launched from a long-lived gateway process,
|
||||
then the child re-enters the CLI with ``-p <assignee>``. Resolve the
|
||||
assignee profile's CLI tool surface at dispatch time and pass it as an
|
||||
explicit ``--toolsets`` pin so worker startup cannot fall back to a stale
|
||||
root/active-profile config or a profile whose top-level ``toolsets`` entry
|
||||
is only the kanban orchestrator surface. ``model_tools`` still appends the
|
||||
task-scoped kanban lifecycle tools when ``HERMES_KANBAN_TASK`` is set.
|
||||
"""
|
||||
if not hermes_home:
|
||||
return None
|
||||
try:
|
||||
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.tools_config import _get_platform_tools
|
||||
|
||||
token = set_hermes_home_override(hermes_home)
|
||||
try:
|
||||
cfg = load_config()
|
||||
toolsets = sorted(_get_platform_tools(cfg, "cli"))
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
return toolsets or None
|
||||
except Exception as exc:
|
||||
_log.debug(
|
||||
"kanban worker: could not resolve CLI toolsets for HERMES_HOME=%r (%s)",
|
||||
hermes_home,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _default_spawn(
|
||||
task: Task,
|
||||
workspace: str,
|
||||
@@ -6808,6 +6842,9 @@ def _default_spawn(
|
||||
cmd.extend(["--skills", sk])
|
||||
if task.model_override:
|
||||
cmd.extend(["-m", task.model_override])
|
||||
worker_toolsets = _resolve_worker_cli_toolsets(env.get("HERMES_HOME"))
|
||||
if worker_toolsets:
|
||||
cmd.extend(["--toolsets", ",".join(worker_toolsets)])
|
||||
cmd.extend([
|
||||
"chat",
|
||||
"-q", prompt,
|
||||
|
||||
+179
-40
@@ -2199,6 +2199,18 @@ def cmd_chat(args):
|
||||
if getattr(args, "yolo", False):
|
||||
os.environ["HERMES_YOLO_MODE"] = "1"
|
||||
|
||||
# --safe-mode: troubleshooting mode that disables ALL customizations.
|
||||
# Inspired by Claude Code v2.1.169's --safe-mode (June 2026): run with a
|
||||
# pristine environment to isolate whether a problem comes from the user's
|
||||
# setup (config, rules files, plugins, MCP servers) or from Hermes itself.
|
||||
# Implemented as a superset of --ignore-user-config + --ignore-rules plus
|
||||
# plugin/MCP discovery suppression (HERMES_SAFE_MODE is checked by
|
||||
# hermes_cli/plugins.py and tools/mcp_tool.py).
|
||||
if getattr(args, "safe_mode", False):
|
||||
os.environ["HERMES_SAFE_MODE"] = "1"
|
||||
os.environ["HERMES_IGNORE_USER_CONFIG"] = "1"
|
||||
os.environ["HERMES_IGNORE_RULES"] = "1"
|
||||
|
||||
# --ignore-user-config: make load_cli_config() / load_config() skip the
|
||||
# user's ~/.hermes/config.yaml and return built-in defaults. Set BEFORE
|
||||
# importing cli (which runs `CLI_CONFIG = load_cli_config()` at module
|
||||
@@ -2256,8 +2268,8 @@ def cmd_chat(args):
|
||||
"checkpoints": getattr(args, "checkpoints", False),
|
||||
"pass_session_id": getattr(args, "pass_session_id", False),
|
||||
"max_turns": getattr(args, "max_turns", None),
|
||||
"ignore_rules": getattr(args, "ignore_rules", False),
|
||||
"ignore_user_config": getattr(args, "ignore_user_config", False),
|
||||
"ignore_rules": getattr(args, "ignore_rules", False) or getattr(args, "safe_mode", False),
|
||||
"ignore_user_config": getattr(args, "ignore_user_config", False) or getattr(args, "safe_mode", False),
|
||||
"compact": getattr(args, "compact", False),
|
||||
}
|
||||
# Filter out None values
|
||||
@@ -8750,6 +8762,22 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
except Exception:
|
||||
pass # profiles module not available or no profiles
|
||||
|
||||
# Backfill per-profile .env files for profiles created before the
|
||||
# .env-seeding fix (#44792). Copies the default install's .env so
|
||||
# those profiles keep the credentials they were effectively using.
|
||||
try:
|
||||
from hermes_cli.profiles import backfill_profile_envs
|
||||
|
||||
backfilled = backfill_profile_envs(quiet=True)
|
||||
if backfilled:
|
||||
print()
|
||||
print(
|
||||
f"→ Seeded .env for {len(backfilled)} profile(s) "
|
||||
f"(copied from default): {', '.join(backfilled)}"
|
||||
)
|
||||
except Exception:
|
||||
pass # profiles module not available or no profiles
|
||||
|
||||
# Sync Honcho host blocks to all profiles
|
||||
try:
|
||||
from plugins.memory.honcho.cli import sync_honcho_profiles_quiet
|
||||
@@ -9065,6 +9093,66 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
break
|
||||
return total if matched else default
|
||||
|
||||
_manage_cmd_cache: dict = {}
|
||||
|
||||
def _resolve_manage_cmd(scope_: str, scope_cmd_: list, svc_name_: str):
|
||||
"""Resolve the command prefix for manage-units operations.
|
||||
|
||||
Read-only systemctl calls (``is-active``, ``show``,
|
||||
``list-units``) work unprivileged, but manage-units verbs
|
||||
(``reset-failed``, ``start``, ``restart``) on a *system*
|
||||
service trigger a polkit ``org.freedesktop.systemd1.manage-units``
|
||||
authentication prompt when run as a non-root user. That
|
||||
interactive prompt runs inside our captured subprocess with a
|
||||
10-15s timeout — the user sees the prompt flash and "exit
|
||||
directly" before they can answer, and the resulting
|
||||
TimeoutExpired used to be swallowed silently.
|
||||
|
||||
Strategy: if root, plain systemctl. If not root, try
|
||||
non-interactive sudo (``sudo -n``) — first a blanket probe,
|
||||
then a targeted ``systemctl reset-failed`` probe so a
|
||||
least-privilege sudoers entry scoped to
|
||||
``systemctl ... hermes-gateway*`` also qualifies
|
||||
(``reset-failed`` is an idempotent no-op we run before every
|
||||
privileged restart anyway). If neither works, return None —
|
||||
the caller must SKIP the restart (without draining the
|
||||
gateway first!) and tell the user how to restart manually.
|
||||
``--no-ask-password`` guarantees polkit can never hang a
|
||||
captured subprocess on this path.
|
||||
"""
|
||||
if scope_ in _manage_cmd_cache:
|
||||
return _manage_cmd_cache[scope_]
|
||||
cmd = scope_cmd_ + ["--no-ask-password"]
|
||||
if (
|
||||
scope_ == "system"
|
||||
and hasattr(os, "geteuid")
|
||||
and os.geteuid() != 0 # windows-footgun: ok — systemd path, Linux-only
|
||||
):
|
||||
sudo_cmd = ["sudo", "-n"] + scope_cmd_ + ["--no-ask-password"]
|
||||
sudo_ok = False
|
||||
try:
|
||||
_probe = subprocess.run(
|
||||
["sudo", "-n", "true"],
|
||||
capture_output=True,
|
||||
timeout=5,
|
||||
)
|
||||
sudo_ok = _probe.returncode == 0
|
||||
if not sudo_ok:
|
||||
# Blanket sudo refused — a targeted sudoers entry
|
||||
# (NOPASSWD for systemctl ... hermes-gateway*)
|
||||
# may still allow the exact commands we need.
|
||||
_probe = subprocess.run(
|
||||
sudo_cmd + ["reset-failed", svc_name_],
|
||||
capture_output=True,
|
||||
timeout=5,
|
||||
)
|
||||
sudo_ok = _probe.returncode == 0
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
sudo_ok = False
|
||||
cmd = sudo_cmd if sudo_ok else None
|
||||
_manage_cmd_cache[scope_] = cmd
|
||||
return cmd
|
||||
|
||||
# Drain budget for graceful SIGUSR1 restarts. The gateway drains
|
||||
# for up to ``agent.restart_drain_timeout`` (default 60s) before
|
||||
# exiting with code 75; we wait slightly longer so the drain
|
||||
@@ -9147,6 +9235,17 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
if check.stdout.strip() != "active":
|
||||
continue
|
||||
|
||||
# Resolve how we may run manage-units verbs
|
||||
# (reset-failed/start/restart) for this scope.
|
||||
# None ⇒ no non-interactive privilege path; we
|
||||
# must avoid those verbs entirely or polkit will
|
||||
# throw an interactive auth prompt inside our
|
||||
# captured 10-15s subprocess (the user sees it
|
||||
# flash and "exit directly" — reported June 2026).
|
||||
_manage_cmd = _resolve_manage_cmd(
|
||||
scope, scope_cmd, svc_name
|
||||
)
|
||||
|
||||
# Prefer a graceful SIGUSR1 restart so in-flight
|
||||
# agent runs drain instead of being SIGKILLed.
|
||||
# The gateway's SIGUSR1 handler calls
|
||||
@@ -9206,35 +9305,40 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
# ``start`` is a no-op and we fall through to
|
||||
# the poll below. Either way we collapse the
|
||||
# 60s+ delay to a ~5s one.
|
||||
subprocess.run(
|
||||
scope_cmd + ["reset-failed", svc_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
subprocess.run(
|
||||
scope_cmd + ["start", svc_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
# Short poll: the gateway should be up within
|
||||
# a few seconds now that we bypassed
|
||||
# RestartSec. Fall back to the longer
|
||||
# RestartSec + slack budget ONLY if the
|
||||
# explicit start failed and we need to rely
|
||||
# on systemd's auto-restart.
|
||||
if _wait_for_service_active(
|
||||
scope_cmd,
|
||||
svc_name,
|
||||
timeout=10.0,
|
||||
):
|
||||
restarted_services.append(svc_name)
|
||||
continue
|
||||
# Explicit start didn't take. Fall back to
|
||||
# the original passive poll (systemd's
|
||||
# auto-restart WILL fire after RestartSec
|
||||
# regardless).
|
||||
#
|
||||
# The shortcut needs manage-units privileges.
|
||||
# Without them (system service, non-root, no
|
||||
# passwordless sudo) skip it — systemd's own
|
||||
# auto-restart still relaunches the unit after
|
||||
# RestartSec, no privileges required.
|
||||
if _manage_cmd is not None:
|
||||
subprocess.run(
|
||||
_manage_cmd + ["reset-failed", svc_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
subprocess.run(
|
||||
_manage_cmd + ["start", svc_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
# Short poll: the gateway should be up
|
||||
# within a few seconds now that we
|
||||
# bypassed RestartSec.
|
||||
if _wait_for_service_active(
|
||||
scope_cmd,
|
||||
svc_name,
|
||||
timeout=10.0,
|
||||
):
|
||||
restarted_services.append(svc_name)
|
||||
continue
|
||||
# Passive poll: systemd's auto-restart fires
|
||||
# after RestartSec regardless of privileges.
|
||||
# This is the primary path when _manage_cmd is
|
||||
# None, and the fallback when the explicit
|
||||
# start didn't take.
|
||||
_restart_sec = _service_restart_sec(
|
||||
scope_cmd,
|
||||
svc_name,
|
||||
@@ -9244,6 +9348,12 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
10.0,
|
||||
_restart_sec + 10.0,
|
||||
)
|
||||
if _manage_cmd is None and _restart_sec > 5.0:
|
||||
print(
|
||||
f" → {svc_name}: waiting for systemd "
|
||||
f"auto-restart (~{int(_restart_sec)}s; "
|
||||
"no root for an immediate restart)..."
|
||||
)
|
||||
if _wait_for_service_active(
|
||||
scope_cmd,
|
||||
svc_name,
|
||||
@@ -9259,6 +9369,22 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
f" ⚠ {svc_name} drained but didn't relaunch — forcing restart"
|
||||
)
|
||||
|
||||
# Forcing a restart requires manage-units
|
||||
# privileges. Without a non-interactive path,
|
||||
# running systemctl here would spawn a polkit
|
||||
# auth prompt inside a captured 10-15s subprocess
|
||||
# — it flashes and dies before the user can
|
||||
# answer. Skip with clear instructions instead.
|
||||
if _manage_cmd is None:
|
||||
print(
|
||||
f" ⚠ {svc_name} is a system service and restarting it needs root.\n"
|
||||
f" Restart it manually to load the new version:\n"
|
||||
f" sudo systemctl restart {svc_name}\n"
|
||||
f" To let `hermes update` restart it automatically, allow\n"
|
||||
f" passwordless sudo for systemctl, or run updates with sudo."
|
||||
)
|
||||
continue
|
||||
|
||||
# Fallback: blunt systemctl restart. This is
|
||||
# what the old code always did; we get here only
|
||||
# when the graceful path failed (unit missing
|
||||
@@ -9276,13 +9402,13 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
# path in `hermes gateway restart`
|
||||
# (`systemd_restart()`) as of PR #20949.
|
||||
subprocess.run(
|
||||
scope_cmd + ["reset-failed", svc_name],
|
||||
_manage_cmd + ["reset-failed", svc_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
restart = subprocess.run(
|
||||
scope_cmd + ["restart", svc_name],
|
||||
_manage_cmd + ["restart", svc_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
@@ -9308,13 +9434,13 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
f" ⚠ {svc_name} died after restart, retrying..."
|
||||
)
|
||||
subprocess.run(
|
||||
scope_cmd + ["reset-failed", svc_name],
|
||||
_manage_cmd + ["reset-failed", svc_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
subprocess.run(
|
||||
scope_cmd + ["restart", svc_name],
|
||||
_manage_cmd + ["restart", svc_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
@@ -9328,19 +9454,29 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
print(f" ✓ {svc_name} recovered on retry")
|
||||
else:
|
||||
_scope_flag = "--user " if scope == "user" else ""
|
||||
_sudo_hint = "sudo " if scope == "system" else ""
|
||||
print(
|
||||
f" ✗ {svc_name} failed to stay running after restart.\n"
|
||||
f" Check logs: journalctl {_scope_flag}-u {svc_name} --since '2 min ago'\n"
|
||||
f" Check logs: {_sudo_hint}journalctl {_scope_flag}-u {svc_name} --since '2 min ago'\n"
|
||||
f" Recover manually:\n"
|
||||
f" systemctl {_scope_flag}reset-failed {svc_name}\n"
|
||||
f" systemctl {_scope_flag}restart {svc_name}"
|
||||
f" {_sudo_hint}systemctl {_scope_flag}reset-failed {svc_name}\n"
|
||||
f" {_sudo_hint}systemctl {_scope_flag}restart {svc_name}"
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f" ⚠ Failed to restart {svc_name}: {restart.stderr.strip()}"
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
# Don't swallow this silently — a wedged systemctl
|
||||
# call here used to make the whole restart phase
|
||||
# vanish with no output (June 2026 report).
|
||||
print(
|
||||
f" ⚠ systemctl timed out during the {scope}-scope "
|
||||
f"gateway restart ({exc.cmd if exc.cmd else 'unknown command'}). "
|
||||
f"Check the gateway with: hermes gateway status"
|
||||
)
|
||||
|
||||
# --- Launchd services (macOS) ---
|
||||
if is_macos():
|
||||
@@ -9741,7 +9877,10 @@ def cmd_profile(args):
|
||||
getattr(args, "clone_from", None) or get_active_profile_name()
|
||||
)
|
||||
if clone_all:
|
||||
print(f"Full copy from {source_label}.")
|
||||
print(
|
||||
f"Full copy from {source_label} "
|
||||
"(excluding session history, backups, and snapshots)."
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"Cloned config, .env, SOUL.md, and skills from {source_label}."
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user