Merge upstream main into fix/hermes-relay-anthropic-context

Signed-off-by: Alex Fournier <afournier@nvidia.com>
This commit is contained in:
Alex Fournier 2026-07-28 08:10:58 -07:00
commit b1a5d67e71
214 changed files with 11437 additions and 977 deletions

View file

@ -3401,75 +3401,139 @@ def reapply_reasoning_echo_for_provider(agent, api_messages: list) -> int:
return changed
def _iter_httpx_pool_objects(http_client: Any):
"""Yield httpcore pool objects reachable from an httpx client.
Hermes' keepalive client (#10324 / ``_build_keepalive_http_client``) and
any ``HTTP(S)_PROXY`` configuration put live connections on *mounted*
transports (``client._mounts``), not only on the default
``client._transport``. Walking the default transport alone makes
``force_close_tcp_sockets`` return 0 while a stream is still mid-recv
the interrupt logs success and the provider keeps burning the slot
(#72975).
"""
seen_pools: set[int] = set()
def _emit(pool: Any):
if pool is None:
return
marker = id(pool)
if marker in seen_pools:
return
seen_pools.add(marker)
yield pool
def _pools_for_transport(transport: Any):
if transport is None:
return
# Normal httpx.HTTPTransport / HTTPProxy-as-transport: connections
# live under ``_pool``. HTTPProxy itself *is* a ConnectionPool and
# may be mounted directly — then ``_connections`` is on the
# transport.
pool = getattr(transport, "_pool", None)
if pool is not None:
yield from _emit(pool)
return
if getattr(transport, "_connections", None) is not None:
yield from _emit(transport)
try:
yield from _pools_for_transport(getattr(http_client, "_transport", None))
mounts = getattr(http_client, "_mounts", None) or {}
for _pattern, mounted in list(mounts.items()):
yield from _pools_for_transport(mounted)
except Exception:
return
def _connection_candidates(conn: Any):
"""Walk nested ``_connection`` wrappers (proxy tunnel → HTTP11/2)."""
seen: set[int] = set()
stack = [conn]
while stack:
candidate = stack.pop()
if candidate is None:
continue
marker = id(candidate)
if marker in seen:
continue
seen.add(marker)
yield candidate
inner = getattr(candidate, "_connection", None)
if inner is not None and id(inner) not in seen:
stack.append(inner)
def _iter_pool_sockets(client: Any):
"""Yield raw sockets reachable from an OpenAI/httpx client pool.
httpcore 1.x stores the concrete HTTP11/HTTP2 connection under
``conn._connection``; older versions exposed stream attributes directly
on the pool entry. Keep the traversal defensive because these are private
transport internals and vary across httpx/httpcore releases.
on the pool entry. Proxy tunnels wrap another layer
(``TunnelHTTPConnection`` / ``ForwardHTTPConnection``). Keep the
traversal defensive because these are private transport internals and
vary across httpx/httpcore releases.
Also walks ``httpx`` mount transports see ``_iter_httpx_pool_objects``.
"""
try:
http_client = getattr(client, "_client", None)
if http_client is None:
return
transport = getattr(http_client, "_transport", None)
if transport is None:
return
pool = getattr(transport, "_pool", None)
if pool is None:
return
# Some SDK wrappers *are* the httpx client (or expose the pool
# directly). Fall through so mount-aware discovery still runs.
http_client = client
pools = list(_iter_httpx_pool_objects(http_client))
except Exception:
return
if not pools:
return
seen: set[int] = set()
for pool in pools:
connections = (
getattr(pool, "_connections", None)
or getattr(pool, "_pool", None)
or []
)
except Exception:
return
seen: set[int] = set()
for conn in list(connections):
candidates = [conn]
inner = getattr(conn, "_connection", None)
if inner is not None:
candidates.append(inner)
for candidate in candidates:
stream = (
getattr(candidate, "_network_stream", None)
or getattr(candidate, "_stream", None)
)
if stream is None:
continue
sock = getattr(stream, "_sock", None)
if sock is None:
get_extra_info = getattr(stream, "get_extra_info", None)
if callable(get_extra_info):
try:
sock = get_extra_info("socket")
except Exception:
sock = None
if sock is None:
wrapped = getattr(stream, "stream", None)
if wrapped is not None:
sock = getattr(wrapped, "_sock", None)
if sock is None:
# anyio-backed streams expose the raw socket through
# SocketAttribute.raw_socket when available.
wrapped = getattr(stream, "_stream", None)
extra = getattr(wrapped, "extra", None)
if callable(extra):
try:
from anyio.abc import SocketAttribute
sock = extra(SocketAttribute.raw_socket)
except Exception:
sock = None
if sock is None:
continue
marker = id(sock)
if marker in seen:
continue
seen.add(marker)
yield sock
for conn in list(connections):
for candidate in _connection_candidates(conn):
stream = (
getattr(candidate, "_network_stream", None)
or getattr(candidate, "_stream", None)
)
if stream is None:
continue
sock = getattr(stream, "_sock", None)
if sock is None:
get_extra_info = getattr(stream, "get_extra_info", None)
if callable(get_extra_info):
try:
sock = get_extra_info("socket")
except Exception:
sock = None
if sock is None:
wrapped = getattr(stream, "stream", None)
if wrapped is not None:
sock = getattr(wrapped, "_sock", None)
if sock is None:
# anyio-backed streams expose the raw socket through
# SocketAttribute.raw_socket when available.
wrapped = getattr(stream, "_stream", None)
extra = getattr(wrapped, "extra", None)
if callable(extra):
try:
from anyio.abc import SocketAttribute
sock = extra(SocketAttribute.raw_socket)
except Exception:
sock = None
if sock is None:
continue
marker = id(sock)
if marker in seen:
continue
seen.add(marker)
yield sock
def cleanup_dead_connections(agent) -> bool:

View file

@ -526,10 +526,16 @@ def direct_api_call(agent, api_kwargs: dict):
def _abort_active_request(reason: str) -> None:
"""Abort the inline request from a watchdog/interrupt thread."""
# Abort while still holding the holder lock: the instant it is
# released, the inline finally may pop + cache the client for reuse
# and the NEXT call check it out — a late abort would then poison
# the slot and shut down an innocent in-flight request's sockets
# (same atomicity contract as _close_request_client_once in the
# interruptible variants; the abort itself never blocks).
with request_client_lock:
request_client = request_client_holder["client"]
if request_client is not None:
agent._abort_request_openai_client(request_client, reason=reason)
if request_client is not None:
agent._abort_request_openai_client(request_client, reason=reason)
def _make_client(reason: str, kind: str = "openai"):
# direct_api_call only runs for OpenAI-wire chat_completions cron
@ -542,6 +548,10 @@ def direct_api_call(agent, api_kwargs: dict):
agent._active_request_abort = _abort_active_request
return client
# Only a clean return may report the reuse reason (request_complete):
# after an error or interrupt the wire client is really closed so the
# retry builds a fresh pool (see _REQUEST_CLIENT_REUSE_REASONS).
succeeded = False
try:
response = _dispatch_nonstreaming_api_request(
agent, api_kwargs, make_client=_make_client
@ -554,6 +564,7 @@ def direct_api_call(agent, api_kwargs: dict):
if getattr(agent, "_interrupt_requested", False):
raise InterruptedError("Agent interrupted during API call")
_reset_stale_streak(agent)
succeeded = True
return response
finally:
if getattr(agent, "_active_request_abort", None) is _abort_active_request:
@ -562,7 +573,10 @@ def direct_api_call(agent, api_kwargs: dict):
request_client = request_client_holder["client"]
request_client_holder["client"] = None
if request_client is not None:
agent._close_request_openai_client(request_client, reason="request_complete")
agent._close_request_openai_client(
request_client,
reason="request_complete" if succeeded else "request_error_cleanup",
)
def interruptible_api_call(agent, api_kwargs: dict):
@ -640,20 +654,28 @@ def interruptible_api_call(agent, api_kwargs: dict):
and owner_tid is not None
and owner_tid != threading.get_ident()
)
if not stranger_thread:
# Owning thread (or no recorded owner) → pop and fully close.
request_client_holder["client"] = None
request_client_holder["owner_tid"] = None
if stranger_thread:
# Abort while still holding the holder lock: the instant it
# is released, the worker's finally may pop + cache the client
# for reuse and the NEXT call check it out — an abort landing
# after that would poison the slot and shut down an innocent
# in-flight request's sockets. The abort itself never blocks
# (socket shutdown + slot poison), so holding the lock across
# it only delays the racing pop, never the data path.
if request_client_kind.get("value", "openai") == "anthropic_messages":
agent._abort_request_anthropic_client(
request_client, reason=reason
)
else:
agent._abort_request_openai_client(request_client, reason=reason)
return
# Owning thread (or no recorded owner) → pop and fully close.
request_client_holder["client"] = None
request_client_holder["owner_tid"] = None
if request_client is None:
return
kind = request_client_kind.get("value", "openai")
if kind == "anthropic_messages":
if stranger_thread:
agent._abort_request_anthropic_client(request_client, reason=reason)
else:
agent._close_request_anthropic_client(request_client, reason=reason)
elif stranger_thread:
agent._abort_request_openai_client(request_client, reason=reason)
if request_client_kind.get("value", "openai") == "anthropic_messages":
agent._close_request_anthropic_client(request_client, reason=reason)
else:
agent._close_request_openai_client(request_client, reason=reason)
@ -690,7 +712,15 @@ def interruptible_api_call(agent, api_kwargs: dict):
return
result["error"] = e
finally:
_close_request_client_once("request_complete")
# Reuse reason only on a clean response; any other outcome —
# error, or the cancel-swallow return above (which leaves both
# result slots None) — really closes so the next attempt builds
# a fresh pool (see _REQUEST_CLIENT_REUSE_REASONS).
_close_request_client_once(
"request_complete"
if result["response"] is not None
else "request_error_cleanup"
)
# ── Stale-call timeout (mirrors streaming stale detector) ────────
# Non-streaming calls return nothing until the full response is
@ -2757,20 +2787,27 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
and owner_tid is not None
and owner_tid != threading.get_ident()
)
if not stranger_thread:
request_client_holder["client"] = None
request_client_holder["owner_tid"] = None
if stranger_thread:
# Abort under the holder lock — see the non-streaming variant
# for why the holder read and the abort must be atomic (a late
# abort would otherwise hit the NEXT request's checkout).
if request_client_kind.get("value", "openai") == "anthropic_messages":
agent._abort_request_anthropic_client(
request_client, reason=reason
)
else:
agent._abort_request_openai_client(request_client, reason=reason)
return
request_client_holder["client"] = None
request_client_holder["owner_tid"] = None
if request_client is None:
return
# Stranger threads returned under the lock above, so only the owner
# (or an any-thread-safe stream handle) reaches the close dispatch.
if request_kind == "stream":
_close_request_stream_handle(request_client, reason)
elif request_kind == "anthropic_messages":
if stranger_thread:
agent._abort_request_anthropic_client(request_client, reason=reason)
else:
agent._close_request_anthropic_client(request_client, reason=reason)
elif stranger_thread:
agent._abort_request_openai_client(request_client, reason=reason)
agent._close_request_anthropic_client(request_client, reason=reason)
else:
agent._close_request_openai_client(request_client, reason=reason)
@ -2945,6 +2982,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
_diag = agent._stream_diag_init()
request_client_holder["diag"] = _diag
_writer_token = {"value": None}
attempt_request_client = {"value": None}
def _open_stream(next_api_kwargs: dict[str, Any]):
stream_kwargs = {
@ -2966,6 +3004,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
api_kwargs=stream_kwargs,
)
)
attempt_request_client["value"] = request_client
last_chunk_time["t"] = time.time()
agent._touch_activity("waiting for provider response (streaming)")
return request_client.chat.completions.create(**stream_kwargs)
@ -3081,6 +3120,26 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
pass
if agent._interrupt_requested:
# Abandoning a half-read SSE response leaves its connection
# permanently checked out of the httpx pool — and the partial
# response built below makes the worker's finally report a
# reuse-reason close, which would cache the client together
# with the leaked connection (each interrupt leaking one more
# until the pool exhausts). Close the stream here, on the
# owning thread, so the connection is released first.
try:
stream.close()
except Exception:
# Connection may still be checked out — poison the slot so
# the finally's close really closes the pool instead of
# caching it (owner-thread abort: shutdown is safe, and the
# FD release still happens in the finally below).
request_client = attempt_request_client["value"]
if request_client is not None:
agent._abort_request_openai_client(
request_client,
reason="interrupt_stream_close_failed",
)
break
if not _stream_attempt_is_active(stream_attempt_id):
@ -3892,7 +3951,14 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
return
finally:
_close_managed_stream()
_close_request_client_once("stream_request_complete")
# Reuse reason only on a clean stream; any other outcome (error,
# cancel-swallow) really closes so the next attempt builds a
# fresh pool (see _REQUEST_CLIENT_REUSE_REASONS).
_close_request_client_once(
"stream_request_complete"
if result["response"] is not None
else "stream_error_cleanup"
)
# Provider-configured stale timeout takes priority over env default.
_cfg_stale = get_provider_stale_timeout(agent.provider, agent.model)

View file

@ -74,7 +74,10 @@ def _record_codex_app_server_usage(agent, turn) -> dict[str, Any]:
try:
if not agent._session_db_created:
agent._ensure_db_session()
agent._session_db.update_token_counts(
# Enqueued for the SessionDB background writer — keeps the
# per-call accounting write off the turn thread (see
# conversation_loop's queue_token_counts call).
agent._session_db.queue_token_counts(
agent.session_id,
model=agent.model,
billing_provider=agent.provider,
@ -154,7 +157,8 @@ def _record_codex_app_server_usage(agent, turn) -> dict[str, Any]:
try:
if not agent._session_db_created:
agent._ensure_db_session()
agent._session_db.update_token_counts(
# Enqueued for the SessionDB background writer (see above).
agent._session_db.queue_token_counts(
agent.session_id,
input_tokens=canonical_usage.input_tokens,
output_tokens=canonical_usage.output_tokens,
@ -1399,7 +1403,20 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta
try:
close_fn()
except Exception:
pass
# A failed close can leave this response's connection
# checked out of the httpx pool while the caller's finally
# reports a reuse-reason close (e.g. interrupt_check broke
# the event loop with collected output) — caching the
# client with the leaked connection. Poison the slot so
# that close really closes the pool (owner-thread abort;
# mirrors the chat-streaming interrupt-break handling).
# ``client is None`` means the shared primary client,
# which is never reuse-cached and must not have its
# sockets force-shut here.
if client is not None:
agent._abort_request_openai_client(
active_client, reason="codex_stream_close_failed"
)
def run_codex_create_stream_fallback(agent, api_kwargs: dict, client: Any = None):

View file

@ -1354,6 +1354,125 @@ class ContextCompressor(ContextEngine):
previous = telemetry.get("aux_call_duration_ms") or 0
telemetry["aux_call_duration_ms"] = previous + max(0, int(duration_ms))
def _emit_init_summary_once(self) -> None:
"""Emit the informative startup line once, on first resolution.
Deferred out of ``__init__`` (#32221): the line reports resolved token
budgets, so emitting it there would force the synchronous
``get_model_context_length()`` probe during construction. Reads via
the properties below are safe here because
``_resolved_context_length`` is already set.
"""
if not getattr(self, "_log_init_summary", False):
return
self._log_init_summary = False
logger.info(
"Context compressor initialized: model=%s context_length=%d "
"threshold=%d (%.0f%%) target_ratio=%.0f%% tail_budget=%d "
"provider=%s base_url=%s",
self.model, self._resolved_context_length, self.threshold_tokens,
self.threshold_percent * 100, self.summary_target_ratio * 100,
self.tail_token_budget,
self.provider or "none", self.base_url or "none",
)
def _resolve_context_length(self) -> int:
"""Resolve and cache the model's context length on first access."""
if self._resolved_context_length is None:
self._resolved_context_length = get_model_context_length(
self.model,
base_url=self.base_url,
api_key=self.api_key,
config_context_length=self._config_context_length,
provider=self.provider,
)
# Small-context threshold floor: models under 512K trigger at
# >=75% so compaction doesn't fire with half the window still
# free. Raise-only; must run AFTER context_length is resolved
# and BEFORE threshold_tokens is derived (deferred here from
# __init__ along with the resolution itself, #32221).
# _base_threshold_percent already has the per-model override
# applied, so the floor stacks on top of it.
self.threshold_percent = self._effective_threshold_percent(
self._resolved_context_length, self._base_threshold_percent,
)
self._emit_init_summary_once()
return self._resolved_context_length
@property
def context_length(self) -> int:
return self._resolve_context_length()
@context_length.setter
def context_length(self, value: int) -> None:
# No-op guard: repeated assignment of the SAME window (e.g. the codex
# app-server usage callback re-reports the window on every response)
# must not invalidate the derived budgets — that would wipe runtime
# corrections applied directly to threshold_tokens/tail_token_budget
# (see conversation_compression's aux-context threshold sync), which
# persisted on main's eager-init behavior.
if value == getattr(self, "_resolved_context_length", None):
return
self._resolved_context_length = value
# Re-apply the small-context floor (raise-only) for the genuinely new
# window so the invalidated budgets below recompute coherently —
# percent and tokens must derive from the same window. Skipped on
# bare test instances built via object.__new__ that never ran
# __init__ (no _base_threshold_percent).
_base = getattr(self, "_base_threshold_percent", None)
if _base is not None:
self.threshold_percent = self._effective_threshold_percent(
value, _base,
)
self._threshold_tokens = None
self._tail_token_budget = None
self._max_summary_tokens = None
self._emit_init_summary_once()
@property
def threshold_tokens(self) -> int:
if self._threshold_tokens is None:
# Resolve the window FIRST (may apply the small-context floor to
# threshold_percent as a side effect) so the percent read below
# is the floored value regardless of argument evaluation order.
_ctx = self.context_length
# Floor: never compress below MINIMUM_CONTEXT_LENGTH tokens even
# if the percentage would suggest a lower value (#14690 handles
# the degenerate small-window case inside the helper).
self._threshold_tokens = self._compute_threshold_tokens(
_ctx, self.threshold_percent, self.max_tokens,
)
# Apply absolute token cap (compression.threshold_tokens) —
# takes the lower of the ratio-based threshold and the cap.
self._apply_threshold_tokens_cap()
return self._threshold_tokens
@threshold_tokens.setter
def threshold_tokens(self, value: int) -> None:
self._threshold_tokens = value
@property
def tail_token_budget(self) -> int:
if self._tail_token_budget is None:
self._tail_token_budget = int(self.threshold_tokens * self.summary_target_ratio)
return self._tail_token_budget
@tail_token_budget.setter
def tail_token_budget(self, value: int) -> None:
self._tail_token_budget = value
@property
def max_summary_tokens(self) -> int:
if self._max_summary_tokens is None:
self._max_summary_tokens = min(
int(self.context_length * 0.05), _SUMMARY_TOKENS_CEILING,
)
return self._max_summary_tokens
@max_summary_tokens.setter
def max_summary_tokens(self, value: int) -> None:
self._max_summary_tokens = value
def on_session_end(self, session_id: str, messages: List[Dict[str, Any]]) -> None:
"""Clear all per-session compaction state at a real session boundary.
@ -1988,56 +2107,30 @@ class ContextCompressor(ContextEngine):
# deterministic "summary unavailable" handoff and drop the middle window.
self.abort_on_summary_failure = abort_on_summary_failure
self.context_length = get_model_context_length(
model, base_url=base_url, api_key=api_key,
config_context_length=config_context_length,
provider=provider,
)
# Small-context threshold floor: models under 512K trigger at >=75%
# so compaction doesn't fire with half the window still free (the
# incompressible floor makes 50%-triggered compaction thrash on
# 128K-262K models). Raise-only; must run AFTER context_length is
# resolved and BEFORE threshold_tokens is derived. The pre-floor
# value is kept so update_model() can re-derive for a new window
# (switching small -> large must drop back to the configured value).
# Note: _base_threshold_percent already has the per-model override
# applied, so the floor stacks on top of any model-specific threshold.
# Defer context-length resolution to first access (#32221):
# get_model_context_length() can issue a synchronous /models HTTP
# probe, which must not block AIAgent construction. The small-context
# threshold floor and the absolute threshold cap both need the
# resolved window, so they are applied on first resolution (see
# _resolve_context_length / the threshold_tokens property) instead
# of here. update_model() re-derives the floor for a new window from
# _config_threshold_percent (the raw config value snapshotted above),
# so switching small -> large correctly drops back to the configured
# value.
self._config_context_length = config_context_length
self._configured_threshold_percent = self.threshold_percent
self.threshold_percent = self._effective_threshold_percent(
self.context_length, self._base_threshold_percent,
)
threshold_percent = self.threshold_percent
# Floor: never compress below MINIMUM_CONTEXT_LENGTH tokens even if
# the percentage would suggest a lower value. This prevents premature
# compression on large-context models at 50% while keeping the % sane
# for models right at the minimum. _compute_threshold_tokens also
# guards the degenerate case where the floor would equal/exceed the
# window (small models), so auto-compression can still fire (#14690).
self.threshold_tokens = self._compute_threshold_tokens(
self.context_length, threshold_percent, self.max_tokens,
)
# Apply absolute token cap (compression.threshold_tokens) — takes
# the lower of the ratio-based threshold and the cap.
self._apply_threshold_tokens_cap()
self._resolved_context_length: int | None = None
self._threshold_tokens: int | None = None
self._tail_token_budget: int | None = None
self._max_summary_tokens: int | None = None
self.compression_count = 0
# Derive token budgets: ratio is relative to the threshold, not total context
target_tokens = int(self.threshold_tokens * self.summary_target_ratio)
self.tail_token_budget = target_tokens
self.max_summary_tokens = min(
int(self.context_length * 0.05), _SUMMARY_TOKENS_CEILING,
)
if not quiet_mode:
logger.info(
"Context compressor initialized: model=%s context_length=%d "
"threshold=%d (%.0f%%) target_ratio=%.0f%% tail_budget=%d "
"provider=%s base_url=%s",
model, self.context_length, self.threshold_tokens,
threshold_percent * 100, self.summary_target_ratio * 100,
self.tail_token_budget,
provider or "none", base_url or "none",
)
# The "initialized" log reports resolved token budgets, which would
# force the deferred get_model_context_length() probe to run inside
# __init__ and re-introduce the exact synchronous blocking this change
# removes (#32221). Emit it on first context-length resolution instead
# so construction stays non-blocking on every path (not just quiet).
self._log_init_summary = not quiet_mode
self._context_probed = False # True after a step-down from context error
self.last_prompt_tokens = 0

View file

@ -213,8 +213,12 @@ async def preprocess_context_references_async(
f"@ context injection warning: {injected_tokens} tokens exceeds the 25% soft limit ({soft_limit})."
)
stripped = _remove_reference_tokens(message, refs)
final = stripped
# Leave the `@file:`/`@folder:` tokens where the user typed them. The token
# IS the reference, not scaffolding around it: clients render each one as an
# inline chip, so stripping them left a sentence with a hole in it ("review
# and ship") and made the desktop re-derive the refs from the attached block
# to show them as a detached list above the prose.
final = message
if warnings:
final = f"{final}\n\n--- Context Warnings ---\n" + "\n".join(f"- {warning}" for warning in warnings)
if blocks:
@ -473,19 +477,6 @@ def _parse_file_reference_value(value: str) -> tuple[str, int | None, int | None
return _strip_reference_wrappers(value), None, None
def _remove_reference_tokens(message: str, refs: list[ContextReference]) -> str:
pieces: list[str] = []
cursor = 0
for ref in refs:
pieces.append(message[cursor:ref.start])
cursor = ref.end
pieces.append(message[cursor:])
text = "".join(pieces)
text = re.sub(r"\s{2,}", " ", text)
text = re.sub(r"\s+([,.;:!?])", r"\1", text)
return text.strip()
def _is_binary_file(path: Path) -> bool:
mime, _ = mimetypes.guess_type(path.name)
if mime and not mime.startswith("text/") and not any(

View file

@ -2721,16 +2721,28 @@ def try_shrink_image_parts_in_messages(
media_type = "image/jpeg"
return f"data:{media_type};base64,{data}"
def _write_data_url_to_source(source: dict, data_url: str) -> None:
def _write_data_url_to_source(source: dict, data_url: str) -> dict:
"""Return a NEW source dict carrying the re-encoded payload.
Copy-on-write: content parts on the per-call ``api_messages`` list may
be shared references into the persistent conversation history (the
per-message copy is shallow, and cache decoration only deep-copies the
marked messages). Mutating the existing dict would rewrite the stored
transcript with the degraded image so the caller replaces the part,
never edits it in place.
"""
header, _, data = data_url.partition(",")
media_type = "image/jpeg"
if header.startswith("data:"):
candidate = header[len("data:"):].split(";", 1)[0].strip()
if candidate.startswith("image/"):
media_type = candidate
source["type"] = "base64"
source["media_type"] = media_type
source["data"] = data
return {
**source,
"type": "base64",
"media_type": media_type,
"data": data,
}
for msg in api_messages:
if not isinstance(msg, dict):
@ -2738,7 +2750,13 @@ def try_shrink_image_parts_in_messages(
content = msg.get("content")
if not isinstance(content, list):
continue
for part in content:
# Copy-on-write per message: never mutate part/source dicts in place —
# they can alias the stored conversation history (see
# _write_data_url_to_source). Build a replacement content list on the
# first shrunken part and reassign msg["content"] (a top-level write on
# the per-call message copy, which never reaches history).
new_content: list | None = None
for part_idx, part in enumerate(content):
if not isinstance(part, dict):
continue
ptype = part.get("type")
@ -2747,7 +2765,12 @@ def try_shrink_image_parts_in_messages(
url = _source_to_data_url(source)
resized, unshrinkable = _shrink_data_url(url or "")
if resized and isinstance(source, dict):
_write_data_url_to_source(source, resized)
if new_content is None:
new_content = list(content)
new_content[part_idx] = {
**part,
"source": _write_data_url_to_source(source, resized),
}
changed_count += 1
elif unshrinkable:
unshrinkable_oversized += 1
@ -2761,17 +2784,26 @@ def try_shrink_image_parts_in_messages(
url = image_value.get("url", "")
resized, unshrinkable = _shrink_data_url(url)
if resized:
image_value["url"] = resized
if new_content is None:
new_content = list(content)
new_content[part_idx] = {
**part,
"image_url": {**image_value, "url": resized},
}
changed_count += 1
elif unshrinkable:
unshrinkable_oversized += 1
elif isinstance(image_value, str):
resized, unshrinkable = _shrink_data_url(image_value)
if resized:
part["image_url"] = resized
if new_content is None:
new_content = list(content)
new_content[part_idx] = {**part, "image_url": resized}
changed_count += 1
elif unshrinkable:
unshrinkable_oversized += 1
if new_content is not None:
msg["content"] = new_content
if changed_count:
logger.info(

View file

@ -140,6 +140,19 @@ def _apply_active_turn_redirect(agent: Any, messages: List[Dict[str, Any]], text
incomplete by definition; the model regenerates it on the retried turn.
If a future path needs to preserve interrupted thinking, carry it in a
provider-gated reasoning *field*, never in content.
INVARIANT the scaffolding is provider-replay text, not transcript text.
``[This response was interrupted by a user correction.]`` and its
``Visible response before the interruption:`` header exist so the MODEL
understands its own reply was cut off. They are not prose the user wrote
or the agent said. Persisting them into ``content`` painted the raw
machinery as an assistant bubble on every reload (and merged it into the
preceding tool-call bubble), which is what made a steered transcript
unreadable. Carry the scaffolded form in the ``api_content`` sidecar --
the exact bytes replayed to the provider -- and keep ``content`` clean.
When nothing was on screen there is no clean form at all, so the row is
marked ``display_kind="hidden"``: still replayed to the model, dropped by
every transcript surface (desktop, TUI, CLI resume), exactly like the
compaction-reference rows.
"""
visible = agent._strip_think_blocks(
getattr(agent, "_current_streamed_assistant_text", "") or ""
@ -162,9 +175,22 @@ def _apply_active_turn_redirect(agent: Any, messages: List[Dict[str, Any]], text
f"{checkpoint}\n\n"
f"{text}"
)
messages.append({"role": "user", "content": correction})
# Transcript shows the user's own words; the provider replays the
# scaffolded form so it still sees the interrupted context.
messages.append(
{"role": "user", "content": text, "api_content": correction}
)
else:
messages.append({"role": "assistant", "content": checkpoint})
entry: Dict[str, Any] = {
"role": "assistant",
"content": visible or checkpoint,
"api_content": checkpoint,
}
if not visible:
# Nothing reached the screen — this row carries no assistant prose
# at all, only the cut-off notice for the model.
entry["display_kind"] = "hidden"
messages.append(entry)
messages.append({"role": "user", "content": text})
agent._current_streamed_assistant_text = ""
@ -1067,6 +1093,8 @@ def run_conversation(
stream_callback: Optional[callable] = None,
persist_user_message: Optional[Any] = None,
persist_user_timestamp: Optional[float] = None,
persist_user_display_kind: Optional[str] = None,
persist_user_display_metadata: Optional[Dict[str, Any]] = None,
moa_config: Optional[dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
@ -1085,6 +1113,13 @@ def run_conversation(
synthetic prefixes.
persist_user_timestamp: Optional platform event timestamp to store
as metadata on that persisted user message.
persist_user_display_kind: Optional presentation type for a
synthesized user turn (``auto_continue``, ``model_switch``, ).
Display-only: transcript surfaces render the row as a timeline
event instead of a user bubble, while the model still receives
the message unchanged.
persist_user_display_metadata: Optional payload for that event
(e.g. a delegation's task count).
or queuing follow-up prefetch work.
Returns:
@ -1127,6 +1162,8 @@ def run_conversation(
stream_callback,
persist_user_message,
persist_user_timestamp,
persist_user_display_kind=persist_user_display_kind,
persist_user_display_metadata=persist_user_display_metadata,
restore_or_build_system_prompt=_restore_or_build_system_prompt,
install_safe_stdio=_install_safe_stdio,
sanitize_surrogates=_sanitize_surrogates,
@ -2509,6 +2546,17 @@ def run_conversation(
_backoff_touch_counter = 0
while time.time() < sleep_end:
if agent._interrupt_requested:
# A redirect uses the interrupt machinery to cancel
# only the live request. Aborting the retry here
# with clear_interrupt() would DESTROY the pending
# correction and kill the turn with "Operation
# interrupted" — the exact mid-stream steer loss
# users hit when a redirect lands during provider
# backoff. Rebuild from the correction instead,
# mirroring the InterruptedError handler.
if agent.clear_interrupt(preserve_redirect=True):
_retry.restart_with_redirected_messages = True
break
agent._vprint(f"{agent.log_prefix}⚡ Interrupt detected during retry wait, aborting.", force=True)
_interrupt_text = f"Operation interrupted during retry ({_failure_hint}, attempt {retry_count}/{max_retries})."
close_interrupted_tool_sequence(messages, _interrupt_text)
@ -2530,6 +2578,8 @@ def run_conversation(
f"retry backoff ({retry_count}/{max_retries}), "
f"{int(sleep_end - time.time())}s remaining"
)
if _retry.restart_with_redirected_messages:
break # rebuild this iteration from the correction
continue # Retry the API call
agent._turn_received_provider_response = True
@ -3189,7 +3239,12 @@ def run_conversation(
_cost_delta = (_cost_delta or 0.0) + float(_moa_ref_cost)
except (TypeError, ValueError): # pragma: no cover
pass
agent._session_db.update_token_counts(
# Enqueued, not written: the background writer
# applies the delta off the turn thread (a cold
# state.db UPDATE here stalled the tool loop for
# up to hundreds of ms per API call). Drained at
# turn finalize via _persist_session.
agent._session_db.queue_token_counts(
agent.session_id,
input_tokens=canonical_usage.input_tokens,
output_tokens=canonical_usage.output_tokens,
@ -4028,6 +4083,12 @@ def run_conversation(
# Check for interrupt before deciding to retry
if agent._interrupt_requested:
# Preserve a pending redirect (mid-stream correction): the
# user is steering, not stopping. Rebuild the turn from the
# correction instead of aborting with a dead-end interrupt.
if agent.clear_interrupt(preserve_redirect=True):
_retry.restart_with_redirected_messages = True
break
agent._vprint(f"{agent.log_prefix}⚡ Interrupt detected during error handling, aborting retries.", force=True)
_interrupt_text = f"Operation interrupted: handling API error ({error_type}: {agent._clean_error_message(str(api_error))})."
close_interrupted_tool_sequence(messages, _interrupt_text)
@ -5264,6 +5325,12 @@ def run_conversation(
_backoff_touch_counter = 0
while time.time() < sleep_end:
if agent._interrupt_requested:
# Same preserve-redirect rule as the retry-wait above:
# a steering correction must survive backoff, not die
# as "Operation interrupted".
if agent.clear_interrupt(preserve_redirect=True):
_retry.restart_with_redirected_messages = True
break
agent._vprint(f"{agent.log_prefix}⚡ Interrupt detected during retry wait, aborting.", force=True)
_interrupt_text = f"Operation interrupted: retrying API call after error (retry {retry_count}/{max_retries})."
close_interrupted_tool_sequence(messages, _interrupt_text)
@ -5285,6 +5352,11 @@ def run_conversation(
f"error retry backoff ({retry_count}/{max_retries}), "
f"{int(sleep_end - time.time())}s remaining"
)
if _retry.restart_with_redirected_messages:
# Leave the retry loop — the check right below rebuilds this
# iteration from the correction instead of re-firing the
# stale request.
break
if _retry.restart_with_redirected_messages:
# The cancelled request produced no valid assistant item. Reuse the

View file

@ -113,6 +113,13 @@ class InsightsEngine:
"""
cutoff = time.time() - (days * 86400)
# Token/cost totals may still sit on the SessionDB's async
# accounting queue; drain so the report reflects exact counters.
# (self.db may be a raw sqlite3 connection in tests — guard.)
flush = getattr(self.db, "flush_token_counts", None)
if callable(flush):
flush()
# Gather raw data
sessions = self._get_sessions(cutoff, source)
tool_usage = self._get_tool_usage(cutoff, source)

View file

@ -187,17 +187,18 @@ def apply_anthropic_cache_control(
is retained.
Returns:
Deep copy of messages with cache_control breakpoints injected.
Shallow copy of message list with selective deep copies of modified messages.
"""
messages = copy.deepcopy(api_messages)
if not messages:
return messages
if not api_messages:
return api_messages
messages = list(api_messages)
marker = _build_marker(cache_ttl)
breakpoints_used = 0
if messages[0].get("role") == "system":
messages[0] = copy.deepcopy(messages[0])
breakpoints_used = _apply_system_cache_markers(
messages[0],
marker,
@ -213,6 +214,7 @@ def apply_anthropic_cache_control(
and _can_carry_marker(messages[i], native_anthropic=native_anthropic)
]
for idx in non_sys[-remaining:]:
messages[idx] = copy.deepcopy(messages[idx])
_apply_cache_marker(messages[idx], marker, native_anthropic=native_anthropic)
return messages

View file

@ -146,19 +146,18 @@ _REASONING_STALE_TIMEOUT_FLOORS: tuple[tuple[str, int], ...] = (
# so we accept that community forks inheriting the same prefix are
# treated as reasoning models (a reasonable default — the upstream
# gateway timing is the same).
_PATTERN_CACHE: dict[str, re.Pattern[str]] = {}
def _get_pattern(slug: str) -> re.Pattern[str]:
compiled = _PATTERN_CACHE.get(slug)
if compiled is None:
compiled = re.compile(
r"^"
+ re.escape(slug)
+ r"(?:$|[\-._])"
)
_PATTERN_CACHE[slug] = compiled
return compiled
# Pre-compile all patterns at module load time to avoid per-call regex
# compilation and thread-safety issues with the mutable _PATTERN_CACHE.
# The list is built once at import and never mutated afterwards, so it is
# safe for free-threaded Python 3.13+ without any locking. The slug is kept
# in each entry for debuggability (log/inspection), even though _match_any
# only consumes floor + pattern.
_SORTED_REASONING_FLOORS: list[tuple[str, float, re.Pattern[str]]] = [
(slug, floor, re.compile(r"^" + re.escape(slug) + r"(?:$|[\-._])"))
for slug, floor in sorted(
_REASONING_STALE_TIMEOUT_FLOORS, key=lambda kv: -len(kv[0])
)
]
def _match_any(model_lower: str) -> Optional[float]:
@ -169,13 +168,8 @@ def _match_any(model_lower: str) -> Optional[float]:
order is irrelevant: longest slug wins (so ``o3-mini`` beats
``o3`` on a model like ``openai/o3-mini``).
"""
# Sort by slug length descending so longer / more-specific slugs
# win on shared prefixes (o3-mini beats o3).
sorted_floors = sorted(
_REASONING_STALE_TIMEOUT_FLOORS, key=lambda kv: -len(kv[0])
)
for slug, floor in sorted_floors:
if _get_pattern(slug).search(model_lower):
for _slug, floor, pattern in _SORTED_REASONING_FLOORS:
if pattern.search(model_lower):
return float(floor)
return None

View file

@ -332,6 +332,7 @@ class ManagedLlmStream(Iterator[Any]):
self._stream: Any = None
self._raw_stream_resource: Any = None
self._closed = False
self._close_error: BaseException | None = None
self._callback_error: BaseException | None = None
self._logical: tuple[relay_runtime.RelayTurnContext, Any, str] | None = None
self._defer_logical_completion = defer_logical_completion
@ -426,7 +427,11 @@ class ManagedLlmStream(Iterator[Any]):
finally:
close = getattr(raw_stream, "close", None)
if callable(close):
run_callback(close)
try:
run_callback(close)
except BaseException as exc:
self._close_error = exc
raise
def observe_chunk(chunk: Any) -> None:
if self._on_chunk is not None:
@ -494,10 +499,10 @@ class ManagedLlmStream(Iterator[Any]):
try:
chunk = next(self._stream)
except StopIteration:
self.close()
self._close(logical_outcome="cancelled")
raise
if self._accept_chunk is not None and not self._accept_chunk(chunk):
self.close()
self._close(logical_outcome="cancelled")
raise StopIteration
return chunk
@ -512,7 +517,7 @@ class ManagedLlmStream(Iterator[Any]):
if not self._defer_logical_completion:
_complete_logical(self._logical, outcome="success")
self._logical = None
self.close()
self._close(logical_outcome="cancelled")
raise StopIteration from None
except BaseException as exc:
callback_error = self._callback_error
@ -552,6 +557,10 @@ class ManagedLlmStream(Iterator[Any]):
def close(self) -> None:
"""Close an explicitly abandoned stream and cancel its logical call."""
self._close(logical_outcome="cancelled")
close_error = self._close_error
self._close_error = None
if close_error is not None:
raise close_error
def _preserve_pending_provider_chunks(self) -> None:
"""Switch a failed Relay stream to its undelivered provider chunks."""
@ -601,7 +610,9 @@ class ManagedLlmStream(Iterator[Any]):
if callable(close):
try:
close()
except Exception:
except Exception as exc:
if self._close_error is None:
self._close_error = exc
logger.debug(
"Provider stream cleanup failed",
exc_info=True,
@ -618,15 +629,16 @@ class ManagedLlmStream(Iterator[Any]):
try:
loop.run_until_complete(close_stream())
except Exception:
pass
except Exception as exc:
if self._close_error is None:
self._close_error = exc
if not self._defer_logical_completion:
_complete_logical(self._logical, outcome=logical_outcome)
self._logical = None
loop.close()
def __del__(self) -> None:
self.close()
self._close(logical_outcome="cancelled")
class AnthropicStreamAccumulator:

View file

@ -97,7 +97,7 @@ def extract_user_instruction_from_skill_message(content: Any) -> Optional[str]:
return None
def describe_skill_invocation(content: Any) -> Optional[str]:
def describe_skill_invocation(content: Any, separator: str = "") -> Optional[str]:
"""Render a slash-skill-expanded turn the way the user typed it.
The expanded message embeds the whole skill body, so any surface that
@ -109,6 +109,10 @@ def describe_skill_invocation(content: Any) -> Optional[str]:
Returns ``"/work — fix the title leak"``, or ``"/work"`` for a bare
invocation, or ``None`` when *content* is not skill scaffolding (the
caller should then summarize it as an ordinary message).
*separator* joins the command and the instruction. Previews use the
default em dash; pass ``" "`` for the literal invocation the user typed,
which is what chat transcripts render.
"""
if not isinstance(content, str) or not content.startswith(_SKILL_INVOCATION_PREFIX):
return None
@ -127,7 +131,7 @@ def describe_skill_invocation(content: Any) -> Optional[str]:
instruction = instruction.split(SKILL_EXCERPT_JOINT)[0]
instruction = " ".join(instruction.split())
if instruction:
return f"{label}{instruction}" if name else instruction
return f"{label}{separator}{instruction}" if name else instruction
return label if name else None

View file

@ -336,6 +336,8 @@ def build_turn_context(
persist_user_message: Optional[Any],
persist_user_timestamp: Optional[float] = None,
*,
persist_user_display_kind: Optional[str] = None,
persist_user_display_metadata: Optional[Dict[str, Any]] = None,
restore_or_build_system_prompt,
install_safe_stdio,
sanitize_surrogates,
@ -542,6 +544,19 @@ def build_turn_context(
# Add the current user message after the prompt/session setup has made
# close persistence safe. The handoff above preserves any marker already
# stamped by an earlier close flush.
#
# A synthesized turn (auto-continue recovery note, delegation completion)
# declares how it should READ in a transcript. Stamp that on the live
# message so the crash persist below writes the row already typed. Typing
# it after the turn instead leaves the row untyped for the whole run — and
# forever if the turn crashes — so the raw system note paints as a user
# bubble. The model still receives role/content unchanged; the api_messages
# build strips both fields from every outgoing copy.
if persist_user_display_kind:
user_msg["display_kind"] = persist_user_display_kind
if persist_user_display_metadata:
user_msg["display_metadata"] = persist_user_display_metadata
messages.append(user_msg)
current_turn_user_idx = len(messages) - 1
agent._persist_user_message_idx = current_turn_user_idx

View file

@ -120,6 +120,17 @@ export function createSandbox(prefix: string): Sandbox {
'utf8',
)
// Pin Chromium actual-size zoom (level 0) for the suite. Fresh installs
// ship DEFAULT_ZOOM_LEVEL at the Appearance 90% preset, but Playwright
// click hit-testing and the committed visual baselines were calibrated at
// 100%. Without this file every sandbox would inherit the product default
// and fail pointer interception + snapshot diffs.
fs.writeFileSync(
path.join(userDataDir, 'zoom-state.json'),
JSON.stringify({ zoomLevel: 0 }, null, 2),
'utf8',
)
return {
root,
hermesHome,

View file

@ -0,0 +1,94 @@
/**
* Tests for electron/dev-cdp.ts.
*
* Run with: npx vitest run --project electron electron/dev-cdp.test.ts
*/
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { DEFAULT_PORT, describeDevCdpDecision, resolveDevCdpPort } from './dev-cdp'
const DEV_SERVER = 'http://127.0.0.1:5174'
/** The ordinary `npm run dev` / `hgui` run. */
const devRun = { env: {}, isPackaged: false, devServer: DEV_SERVER }
test('a dev-server run opens the default port with no opt-in', () => {
assert.deepEqual(resolveDevCdpPort(devRun), { port: DEFAULT_PORT, reason: null })
})
test('the default matches what the scripts/ tooling reaches for', () => {
// scripts/eval.mjs and scripts/perf/lib/cdp.mjs both default here; if this
// drifts, `node scripts/eval.mjs ...` stops finding a live renderer.
assert.equal(DEFAULT_PORT, 9222)
})
test('a packaged build never opens the port, however loudly the env asks', () => {
const decision = resolveDevCdpPort({ ...devRun, env: { HERMES_DESKTOP_CDP_PORT: '9222' }, isPackaged: true })
assert.deepEqual(decision, { port: null, reason: 'packaged' })
})
test('packaged is checked before every other gate', () => {
// Belt-and-suspenders: dev server present, valid port requested, still shut.
for (const value of ['9222', '', 'off', 'garbage']) {
const decision = resolveDevCdpPort({
env: { HERMES_DESKTOP_CDP_PORT: value },
isPackaged: true,
devServer: DEV_SERVER
})
assert.equal(decision.port, null, `expected packaged to refuse ${JSON.stringify(value)}`)
assert.equal(decision.reason, 'packaged')
}
})
test('an unpackaged dist run (no dev server) does not qualify', () => {
// `electron .` against dist/ is how the packaged app gets smoke tested; it
// should behave like the packaged app, not like a source-tree dev run.
assert.deepEqual(resolveDevCdpPort({ ...devRun, devServer: undefined }), { port: null, reason: 'no-dev-server' })
})
test('the port is overridable', () => {
assert.equal(resolveDevCdpPort({ ...devRun, env: { HERMES_DESKTOP_CDP_PORT: '9333' } }).port, 9333)
})
test('tolerates surrounding whitespace on the override', () => {
assert.equal(resolveDevCdpPort({ ...devRun, env: { HERMES_DESKTOP_CDP_PORT: ' 9333 ' } }).port, 9333)
})
test('can be switched off on a dev run', () => {
for (const value of ['0', 'off', 'OFF', 'false', 'no']) {
const decision = resolveDevCdpPort({ ...devRun, env: { HERMES_DESKTOP_CDP_PORT: value } })
assert.equal(decision.port, null, `expected ${JSON.stringify(value)} to close the port`)
assert.equal(decision.reason, 'opted-out')
}
})
test('refuses ports that are not usable integers', () => {
for (const value of ['80', '-1', '70000', 'yes', '9222.5', '92 22']) {
const decision = resolveDevCdpPort({ ...devRun, env: { HERMES_DESKTOP_CDP_PORT: value } })
assert.equal(decision.port, null, `expected ${JSON.stringify(value)} to be refused`)
assert.equal(decision.reason, 'invalid-port')
}
})
test('explains itself when an explicit setting was not honoured', () => {
// A typo'd port or a deliberate opt-out should say so — silently doing
// something other than what the env asked for is the bad failure mode.
for (const value of ['garbage', 'off']) {
const decision = resolveDevCdpPort({ ...devRun, env: { HERMES_DESKTOP_CDP_PORT: value } })
assert.ok(describeDevCdpDecision(decision), `expected an explanation for ${JSON.stringify(value)}`)
}
})
test('stays quiet when the port opened, or is closed by design', () => {
assert.equal(describeDevCdpDecision(resolveDevCdpPort(devRun)), null)
assert.equal(describeDevCdpDecision(resolveDevCdpPort({ ...devRun, isPackaged: true })), null)
assert.equal(describeDevCdpDecision(resolveDevCdpPort({ ...devRun, devServer: undefined })), null)
})

View file

@ -0,0 +1,108 @@
/**
* Dev Chrome DevTools Protocol exposure for the desktop renderer.
*
* The renderer is a Chromium page, so `--remote-debugging-port` turns it into
* something the repo's existing CDP tooling (`scripts/eval.mjs`,
* `scripts/perf/lib/cdp.mjs`, the `diag-*` / `probe-*` family) can attach to
* and read the live DOM from. Every one of those scripts already defaults to
* 9222, so a dev-server run opens 9222 and they just work.
*
* If you are running a dev server you are already executing arbitrary local
* JS vite's module graph and every postinstall in node_modules so a
* loopback debugging port does not meaningfully widen that. `perf:serve`
* already opens one unconditionally. What must never happen is a *packaged*
* app exposing it, which is the one hard gate here.
*
* - packaged build always closed, whatever the env says.
* - no HERMES_DESKTOP_DEV_SERVER closed (an unpackaged `electron .` against
* dist/ is how the packaged app gets smoke tested; it should behave like
* the packaged app).
* - otherwise open on 9222, or HERMES_DESKTOP_CDP_PORT.
*
* `HERMES_DESKTOP_CDP_PORT=off` (or `0` / `false`) opts out for anyone who
* wants the port closed on a dev run.
*
* The port binds to loopback (Chromium's default) and the address is
* deliberately not configurable: there is no reason to expose a renderer
* debugger off-host, and offering the knob invites someone to try.
*/
/** Why the port is closed, for a one-line log the developer can act on. */
type ClosedReason = 'packaged' | 'no-dev-server' | 'opted-out' | 'invalid-port'
type DevCdpDecision = { port: number; reason: null } | { port: null; reason: ClosedReason }
type DevCdpInput = {
env: Record<string, string | undefined>
isPackaged: boolean
devServer: string | undefined
}
/** What every script under scripts/ already reaches for. */
const DEFAULT_PORT = 9222
// Below 1024 needs privileges on most platforms; 65535 is the ceiling.
const MIN_PORT = 1024
const MAX_PORT = 65535
const OPT_OUT = new Set(['0', 'off', 'false', 'no'])
/**
* Decide whether this run may expose a renderer debugging port, and on which
* port. Pure: every input is passed in, so the gate is testable without an
* Electron app or a real environment.
*/
function resolveDevCdpPort({ env, isPackaged, devServer }: DevCdpInput): DevCdpDecision {
// Packaged wins over everything. Checked first so no combination of
// environment variables can talk a shipped build into opening the port.
if (isPackaged) {
return { port: null, reason: 'packaged' }
}
// A dev server means a source-tree run (`npm run dev` / `hgui`).
if (!devServer) {
return { port: null, reason: 'no-dev-server' }
}
const requested = (env.HERMES_DESKTOP_CDP_PORT ?? '').trim()
if (!requested) {
return { port: DEFAULT_PORT, reason: null }
}
if (OPT_OUT.has(requested.toLowerCase())) {
return { port: null, reason: 'opted-out' }
}
const port = Number(requested)
if (!Number.isInteger(port) || port < MIN_PORT || port > MAX_PORT) {
return { port: null, reason: 'invalid-port' }
}
return { port, reason: null }
}
/** One-line explanation for a closed port, or null when it opened. */
function describeDevCdpDecision(decision: DevCdpDecision): string | null {
switch (decision.reason) {
case null:
return null
case 'invalid-port':
return `HERMES_DESKTOP_CDP_PORT is not a valid port (expected an integer ${MIN_PORT}-${MAX_PORT}, or "off"); renderer debugging is disabled.`
case 'opted-out':
return 'renderer debugging disabled by HERMES_DESKTOP_CDP_PORT.'
// Packaged and dist-run builds are closed by design — the common case, not
// worth a line of startup noise.
case 'packaged':
case 'no-dev-server':
return null
}
}
export { DEFAULT_PORT, describeDevCdpDecision, resolveDevCdpPort }
export type { DevCdpDecision }

View file

@ -7,6 +7,9 @@ import { pathToFileURL } from 'node:url'
import { test } from 'vitest'
import {
clampDataUrlReadMaxMb,
DATA_URL_READ_DEFAULT_MAX_MB,
dataUrlReadMaxBytesFromMb,
DEFAULT_FETCH_TIMEOUT_MS,
encryptDesktopSecret,
resolveDirectoryForIpc,
@ -24,6 +27,14 @@ async function rejectsWithCode(promise, code: string) {
})
}
test('clampDataUrlReadMaxMb defaults and bounds the attach size preference', () => {
assert.equal(clampDataUrlReadMaxMb(undefined), DATA_URL_READ_DEFAULT_MAX_MB)
assert.equal(clampDataUrlReadMaxMb(0), 1)
assert.equal(clampDataUrlReadMaxMb(256), 256)
assert.equal(clampDataUrlReadMaxMb(99999), 4096)
assert.equal(dataUrlReadMaxBytesFromMb(16), 16 * 1024 * 1024)
})
test('resolveTimeoutMs falls back to defaults and accepts overrides', () => {
assert.equal(resolveTimeoutMs(undefined), DEFAULT_FETCH_TIMEOUT_MS)
assert.equal(resolveTimeoutMs(0), DEFAULT_FETCH_TIMEOUT_MS)

View file

@ -4,9 +4,30 @@ import path from 'node:path'
import { fileURLToPath } from 'node:url'
const DEFAULT_FETCH_TIMEOUT_MS = 15_000
const DATA_URL_READ_MAX_BYTES = 16 * 1024 * 1024
// Default / floor / ceiling for Desktop's data-URL file load (composer attach,
// image preview, etc.). The whole file is base64-buffered in main, so this is
// a memory guard — not a model limit. Settings → Chat takes a free-form MB
// value; 16 MB ships as default. The ceiling is only a typo guard (very large
// values can OOM / crash the app).
const DATA_URL_READ_DEFAULT_MAX_MB = 16
const DATA_URL_READ_MIN_MAX_MB = 1
const DATA_URL_READ_MAX_MAX_MB = 4096
const TEXT_PREVIEW_SOURCE_MAX_BYTES = 64 * 1024 * 1024
function clampDataUrlReadMaxMb(value) {
const parsed = Number(value)
if (!Number.isFinite(parsed)) {
return DATA_URL_READ_DEFAULT_MAX_MB
}
return Math.min(DATA_URL_READ_MAX_MAX_MB, Math.max(DATA_URL_READ_MIN_MAX_MB, Math.round(parsed)))
}
function dataUrlReadMaxBytesFromMb(maxMb) {
return clampDataUrlReadMaxMb(maxMb) * 1024 * 1024
}
const SAFE_ENV_SUFFIXES = new Set(['dist', 'example', 'sample', 'template'])
const SENSITIVE_EXTENSIONS = new Set(['.kdbx', '.p12', '.pem', '.pfx'])
@ -304,7 +325,11 @@ async function resolveReadableFileForIpc(
}
export {
DATA_URL_READ_MAX_BYTES,
clampDataUrlReadMaxMb,
DATA_URL_READ_DEFAULT_MAX_MB,
DATA_URL_READ_MAX_MAX_MB,
DATA_URL_READ_MIN_MAX_MB,
dataUrlReadMaxBytesFromMb,
DEFAULT_FETCH_TIMEOUT_MS,
encryptDesktopSecret,
rejectUnsafePathSyntax,

View file

@ -81,6 +81,7 @@ import {
shouldRemoveAppBundle,
uninstallArgsForMode
} from './desktop-uninstall'
import { describeDevCdpDecision, resolveDevCdpPort } from './dev-cdp'
import { installEmbedReferer } from './embed-referer'
import { createEventDeduper } from './event-dedupe'
import { findGitBash as _findGitBash } from './find-git-bash'
@ -114,7 +115,9 @@ import {
switchBranch
} from './git-worktree-ops'
import {
DATA_URL_READ_MAX_BYTES,
clampDataUrlReadMaxMb,
DATA_URL_READ_DEFAULT_MAX_MB,
dataUrlReadMaxBytesFromMb,
DEFAULT_FETCH_TIMEOUT_MS,
encryptDesktopSecret as encryptDesktopSecretStrict,
resolveReadableFileForIpc,
@ -272,6 +275,29 @@ if (REMOTE_DISPLAY_REASON) {
)
}
// Renderer debugging port. On for dev-server runs (`hgui` / `npm run dev`) so
// the CDP tooling in scripts/ can attach; never for a packaged build — see
// electron/dev-cdp.ts. Must run before app `ready` like the switches above;
// Chromium binds it at launch.
const DEV_CDP = resolveDevCdpPort({ env: process.env, isPackaged: IS_PACKAGED, devServer: DEV_SERVER })
if (DEV_CDP.port) {
app.commandLine.appendSwitch('remote-debugging-port', String(DEV_CDP.port))
// Loopback only. Chromium already defaults to 127.0.0.1, but say it out loud
// so a future edit can't widen it by omission.
app.commandLine.appendSwitch('remote-debugging-address', '127.0.0.1')
console.log(
`[hermes] renderer debugging on http://127.0.0.1:${DEV_CDP.port} — anything that can reach it ` +
'can run code in the renderer. HERMES_DESKTOP_CDP_PORT=off to disable.'
)
} else {
const why = describeDevCdpDecision(DEV_CDP)
if (why) {
console.warn(`[hermes] ${why}`)
}
}
// WSLg: Chromium blocklists the Mesa vGPU → software compositing → typing lag.
// /dev/dxg means a real GPU is available; un-blocklist it. Skipped when a remote
// display already forced software (SSH'd-into-WSL).
@ -936,7 +962,7 @@ app.setAboutPanelOptions({
// Custom scheme for streaming local media (video/audio) into the renderer.
// Reading large media through `readFileDataUrl` failed: it base64-loads the
// whole file into memory and is hard-capped at DATA_URL_READ_MAX_BYTES (16 MB),
// whole file into memory and is hard-capped (default 16 MB, Settings → Chat),
// so any non-trivial video silently refused to load. Streaming via a protocol
// handler removes the size cap and gives the <video> element seekable,
// range-aware playback. Must be registered before the app is ready.
@ -5163,7 +5189,7 @@ function buildApplicationMenu() {
label: 'Actual Size',
accelerator: 'CommandOrControl+0',
click: () => {
setAndPersistZoomLevel(mainWindow, 0)
setAndPersistZoomLevel(mainWindow, DEFAULT_ZOOM_LEVEL)
}
},
{
@ -5171,7 +5197,7 @@ function buildApplicationMenu() {
accelerator: 'CommandOrControl+Plus',
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
setAndPersistZoomLevel(mainWindow, mainWindow.webContents.getZoomLevel() + 0.1)
setAndPersistZoomLevel(mainWindow, mainWindow.webContents.getZoomLevel() + ZOOM_STEP)
}
}
},
@ -5180,7 +5206,7 @@ function buildApplicationMenu() {
accelerator: 'CommandOrControl+-',
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
setAndPersistZoomLevel(mainWindow, mainWindow.webContents.getZoomLevel() - 0.1)
setAndPersistZoomLevel(mainWindow, mainWindow.webContents.getZoomLevel() - ZOOM_STEP)
}
}
},
@ -5259,8 +5285,10 @@ function installPreviewShortcut(window) {
// read it back on did-finish-load to re-apply after reloads or crash recovery.
import {
applyZoomLevel,
DEFAULT_ZOOM_LEVEL,
installZoomReassertOnWindowEvents,
percentToZoomLevel,
ZOOM_STEP,
ZOOM_STORAGE_KEY,
zoomLevelToPercent,
zoomWiringForWindowKind
@ -5304,31 +5332,33 @@ function restorePersistedZoomLevel(window) {
return
}
// Fall back to localStorage for installs that predate zoom-state.json,
// migrating the value into the JSON store on first read.
// No JSON yet: paint the shipped default immediately so a fresh install
// doesn't flash Chromium 100%, then try localStorage for pre-JSON installs
// and overwrite if a legacy value is there.
applyZoomLevel(window.webContents, DEFAULT_ZOOM_LEVEL)
window.webContents
.executeJavaScript(
`(() => { try { return localStorage.getItem(${JSON.stringify(ZOOM_STORAGE_KEY)}) } catch { return null } })()`
)
.then(stored => {
if (stored == null || !window || window.isDestroyed()) {
if (!window || window.isDestroyed()) {
return
}
// Notify the renderer too — otherwise the Appearance UI Scale control
// can stay stuck at 100% even though the window zoom was restored.
const applied = applyZoomLevel(window.webContents, Number(stored))
const level = stored == null ? DEFAULT_ZOOM_LEVEL : Number(stored)
const applied = applyZoomLevel(window.webContents, level)
writeZoomState(applied)
})
.catch(error => rememberLog(`[zoom] restore failed: ${error?.message || error}`))
}
function installZoomShortcuts(window) {
// Override Ctrl/Cmd + +/-/0 with half the default zoom step (0.1 vs 0.2).
// The menu items handle this on macOS (where the menu is always present),
// but on Linux/Windows the menu is null and Chromium's default handler
// would use the full 0.2 step, so we intercept here for consistency.
const ZOOM_STEP = 0.1
// Override Ctrl/Cmd + +/-/0 with half Chromium's default zoom step (ZOOM_STEP
// is 0.1 vs Chromium's 0.2). The menu items handle this on macOS (where the
// menu is always present), but on Linux/Windows the menu is null and
// Chromium's default handler would use the full 0.2 step, so we intercept
// here for consistency. Ctrl/Cmd+0 resets to DEFAULT_ZOOM_LEVEL, not Chromium 0.
window.webContents.on('before-input-event', (event, input) => {
const mod = IS_MAC ? input.meta : input.control
@ -5344,7 +5374,7 @@ function installZoomShortcuts(window) {
}
event.preventDefault()
setAndPersistZoomLevel(window, 0)
setAndPersistZoomLevel(window, DEFAULT_ZOOM_LEVEL)
} else if (key === '=' || key === '+') {
// Zoom-in must accept the shift modifier: on US layouts Plus is
// physically Shift+=, so Cmd+Plus arrives as Cmd+Shift+'+' (or '='
@ -9242,7 +9272,8 @@ ipcMain.handle('hermes:window:openInstance', async () => {
// shortcuts and the View menu. Reads and writes target the asking window.
ipcMain.handle('hermes:zoom:get', event => {
const window = BrowserWindow.fromWebContents(event.sender)
const level = window && !window.isDestroyed() ? window.webContents.getZoomLevel() : 0
const level =
window && !window.isDestroyed() ? window.webContents.getZoomLevel() : DEFAULT_ZOOM_LEVEL
return { level, percent: zoomLevelToPercent(level) }
})
@ -10023,9 +10054,56 @@ ipcMain.handle('hermes:notify', (_event, payload) => {
return true
})
// Data-URL file load cap (composer attach + local previews). Main owns the
// persisted MB value so every IPC read honours Settings → Chat without the
// renderer having to pass maxBytes on each call. Default is 16 MB; clamp
// lives in hardening.ts.
const DATA_URL_READ_MAX_CONFIG_PATH = path.join(app.getPath('userData'), 'data-url-read-max.json')
function readPersistedDataUrlReadMaxMb() {
try {
return clampDataUrlReadMaxMb(JSON.parse(fs.readFileSync(DATA_URL_READ_MAX_CONFIG_PATH, 'utf8')).maxMb)
} catch {
return DATA_URL_READ_DEFAULT_MAX_MB
}
}
let dataUrlReadMaxMb = readPersistedDataUrlReadMaxMb()
function persistDataUrlReadMaxMb(maxMb) {
const next = clampDataUrlReadMaxMb(maxMb)
dataUrlReadMaxMb = next
try {
fs.mkdirSync(path.dirname(DATA_URL_READ_MAX_CONFIG_PATH), { recursive: true })
fs.writeFileSync(DATA_URL_READ_MAX_CONFIG_PATH, JSON.stringify({ maxMb: next }, null, 2), 'utf8')
} catch (error) {
rememberLog(`[data-url-read-max] write failed: ${error.message}`)
}
return next
}
ipcMain.handle('hermes:data-url-read-max:get', () => ({
maxMb: dataUrlReadMaxMb,
// Keep the default bytes constant visible for tests / diagnostics.
defaultMaxMb: DATA_URL_READ_DEFAULT_MAX_MB,
maxBytes: dataUrlReadMaxBytesFromMb(dataUrlReadMaxMb)
}))
ipcMain.handle('hermes:data-url-read-max:set', (_event, maxMb) => {
const next = persistDataUrlReadMaxMb(maxMb)
return {
maxMb: next,
defaultMaxMb: DATA_URL_READ_DEFAULT_MAX_MB,
maxBytes: dataUrlReadMaxBytesFromMb(next)
}
})
ipcMain.handle('hermes:readFileDataUrl', async (_event, filePath) => {
const { resolvedPath } = await resolveReadableFileForIpc(filePath, {
maxBytes: DATA_URL_READ_MAX_BYTES,
maxBytes: dataUrlReadMaxBytesFromMb(dataUrlReadMaxMb),
purpose: 'File preview'
})

View file

@ -98,6 +98,10 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
notify: payload => ipcRenderer.invoke('hermes:notify', payload),
requestMicrophoneAccess: () => ipcRenderer.invoke('hermes:requestMicrophoneAccess'),
readFileDataUrl: filePath => ipcRenderer.invoke('hermes:readFileDataUrl', filePath),
dataUrlReadMax: {
get: () => ipcRenderer.invoke('hermes:data-url-read-max:get'),
set: maxMb => ipcRenderer.invoke('hermes:data-url-read-max:set', maxMb)
},
readFileText: filePath => ipcRenderer.invoke('hermes:readFileText', filePath),
selectPaths: options => ipcRenderer.invoke('hermes:selectPaths', options),
writeClipboard: text => ipcRenderer.invoke('hermes:writeClipboard', text),

View file

@ -11,9 +11,11 @@ import { test, vi } from 'vitest'
import {
applyZoomLevel,
clampZoomLevel,
DEFAULT_ZOOM_LEVEL,
installZoomReassertOnWindowEvents,
percentToZoomLevel,
ZOOM_RESIZE_REASSERT_DELAY_MS,
ZOOM_STEP,
ZOOM_STORAGE_KEY,
zoomLevelToPercent,
zoomReassertWindowEvents,
@ -24,26 +26,32 @@ test('storage key stays stable so persisted zoom survives upgrades', () => {
assert.equal(ZOOM_STORAGE_KEY, 'hermes:desktop:zoomLevel')
})
test('default zoom matches the Appearance 90% preset', () => {
assert.equal(ZOOM_STEP, 0.1)
assert.equal(zoomLevelToPercent(DEFAULT_ZOOM_LEVEL), 90)
assert.equal(DEFAULT_ZOOM_LEVEL, percentToZoomLevel(90))
})
test('clampZoomLevel rejects garbage and enforces bounds', () => {
assert.equal(clampZoomLevel(NaN), 0)
assert.equal(clampZoomLevel(Infinity), 0)
assert.equal(clampZoomLevel(undefined), 0)
assert.equal(clampZoomLevel('2'), 0)
assert.equal(clampZoomLevel(NaN), DEFAULT_ZOOM_LEVEL)
assert.equal(clampZoomLevel(Infinity), DEFAULT_ZOOM_LEVEL)
assert.equal(clampZoomLevel(undefined), DEFAULT_ZOOM_LEVEL)
assert.equal(clampZoomLevel('2'), DEFAULT_ZOOM_LEVEL)
assert.equal(clampZoomLevel(0.3), 0.3)
assert.equal(clampZoomLevel(-42), -9)
assert.equal(clampZoomLevel(42), 9)
})
test('level 0 is exactly 100 percent', () => {
test('level 0 is exactly 100 percent (Chromium actual-size baseline)', () => {
assert.equal(zoomLevelToPercent(0), 100)
assert.equal(percentToZoomLevel(100), 0)
})
test('percentToZoomLevel rejects garbage', () => {
assert.equal(percentToZoomLevel(NaN), 0)
assert.equal(percentToZoomLevel(0), 0)
assert.equal(percentToZoomLevel(-50), 0)
assert.equal(percentToZoomLevel(undefined), 0)
test('percentToZoomLevel rejects garbage by falling back to the shipped default', () => {
assert.equal(percentToZoomLevel(NaN), DEFAULT_ZOOM_LEVEL)
assert.equal(percentToZoomLevel(0), DEFAULT_ZOOM_LEVEL)
assert.equal(percentToZoomLevel(-50), DEFAULT_ZOOM_LEVEL)
assert.equal(percentToZoomLevel(undefined), DEFAULT_ZOOM_LEVEL)
})
test('preset percentages roundtrip within rounding', () => {

View file

@ -2,8 +2,11 @@
* Pure helpers for window zoom. The main process owns webContents.setZoomLevel,
* so the menu items, the Ctrl/Cmd shortcuts, and the settings UI all funnel
* through this one clamped scale. Percent is the user-facing unit (100 = the
* default size); Chromium's internal unit is the zoom level, where
* factor = 1.2 ^ level.
* Chromium actual-size baseline); Chromium's internal unit is the zoom level,
* where factor = 1.2 ^ level.
*
* Our shipped default is the Appearance 90% preset tight enough to feel
* denser than Chromium 100%, and selected in the UI Scale control on first run.
*/
export const ZOOM_STORAGE_KEY = 'hermes:desktop:zoomLevel'
@ -12,9 +15,15 @@ const ZOOM_FACTOR_BASE = 1.2
const MIN_ZOOM_LEVEL = -9
const MAX_ZOOM_LEVEL = 9
/** Half Chromium's default step; matching the shortcuts and View menu. */
export const ZOOM_STEP = 0.1
/** Appearance 90% preset. Fresh installs + Actual Size / Ctrl+0. */
export const DEFAULT_ZOOM_LEVEL = Math.log(0.9) / Math.log(ZOOM_FACTOR_BASE)
export function clampZoomLevel(value) {
if (!Number.isFinite(value)) {
return 0
return DEFAULT_ZOOM_LEVEL
}
return Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL)
@ -26,7 +35,7 @@ export function zoomLevelToPercent(level) {
export function percentToZoomLevel(percent) {
if (!Number.isFinite(percent) || percent <= 0) {
return 0
return DEFAULT_ZOOM_LEVEL
}
return clampZoomLevel(Math.log(percent / 100) / Math.log(ZOOM_FACTOR_BASE))

View file

@ -106,7 +106,6 @@
"hast-util-to-text": "^4.0.2",
"ignore": "^7.0.5",
"katex": "^0.16.45",
"leva": "^0.10.1",
"mermaid": "^11.15.0",
"motion": "^12.38.0",
"nanostores": "^1.3.0",

View file

@ -1,6 +1,31 @@
// Simple eval helper — runs an expression and returns the result.value.
const targets = await (await fetch('http://127.0.0.1:9222/json')).json()
const t = targets.find((t) => t.url.includes('5174'))
//
// node scripts/eval.mjs "document.title"
// HERMES_DESKTOP_CDP_PORT=9333 node scripts/eval.mjs "document.title"
//
// Needs a renderer with a debugging port: launch `hgui` / `npm run dev` with
// HERMES_DESKTOP_CDP_PORT set (see electron/dev-cdp.ts).
const port = Number(process.env.HERMES_DESKTOP_CDP_PORT || 9222)
let targets
try {
targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json()
} catch {
console.error(
`no renderer debugging port on 127.0.0.1:${port}. ` +
'Dev-server runs (`npm run dev` / `hgui`) open one automatically — check the app is running, ' +
'and that HERMES_DESKTOP_CDP_PORT is not set to "off" or another port.'
)
process.exit(1)
}
const t = targets.find((t) => t.url.includes('5174')) ?? targets.find((t) => t.type === 'page')
if (!t) {
console.error(`no page target on 127.0.0.1:${port} (found ${targets.length} target(s))`)
process.exit(1)
}
const ws = new WebSocket(t.webSocketDebuggerUrl)
let id = 0
const pending = new Map()

View file

@ -37,8 +37,8 @@ import { notifyError } from '@/store/notifications'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { openSession } from '../open-session'
import { PageSearchShell } from '../page-search-shell'
import { sessionRoute } from '../routes'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import {
@ -280,7 +280,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
const cellCtx: CellCtx = {
onOpen: openArtifact,
onOpenChat: sessionId => navigate(sessionRoute(sessionId))
onOpenChat: sessionId => openSession(sessionId, navigate)
}
return (
@ -345,7 +345,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
failedImage={failedImageIds.has(artifact.id)}
key={artifact.id}
onImageError={markImageFailed}
onOpenChat={sessionId => navigate(sessionRoute(sessionId))}
onOpenChat={sessionId => openSession(sessionId, navigate)}
/>
))}
</div>

View file

@ -1,5 +1,5 @@
import { closeActiveTerminal } from '@/app/right-sidebar/terminal/terminals'
import { closeWorkspaceTab } from '@/components/pane-shell/tree/store'
import { closeFocusedSessionTab } from '@/components/pane-shell/tree/store'
import { isFocusWithin } from '@/lib/keybinds/combo'
import { $previewTabs, closeActiveRightRailTab } from '@/store/preview'
import { closeSessionTile, nextSessionTileForWorkspace } from '@/store/session-states'
@ -8,14 +8,17 @@ import { closeSessionTile, nextSessionTileForWorkspace } from '@/store/session-s
* W close the tab of the context you're in, by precedence:
* 1. a focused terminal its active terminal tab,
* 2. right-rail tabs (live preview and/or file peeks),
* 3. the MAIN zone its active tab (a session tile stacked into the workspace).
* 4. the MAIN (workspace) tab itself, when session tabs are stacked with it:
* 3. the FOCUSED chat zone its active tab (a session tile stacked into it).
* 4. the workspace tab itself, when session tabs are stacked with it:
* the workspace can't close, so W shifts the NEXT session tab into main
* (loads it as the primary + drops its now-redundant tile).
* Returns false when nothing closes, so W is a no-op it never closes the
* window (a bare workspace stays put). Shared by the keyboard path (Win/Linux)
* and the macOS menu-accelerator IPC.
*
* Steps 3-4 follow the same focused zone 19 indexes, so a second chat zone
* with its own tab strip closes ITS tab instead of main's.
*
* `loadSessionIntoWorkspace` carries the app's route-based "load this session
* into main" (the two call sites have router access); omitting it disables the
* step-4 promotion (W stays the pre-existing no-op on the main tab).
@ -28,15 +31,15 @@ export function closeActiveTab(loadSessionIntoWorkspace?: (storedSessionId: stri
}
// Gate on tab *presence*, not on the selection: a stale `$rightRailActiveTabId`
// would otherwise make ⌘W fall through to closeWorkspaceTab() and look broken
// with a tab still on screen. The store resolves which tab that is.
// would otherwise make ⌘W fall through to closeFocusedSessionTab() and look
// broken with a tab still on screen. The store resolves which tab that is.
if ($previewTabs.get().length > 0) {
return closeActiveRightRailTab()
}
// A closeable main-zone tab (a session tile that's the active tab) closes
// outright; the uncloseable workspace tab returns false and falls through.
if (closeWorkspaceTab()) {
// A closeable tab in the focused chat zone (a session tile that's the active
// tab) closes outright; the uncloseable workspace tab falls through.
if (closeFocusedSessionTab()) {
return true
}

View file

@ -9,8 +9,6 @@ import {
} from '@/app/chat/surface-vars'
import { useMediaQuery } from '@/hooks/use-media-query'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { $composerPoppedOut } from '@/store/composer-popout'
import { isSecondaryWindow } from '@/store/windows'
import { COMPOSER_COMPACT_PILL_PX, COMPOSER_SINGLE_LINE_MAX_PX, COMPOSER_STACK_BREAKPOINT_PX } from '../composer-utils'
@ -82,6 +80,11 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef,
const lastBucketedSurfaceHeightRef = useRef(0)
const lastTightRef = useRef<boolean | null>(null)
const lastCompactPillRef = useRef<boolean | null>(null)
// Mirrored into a ref so `syncComposerMetrics` stays referentially stable —
// it's the shared ResizeObserver's handler, and a new identity every render
// would re-register the observation.
const poppedOutRef = useRef(poppedOut)
poppedOutRef.current = poppedOut
const syncComposerMetrics = useCallback(() => {
const composer = composerRef.current
@ -92,9 +95,10 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef,
// Floating composer is out of the thread's flow — it must not reserve any
// bottom clearance. Zero the measured vars so the thread reclaims the space.
// (Read globals here so the callback stays stable; mirror the popoutAllowed
// gate since secondary windows are forced docked.)
if ($composerPoppedOut.get() && !isSecondaryWindow()) {
// Read through a ref so the callback stays stable, and read THIS surface's
// own state: pop-out is per layout zone, so a float in the left split must
// not zero the right split's clearance.
if (poppedOutRef.current) {
lastBucketedHeightRef.current = 0
lastBucketedSurfaceHeightRef.current = 0
setSurfaceVar(composer, COMPOSER_HEIGHT_VAR, '0px')

View file

@ -1,18 +1,20 @@
import { useStore } from '@nanostores/react'
import { type RefObject, useCallback, useEffect } from 'react'
import { type RefObject, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { usePaneGroup, usePaneVisible } from '@/components/pane-shell/pane-visibility'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { triggerHaptic } from '@/lib/haptics'
import {
$composerPopoutPosition,
$composerPoppedOut,
$composerPopoutZone,
clampPopoutPosition,
getComposerPopoutZone,
popoutBoundsElement,
type PopoutPosition,
readPopoutBounds,
setComposerPopoutPosition,
setComposerPoppedOut
} from '@/store/composer-popout'
import { isSecondaryWindow } from '@/store/windows'
import { useComposerScope } from '../scope'
import { useComposerPopoutGestures } from './use-popout-drag'
interface UseComposerPopoutOptions {
@ -20,32 +22,122 @@ interface UseComposerPopoutOptions {
}
/**
* Pop-out engine: the dockedfloating state (a shared, persisted atom), the
* dock/float/toggle actions, the drag gestures, and the on-screen re-clamp.
* Secondary windows (the tiny Ctrl+Shift+N window, subagent watch windows) can't
* pop out a floating composer makes no sense there and would yank the main
* window's composer out via the shared atom.
* This surface's on-screen placement, derived from its zone's drag intent.
*
* A zone stores one intent for its whole tab stack drag the box in any tab and
* it moves in all of them but each surface owns a different rect, so the
* intent is clamped per surface. Clamping into the store instead would have
* every keep-alive-mounted tab overwrite the others with a position bounded by
* ITS geometry, last writer winning: that's how a drag in one tab used to get
* lost in another.
*
* Re-placing is skipped while this surface drags (the gesture already clamped
* against this rect) and while it's an inactive tab (still mounted, so a live
* drag would otherwise force a reflow per background tab per frame).
*/
function usePopoutPlacement(
composerRef: RefObject<HTMLFormElement | null>,
groupId: string,
intent: PopoutPosition,
dragging: boolean,
poppedOut: boolean
): PopoutPosition {
const [placement, setPlacement] = useState(intent)
const visible = usePaneVisible()
// Re-place while this surface is the visible tab and isn't itself dragging.
const live = poppedOut && visible && !dragging
// Resolved before the shared ResizeObserver below registers (hook order puts
// this layout effect first), so the observer always has this surface's own
// bounds element rather than a document-wide first match.
const boundsRef = useRef<Element | null>(null)
useLayoutEffect(() => {
boundsRef.current = popoutBoundsElement(composerRef.current)
})
const reclamp = useCallback(() => {
const el = composerRef.current
if (!el) {
return
}
const size = { height: el.offsetHeight, width: el.offsetWidth }
const next = clampPopoutPosition(getComposerPopoutZone(groupId).position, size, readPopoutBounds(el))
// Bail on an unchanged placement: a sash drag resizes the surface every
// frame, and a fresh object each time re-renders the whole composer.
setPlacement(prev => (prev.bottom === next.bottom && prev.right === next.right ? prev : next))
}, [composerRef, groupId])
// The surface resizing (sash drag, sidebar open, tab split) re-places the box
// against its new rect; the composer resizing (a growing draft) re-places it
// against its new height.
useResizeObserver(
useCallback(() => {
if (live) {
reclamp()
}
}, [live, reclamp]),
composerRef,
boundsRef
)
// useLayoutEffect, not useEffect: a tab revealed after the box was dragged in
// another one must not paint a frame at its stale placement before catching
// up. Runs before paint, and no-ops for hidden tabs (`live`).
useLayoutEffect(() => {
if (!live) {
return undefined
}
reclamp()
// A second pass after layout settles (sidebar widths, fonts): anyone
// restored out of bounds is pulled back even if the first measure was
// premature.
const raf = requestAnimationFrame(reclamp)
window.addEventListener('resize', reclamp)
return () => {
cancelAnimationFrame(raf)
window.removeEventListener('resize', reclamp)
}
}, [intent, live, reclamp])
return dragging ? intent : placement
}
/**
* Pop-out engine: the dockedfloating state, the dock/float/toggle actions, the
* drag gestures, and this surface's placement.
*
* State is scoped to the surface's layout ZONE (its tab stack): tabs in the same
* zone share one float, so switching tabs keeps the box exactly where you put
* it, while a split zone beside them keeps its own popping out on the left
* doesn't fling a composer out of the right.
*
* Secondary windows (the tiny Ctrl+Shift+N window, subagent watch windows) stay
* docked: a floating composer makes no sense in a scratch window.
*/
export function useComposerPopout({ composerRef }: UseComposerPopoutOptions) {
// The floating composer is a window-level singleton: only the main scope
// (not tiles) in a primary window may pop out.
const scope = useComposerScope()
const popoutAllowed = !isSecondaryWindow() && scope.popoutAllowed
const poppedOut = useStore($composerPoppedOut) && popoutAllowed
const popoutPosition = useStore($composerPopoutPosition)
const popoutAllowed = !isSecondaryWindow()
const groupId = usePaneGroup()
const zone = useStore(useMemo(() => $composerPopoutZone(groupId), [groupId]))
const poppedOut = zone.poppedOut && popoutAllowed
const handleComposerPopOut = useCallback(() => {
triggerHaptic('open')
setComposerPoppedOut(true)
}, [])
setComposerPoppedOut(groupId, true)
}, [groupId])
const handleComposerDock = useCallback(() => {
triggerHaptic('success')
setComposerPoppedOut(false)
}, [])
setComposerPoppedOut(groupId, false)
}, [groupId])
// Double-click the grab area toggles dock/float. Undocking restores the last
// position (the persisted atom is never cleared on dock).
// position (a zone's stored position is never cleared on dock).
const handleComposerToggle = useCallback(() => {
poppedOut ? handleComposerDock() : handleComposerPopOut()
}, [handleComposerDock, handleComposerPopOut, poppedOut])
@ -56,39 +148,14 @@ export function useComposerPopout({ composerRef }: UseComposerPopoutOptions) {
onPointerDown: onComposerGesturePointerDown
} = useComposerPopoutGestures({
composerRef,
groupId,
onDock: handleComposerDock,
onPopOut: handleComposerPopOut,
poppedOut,
position: popoutPosition
position: zone.position
})
// Keep the floating box on-screen: re-clamp (with the real measured size +
// thread bounds) when it pops out and on every window resize — so a position
// persisted on a bigger/other monitor, a shrunk window, or now-wider sidebar
// can never strand it. The rAF pass re-clamps after layout settles (sidebar
// widths, fonts), so anyone loading in out of bounds is pulled back + saved
// even if the first measure was premature.
useEffect(() => {
if (!poppedOut) {
return undefined
}
const reclamp = (persist: boolean) => {
const el = composerRef.current
const size = el ? { height: el.offsetHeight, width: el.offsetWidth } : undefined
setComposerPopoutPosition($composerPopoutPosition.get(), { area: readPopoutBounds(el), persist, size })
}
reclamp(true)
const raf = requestAnimationFrame(() => reclamp(true))
const onResize = () => reclamp(false)
window.addEventListener('resize', onResize)
return () => {
cancelAnimationFrame(raf)
window.removeEventListener('resize', onResize)
}
}, [composerRef, poppedOut])
const popoutPosition = usePopoutPlacement(composerRef, groupId, zone.position, dragging, poppedOut)
return {
dockProximity,

View file

@ -106,7 +106,9 @@ export function useComposerQueue({
entryId: entry.id,
sessionKey: activeQueueSessionKey
})
loadIntoComposer(entry.text, entry.attachments)
// Edit what the panel SHOWS. A queued `/skill` entry's text is the
// expanded skill body — never drop that into the composer.
loadIntoComposer(entry.displayText ?? entry.text, entry.attachments)
triggerHaptic('selection')
focusInput()
}
@ -135,7 +137,7 @@ export function useComposerQueue({
if (next) {
setQueueEditSnapshot({ ...queueEdit, entryId: next.id })
loadIntoComposer(next.text, next.attachments)
loadIntoComposer(next.displayText ?? next.text, next.attachments)
} else {
setQueueEditSnapshot(null)
loadIntoComposer(queueEdit.draft, queueEdit.attachments)
@ -213,6 +215,7 @@ export function useComposerQueue({
const accepted = await Promise.resolve(
onSubmit(entry.text, {
attachments: entry.attachments,
...(entry.displayText ? { displayText: entry.displayText } : {}),
fromQueue: true,
sessionId: drainRuntimeSessionId,
storedSessionId: drainQueueSessionKey

View file

@ -9,6 +9,7 @@ import { enqueueQueuedPrompt, type QueuedPromptEntry } from '@/store/composer-qu
import { cloneAttachments, type QueueEditState } from '../composer-utils'
import { onComposerSubmitRequest } from '../focus'
import { pathifyRefs } from '../path-refs'
import { composerPlainText } from '../rich-editor'
import { useComposerScope } from '../scope'
import type { ChatBarProps } from '../types'
@ -139,7 +140,10 @@ export function useComposerSubmit({
}
}
const text = draftRef.current
// A path that never got its committing space (`@apps/desktop/` left by a Tab
// descend, then Enter) is still the reference the user picked — promote it
// on the way out so it attaches instead of submitting as inert text.
const text = pathifyRefs(draftRef.current)
const payloadPresent = text.trim().length > 0 || attachments.length > 0
// A clarify card parked on this session owns the turn: the agent is blocked

View file

@ -25,9 +25,18 @@ export function useLiveCompletionAdapter(options: {
enabled: boolean
debounceMs?: number
fetcher: (query: string) => Promise<CompletionPayload>
/** True when `fetcher` will answer this query from cache. Such a query skips
* both the debounce and the loading state the debounce exists to avoid a
* request per keystroke, and a spinner over an answer we already hold reads
* as latency the user isn't actually paying. */
isCached?: (query: string) => boolean
/** Bump to declare the held answer stale. Without it a popover left open on
* an unchanged query would keep serving what it fetched before the source
* changed, because the adapter de-dupes on the query alone. */
epoch?: number
toItem: (entry: CompletionEntry, index: number) => Unstable_TriggerItem
}): { adapter: Unstable_TriggerAdapter; loading: boolean } {
const { enabled, debounceMs = 60, fetcher, toItem } = options
const { enabled, debounceMs = 60, epoch = 0, fetcher, isCached, toItem } = options
const [state, setState] = useState<{ query: string; items: Unstable_TriggerItem[] }>({
query: EMPTY_QUERY,
@ -62,6 +71,16 @@ export function useLiveCompletionAdapter(options: {
setState({ query: EMPTY_QUERY, items: [] })
}, [cancelTimer, enabled])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
// Invalidate by forgetting which query the held items answer, so the next
// search() re-fetches. The items themselves stay until the new answer
// lands — an open popover must not blink empty on a background refresh.
// On mount this is already the state, so the first run is a no-op.
pendingQueryRef.current = null
setState(current => (current.query === EMPTY_QUERY ? current : { ...current, query: EMPTY_QUERY }))
}, [epoch])
const scheduleFetch = useCallback(
(query: string) => {
if (!enabled) {
@ -75,9 +94,13 @@ export function useLiveCompletionAdapter(options: {
pendingQueryRef.current = query
cancelTimer()
const token = ++tokenRef.current
setLoading(true)
const cached = isCached?.(query) ?? false
timerRef.current = window.setTimeout(() => {
if (!cached) {
setLoading(true)
}
const run = () => {
timerRef.current = null
fetcher(query)
@ -103,9 +126,13 @@ export function useLiveCompletionAdapter(options: {
setLoading(false)
}
})
}, debounceMs)
}
// A cached answer resolves in a microtask, so debouncing it would only
// add a frame of empty popover on every keystroke.
cached ? run() : (timerRef.current = window.setTimeout(run, debounceMs))
},
[cancelTimer, debounceMs, enabled, fetcher, toItem]
[cancelTimer, debounceMs, enabled, fetcher, isCached, toItem]
)
const adapter = useMemo<Unstable_TriggerAdapter>(

View file

@ -3,6 +3,7 @@ import { type PointerEvent as ReactPointerEvent, type RefObject, useCallback, us
import {
POPOUT_ESTIMATED_HEIGHT,
POPOUT_WIDTH_REM,
type PopoutBounds,
type PopoutPosition,
type PopoutSize,
readPopoutBounds,
@ -35,6 +36,8 @@ interface PressState {
interface ComposerPopoutGesturesOptions {
composerRef: RefObject<HTMLFormElement | null>
/** Layout zone this composer belongs to — the scope its float is stored under. */
groupId: string
onDock: () => void
onPopOut: () => void
poppedOut: boolean
@ -67,10 +70,17 @@ function isFloatDragPlatform(target: EventTarget | null) {
}
/** 0 (far) 1 (inside the dock zone). Drives both the dock glow and the
* release-to-dock test (which fires at proximity 1). */
function dockProximityOf(rect: DOMRect) {
const horizontalDist = Math.abs(rect.left + rect.width / 2 - window.innerWidth / 2)
const verticalGap = window.innerHeight - DOCK_ZONE_BOTTOM_PX - rect.bottom
* release-to-dock test (which fires at proximity 1).
*
* Measured against THIS surface's area, not the window: the dock target is the
* docked composer, which sits at the bottom-center of its own chat surface. In
* a split (or any layout where the chat isn't the full window) the viewport's
* bottom-center is somewhere else entirely, so dragging onto the real dock
* never registered. */
function dockProximityOf(rect: DOMRect, area?: PopoutBounds) {
const a = area ?? { bottom: window.innerHeight, left: 0, right: window.innerWidth, top: 0 }
const horizontalDist = Math.abs(rect.left + rect.width / 2 - (a.left + a.right) / 2)
const verticalGap = a.bottom - DOCK_ZONE_BOTTOM_PX - rect.bottom
const v = verticalGap <= 0 ? 1 : Math.max(0, 1 - verticalGap / DOCK_VERTICAL_FALLOFF_PX)
@ -109,6 +119,7 @@ function popoutPositionUnderPointer(
*/
export function useComposerPopoutGestures({
composerRef,
groupId,
onDock,
onPopOut,
poppedOut,
@ -142,7 +153,12 @@ export function useComposerPopoutGestures({
const beginFloatDrag = useCallback(
(state: PressState, clientX: number, clientY: number, next: PopoutPosition, size?: PopoutSize) => {
clearTimer()
const clamped = setComposerPopoutPosition(next, { area: readPopoutBounds(composerRef.current), size })
const clamped = setComposerPopoutPosition(groupId, next, {
area: readPopoutBounds(composerRef.current),
size
})
liveRef.current = clamped
state.mode = 'float'
@ -154,7 +170,7 @@ export function useComposerPopoutGestures({
setDragging(true)
},
[clearTimer, composerRef]
[clearTimer, composerRef, groupId]
)
const peelOffFromDock = useCallback(
@ -255,17 +271,19 @@ export function useComposerPopoutGestures({
const composer = composerRef.current
const size = composer ? { height: composer.offsetHeight, width: composer.offsetWidth } : undefined
const area = readPopoutBounds(composer)
liveRef.current = setComposerPopoutPosition(
groupId,
{
bottom: state.startBottom - (pending.y - state.startY),
right: state.startRight - (pending.x - state.startX)
},
{ area: readPopoutBounds(composer), size }
{ area, size }
)
if (composer) {
setDockProximity(dockProximityOf(composer.getBoundingClientRect()))
setDockProximity(dockProximityOf(composer.getBoundingClientRect(), area))
}
}
@ -317,13 +335,14 @@ export function useComposerPopoutGestures({
if (state.armed && state.mode === 'float') {
const composer = composerRef.current
const rect = composer?.getBoundingClientRect()
const area = readPopoutBounds(composer)
if (rect && dockProximityOf(rect) >= 1) {
if (rect && dockProximityOf(rect, area) >= 1) {
onDock()
} else {
// Persist the resting position once, on release — never per move.
const size = composer ? { height: composer.offsetHeight, width: composer.offsetWidth } : undefined
setComposerPopoutPosition(liveRef.current, { area: readPopoutBounds(composer), persist: true, size })
setComposerPopoutPosition(groupId, liveRef.current, { area, persist: true, size })
}
}
@ -340,7 +359,7 @@ export function useComposerPopoutGestures({
window.removeEventListener('pointerup', handleUp)
window.removeEventListener('pointercancel', handleUp)
}
}, [composerRef, onDock, peelOffFromDock, resetGesture])
}, [composerRef, groupId, onDock, peelOffFromDock, resetGesture])
useEffect(() => clearTimer, [clearTimer])

View file

@ -0,0 +1,98 @@
import type { Unstable_TriggerItem } from '@assistant-ui/core'
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { HermesGateway } from '@/hermes'
import { queryClient } from '@/lib/query-client'
import { invalidateSlashCompletions } from '@/lib/slash-completion-cache'
import { isSkillItem } from '../composer-utils'
import { useSlashCompletions } from './use-slash-completions'
const CATALOG = {
categories: [{ name: 'Session', pairs: [['/new', 'Start a new session']] }],
pairs: [
['/new', 'Start a new session'],
['/work', 'Kick off a task in a fresh worktree']
]
}
function harness(gateway: HermesGateway) {
const api: { search?: (query: string) => readonly Unstable_TriggerItem[] } = {}
function Probe() {
const { adapter } = useSlashCompletions({ gateway })
api.search = adapter.search
return null
}
render(<Probe />)
return api as { search: (query: string) => readonly Unstable_TriggerItem[] }
}
/** Drive the adapter until its async fetch has settled into `search`'s result. */
async function completions(api: { search: (query: string) => readonly Unstable_TriggerItem[] }, query: string) {
await act(async () => {
api.search(query)
await Promise.resolve()
})
// The debounce is skipped only for cached queries; give the timer a beat.
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 120))
})
return api.search(query)
}
afterEach(() => {
cleanup()
queryClient.clear()
})
describe('useSlashCompletions', () => {
it('serves the bare-slash catalog from cache instead of re-requesting it', async () => {
const request = vi.fn().mockResolvedValue(CATALOG)
const api = harness({ request } as unknown as HermesGateway)
await completions(api, '')
expect(request).toHaveBeenCalledTimes(1)
// Reopening `/` must not hit the gateway again.
queryClient.setQueryData(['unrelated'], 1)
await completions(api, '')
expect(request).toHaveBeenCalledTimes(1)
// …until something that changes the command set invalidates it.
await act(async () => invalidateSlashCompletions())
await completions(api, '')
expect(request).toHaveBeenCalledTimes(2)
})
it('offers skill commands on a bare slash, not just built-ins', async () => {
const request = vi.fn().mockResolvedValue(CATALOG)
const api = harness({ request } as unknown as HermesGateway)
const items = await completions(api, '')
const work = items.find(item => (item.metadata as { command?: string })?.command === '/work')
expect((work?.metadata as { group?: string })?.group).toBe('Skills')
})
// A `/` typed mid-message is a reference dropped into prose, so the trigger
// filters the list to skills (use-composer-trigger). A bare mid-message `/`
// resolves to the same empty query as an opening `/`, so that filter runs
// over the catalog — which listed no skill-group rows at all, leaving the
// inline popover empty. Asserted through isSkillItem, the real predicate.
it('leaves only skills for a mid-message slash', async () => {
const request = vi.fn().mockResolvedValue(CATALOG)
const api = harness({ request } as unknown as HermesGateway)
const inline = (await completions(api, '')).filter(isSkillItem)
expect(inline.map(item => (item.metadata as { command?: string })?.command)).toEqual(['/work'])
})
})

View file

@ -1,4 +1,5 @@
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
import { useStore } from '@nanostores/react'
import { useCallback } from 'react'
import type { HermesGateway } from '@/hermes'
@ -12,6 +13,7 @@ import {
isDesktopSlashExtensionCommand,
isDesktopSlashSuggestion
} from '@/lib/desktop-slash-commands'
import { $slashCompletionsEpoch, cachedSlashCompletion, hasCachedSlashCompletion } from '@/lib/slash-completion-cache'
import { normalize } from '@/lib/text'
import { $sessions } from '@/store/session'
@ -63,6 +65,7 @@ export function useSlashCompletions(options: {
} {
const { gateway, skinThemes, activeSkin } = options
const enabled = Boolean(gateway)
const epoch = useStore($slashCompletionsEpoch)
const fetcher = useCallback(
async (query: string): Promise<CompletionPayload> => {
@ -133,7 +136,9 @@ export function useSlashCompletions(options: {
try {
if (!query) {
const catalog = filterDesktopCommandsCatalog(await gateway.request<CommandsCatalogLike>('commands.catalog'))
const catalog = filterDesktopCommandsCatalog(
await cachedSlashCompletion('catalog', () => gateway.request<CommandsCatalogLike>('commands.catalog'))
)
// Prefer the categorized layout so the popover renders section headers
// (Session, Tools & Skills, ...). Fall back to the flat list when the
@ -149,12 +154,26 @@ export function useSlashCompletions(options: {
}))
)
// Skill commands reach us only through the flat `pairs` list — the
// backend categorizes registry commands but appends skills
// uncategorized, so the categorized layout alone drops every skill
// from the bare `/` list even though typing `/wo` offers them.
// Re-add the leftovers under one Skills header (which also gives them
// the skill pill accent and makes them offerable mid-message).
const categorized = new Set(items.map(item => item.text.toLowerCase()))
for (const [command, meta] of catalog.pairs ?? []) {
if (!categorized.has(command.toLowerCase()) && isDesktopSlashExtensionCommand(command)) {
items.push({ text: command, display: command, group: 'Skills', meta })
}
}
return { items, query }
}
const result = await gateway.request<{ items?: CompletionEntry[]; replace_from?: number }>('complete.slash', {
text
})
const result = await cachedSlashCompletion(`slash:${text.toLowerCase()}`, () =>
gateway.request<{ items?: CompletionEntry[]; replace_from?: number }>('complete.slash', { text })
)
// Arg-completion items (replace_from > 1) carry just the arg stub —
// e.g. complete.slash returns `{text: "alice"}` for `/personality alic`
@ -231,5 +250,21 @@ export function useSlashCompletions(options: {
}
}, [])
return useLiveCompletionAdapter({ enabled, fetcher, toItem })
// Mirrors the fetcher's branching: the `/skin` and `/resume` arg stages are
// answered from client-side state, so they never wait on the network; every
// other query is served from the completion cache when it's still warm.
const isCached = useCallback(
(query: string) => {
const text = `/${query}`
if ((skinThemes && /^\/skin\s+/is.test(text)) || /^\/(?:resume|sessions|switch)\s+/is.test(text)) {
return true
}
return hasCachedSlashCompletion(query ? `slash:${text.toLowerCase()}` : 'catalog')
},
[skinThemes]
)
return useLiveCompletionAdapter({ enabled, epoch, fetcher, isCached, toItem })
}

View file

@ -50,6 +50,7 @@ import { useComposerUrlDialog } from './hooks/use-composer-url-dialog'
import { useComposerVoice } from './hooks/use-composer-voice'
import { useSlashCompletions } from './hooks/use-slash-completions'
import { useSessionStatusPresence } from './hooks/use-status-presence'
import { chipTypedPathOnSpace, pathifyRefs } from './path-refs'
import { QueuePanel } from './queue-panel'
import {
composerPlainText,
@ -449,9 +450,9 @@ export function ChatBar({
// Links in the paste land as `@url:` chips rather than a wall of URL text —
// the same reference the "Add URL" dialog inserts, parsed in place so a link
// mid-sentence keeps its position.
// mid-sentence keeps its position. Bare `@path` tokens promote the same way.
recordUndoPoint()
insertComposerContentsAtCaret(event.currentTarget, linkifyUrls(pastedText))
insertComposerContentsAtCaret(event.currentTarget, pathifyRefs(linkifyUrls(pastedText)))
scheduleFlushEditorToDraft(event.currentTarget)
}
@ -519,6 +520,16 @@ export function ChatBar({
return
}
// Same for a bare `@path` — a hand-typed or Tab-descended path chips into
// the `@file:`/`@folder:` ref it means, instead of submitting as plain text
// the backend never resolves.
if (withUndoPoint(() => chipTypedPathOnSpace(event))) {
event.preventDefault()
flushEditorToDraft(event.currentTarget)
return
}
// Cmd/Ctrl+Shift+K drains the next queued message. Plain Cmd/Ctrl+K is
// reserved for the global command palette.
if ((event.metaKey || event.ctrlKey) && !event.altKey && event.shiftKey && event.key.toLowerCase() === 'k') {

View file

@ -0,0 +1,103 @@
import type { KeyboardEvent } from 'react'
import { describe, expect, it } from 'vitest'
import { chipTypedPathOnSpace, pathifyRefs } from './path-refs'
import { composerPlainText, RICH_INPUT_SLOT } from './rich-editor'
/**
* Tab-descending into a folder from the `@` popover leaves a bare
* `@apps/desktop/` in the editor. That is not a reference the backend's
* REFERENCE_PATTERN only matches `@kind:value` so it used to submit as plain
* text and silently attach nothing.
*/
/** An editor holding `text` with a collapsed caret at its end, plus the space
* keydown the composer would hand `chipTypedPathOnSpace`. */
const spaceOn = (text: string) => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.textContent = text
document.body.append(editor)
const selection = window.getSelection()!
const range = document.createRange()
range.setStart(editor.firstChild!, text.length)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
return { editor, event: { currentTarget: editor, key: ' ' } as KeyboardEvent<HTMLDivElement> }
}
describe('pathifyRefs', () => {
it('promotes a Tab-descended folder token', () => {
expect(pathifyRefs('look at @apps/desktop/')).toBe('look at @folder:`apps/desktop`')
})
it('promotes a typed file path', () => {
expect(pathifyRefs('read @src/main.ts please')).toBe('read @file:`src/main.ts` please')
})
it('promotes every bare path in one message', () => {
expect(pathifyRefs('@apps/desktop/ and @src/main.ts')).toBe('@folder:`apps/desktop` and @file:`src/main.ts`')
})
it('leaves an already-typed directive alone', () => {
const text = 'look at @folder:`apps/desktop` ok'
expect(pathifyRefs(text)).toBe(text)
})
it('leaves a handle alone', () => {
expect(pathifyRefs('cc @teknium1 on this')).toBe('cc @teknium1 on this')
})
it('leaves simple refs alone', () => {
expect(pathifyRefs('check @diff and @staged')).toBe('check @diff and @staged')
})
it('leaves an email alone', () => {
expect(pathifyRefs('mail me at brooklyn@nous.dev/x')).toBe('mail me at brooklyn@nous.dev/x')
})
it('is a no-op without an @', () => {
expect(pathifyRefs('nothing here')).toBe('nothing here')
})
})
describe('chipTypedPathOnSpace', () => {
it('chips a Tab-descended folder token into a real @folder: chip', () => {
const { editor, event } = spaceOn('look at @apps/desktop/')
expect(chipTypedPathOnSpace(event)).toBe(true)
const chip = editor.querySelector('[data-ref-text]')
expect(chip?.getAttribute('data-ref-kind')).toBe('folder')
expect(chip?.getAttribute('data-ref-id')).toBe('apps/desktop')
expect(chip?.textContent).toContain('desktop')
expect(composerPlainText(editor)).toBe('look at @folder:`apps/desktop` ')
})
it('chips a typed file path', () => {
const { editor, event } = spaceOn('read @src/main.ts')
expect(chipTypedPathOnSpace(event)).toBe(true)
expect(editor.querySelector('[data-ref-kind="file"]')).not.toBeNull()
expect(composerPlainText(editor)).toBe('read @file:`src/main.ts` ')
})
it('leaves a handle alone', () => {
const { editor, event } = spaceOn('cc @teknium1')
expect(chipTypedPathOnSpace(event)).toBe(false)
expect(editor.querySelector('[data-ref-text]')).toBeNull()
})
it('leaves an already-typed ref alone', () => {
const { event } = spaceOn('look at @folder:`apps/desktop`')
expect(chipTypedPathOnSpace(event)).toBe(false)
})
})

View file

@ -0,0 +1,103 @@
/**
* Bare-path recognition for the composer. Walking into a folder from the `@`
* popover (Tab) re-types the token as a bare `@apps/desktop/` so the next
* `complete.path` lists that folder's children. That's right while typing, but
* a bare token is not a reference: `REFERENCE_PATTERN` only matches
* `@kind:value`, so a draft sent with one attaches nothing and renders as
* plain text instead of a chip.
*
* So the bare token is promoted to its typed form on the way out the same
* shape `url-refs.ts` uses for bare links.
*/
import type { KeyboardEvent } from 'react'
import { quoteRefValue, REF_RE, refChipElement, replaceBeforeCaret } from './rich-editor'
import { textBeforeCaret } from './text-utils'
// A `/` is required, exactly like URL_RE requires an explicit scheme: `@teknium1`
// and `@diff` are a handle and a simple ref, not paths, and guessing wrong turns
// someone's name into a file reference. A separator is the cheap signal that the
// user meant a path. The token also can't start with a `:` kind prefix — that is
// already a typed ref and REF_RE owns it.
const BARE_PATH_RE = /(?<![\w@/])@((?!(?:file|folder|url|image|tool|line|terminal|session|git):)[^\s@:]*\/[^\s@:]*)/g
const TYPED_BARE_PATH_RE = new RegExp(`${BARE_PATH_RE.source}$`)
/** Trailing `/` means a directory — that's how the gateway's completion emits
* folders, and what Tab-descending leaves behind. */
export function barePathRef(path: string) {
const trimmed = path.replace(/\/+$/, '')
return trimmed ? `@${path.endsWith('/') ? 'folder' : 'file'}:${quoteRefValue(trimmed)}` : null
}
/** Rewrite bare `@path` tokens as typed `@file:`/`@folder:` directives, leaving
* anything already inside a directive alone. Returns `text` unchanged when
* there are none. */
export function pathifyRefs(text: string) {
if (!text.includes('@')) {
return text
}
REF_RE.lastIndex = 0
const fenced = Array.from(text.matchAll(REF_RE)).map(match => {
const start = match.index ?? 0
return { end: start + match[0].length, start }
})
let out = ''
let cursor = 0
for (const match of text.matchAll(BARE_PATH_RE)) {
const start = match.index ?? 0
const ref = barePathRef(match[1] || '')
if (!ref || fenced.some(span => start >= span.start && start < span.end)) {
continue
}
out += `${text.slice(cursor, start)}${ref}`
cursor = start + match[0].length
}
return out + text.slice(cursor)
}
/** A plain space finishing a typed `@path` commits it as a chip, so a hand-typed
* path chips the same way a picked one does. Returns whether it ran, so a
* keydown handler can fall through on anything else. */
export function chipTypedPathOnSpace(event: KeyboardEvent<HTMLDivElement>) {
if (event.key !== ' ' || event.metaKey || event.ctrlKey || event.altKey) {
return false
}
const editor = event.currentTarget
// Runs on every space, so bail on the cheap native read before paying for the
// caret range walk (same guard shape as the trigger detector).
if (!editor.textContent?.includes('@')) {
return false
}
const before = textBeforeCaret(editor)
const match = before ? TYPED_BARE_PATH_RE.exec(before) : null
const token = match?.[0]
const ref = match?.[1] ? barePathRef(match[1]) : null
if (!token || !ref) {
return false
}
const directive = ref.match(/^@([^:]+):(.+)$/)
if (!directive) {
return false
}
const fragment = document.createDocumentFragment()
fragment.append(refChipElement(directive[1], directive[2]), document.createTextNode(' '))
return replaceBeforeCaret(editor, token.length, fragment)
}

View file

@ -22,7 +22,7 @@ interface QueuePanelProps {
}
const entryPreview = (entry: QueuedPromptEntry, c: Translations['composer']) =>
entry.text.trim() || (entry.attachments.length > 0 ? c.attachmentOnly : c.emptyTurn)
(entry.displayText ?? entry.text).trim() || (entry.attachments.length > 0 ? c.attachmentOnly : c.emptyTurn)
export function QueuePanel({
busy,

View file

@ -23,8 +23,6 @@ export interface ComposerScope {
/** This scope's "turn parked on user input" edge — gates Esc-to-stop. */
$awaitingInput: ReadableAtom<boolean>
attachments: ComposerAttachmentScope
/** Only the main scope may pop out (the floating composer is a singleton). */
popoutAllowed: boolean
/** This scope's transcript. Read it imperatively (input-history browse) to
* keep streaming out of the composer's renders; subscribe only off-render
* (auto-speak) where the reply edge is the whole point. */
@ -37,7 +35,6 @@ export const MAIN_COMPOSER_SCOPE: ComposerScope = {
$awaitingInput: $activeSessionAwaitingInput,
$messages,
attachments: mainComposerScope,
popoutAllowed: true,
target: 'main'
}

View file

@ -0,0 +1,54 @@
import { MessageRepository } from '@assistant-ui/core/internal'
import { renderHook } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import type { ChatMessage } from '@/lib/chat-messages'
import { syncRepositoryIncrementally } from '@/lib/incremental-external-store-runtime'
import { useRuntimeMessageRepository } from './runtime-repository'
const text = (id: string, role: ChatMessage['role'], body: string): ChatMessage => ({
id,
role,
parts: [{ type: 'text', text: body }]
})
/** The repository the runtime drives — it throws on a duplicate link. */
const feedToRepository = (repository: ExportedRepository) => {
const runtime = { repository: new MessageRepository() } as unknown as Parameters<
typeof syncRepositoryIncrementally
>[0]
return syncRepositoryIncrementally(runtime, repository)
}
type ExportedRepository = ReturnType<typeof useRuntimeMessageRepository>
describe('useRuntimeMessageRepository', () => {
it('emits each id once when the transcript repeats one', () => {
const { result } = renderHook(() =>
useRuntimeMessageRepository([
text('user-1', 'user', 'hi'),
text('assistant-1', 'assistant', 'hello'),
text('user-1', 'user', 'hi')
])
)
const ids = result.current.messages.map(item => item.message.id)
expect(ids).toEqual(['user-1', 'assistant-1'])
})
it('builds a repository the runtime can link without throwing', () => {
const { result } = renderHook(() =>
useRuntimeMessageRepository([
text('user-1', 'user', 'hi'),
text('assistant-stream-1', 'assistant', 'partial'),
text('assistant-stream-1', 'assistant', 'partial'),
text('user-2', 'user', 'more')
])
)
expect(feedToRepository(result.current).map(item => item.id)).toEqual(['user-1', 'assistant-stream-1', 'user-2'])
})
})

View file

@ -30,10 +30,22 @@ export function useRuntimeMessageRepository(messages: ChatMessage[]): ExportedMe
return useMemo(() => {
const items: { message: ThreadMessage; parentId: string | null }[] = []
const branchParentByGroup = new Map<string, string | null>()
const seenIds = new Set<string>()
let visibleParentId: string | null = null
let headId: string | null = null
for (const message of coalesceToolOnlyAssistants(messages, toolMergeCacheRef.current)) {
// A repeated id is a transcript bug upstream, but it must not reach the
// repository: MessageRepository throws on the second link ("A message
// with the same id already exists in the parent tree") and takes the
// whole workspace pane down with it. Keep the first occurrence — the
// later copy carries the same id, so it is the row we already rendered.
if (seenIds.has(message.id)) {
continue
}
seenIds.add(message.id)
let parentId = visibleParentId
if (message.role === 'assistant' && message.branchGroupId) {

View file

@ -43,6 +43,7 @@ import {
$layoutTree,
$treeDragging,
type DropHint,
isSessionStripPane,
revealTreePane,
SESSION_TILE_DRAG
} from '@/components/pane-shell/tree/store'
@ -84,7 +85,7 @@ function chatZonePane(groupId: string): null | string {
const tree = $layoutTree.get()
const panes = tree ? (findGroup(tree, groupId)?.panes ?? []) : []
return panes.find(p => p === 'workspace' || p.startsWith('session-tile:')) ?? null
return panes.find(isSessionStripPane) ?? null
}
/**

View file

@ -128,7 +128,6 @@ function TileChat({
$awaitingInput: sessionAwaitingInput(runtimeId),
$messages: view.$messages,
attachments,
popoutAllowed: false,
target: `tile:${storedSessionId}`
}),
[attachments, runtimeId, storedSessionId, view.$messages]

View file

@ -113,6 +113,7 @@ import { orderByIds, reconcileOrderIds, resolveManualSessionOrderIds, sameIds }
import { ProfileRail } from './profile-switcher'
import { ProjectDialog } from './project-dialog'
import {
excludeProjectSessions,
orderProjectsByIds,
overlayLiveLanes,
overlayLivePreviews,
@ -429,6 +430,16 @@ export function ChatSidebar({
}, [pinnedSessionIds, sessionByAnyId])
const pinnedRealIdSet = useMemo(() => new Set(pinnedSessions.map(s => s.id)), [pinnedSessions])
const pinnedIdSet = useMemo(() => new Set(pinnedSessionIds), [pinnedSessionIds])
// A pinned session belongs to the Pinned section and nowhere else, so every
// other list filters it out (the flat recents already did). Match on the live
// id AND the durable pin id — a backend snapshot can surface either side of a
// compression tip rotation.
const isPinnedSession = useCallback(
(session: SessionInfo) => pinnedRealIdSet.has(session.id) || pinnedIdSet.has(sessionPinId(session)),
[pinnedRealIdSet, pinnedIdSet]
)
// Full-text search across *all* sessions (not just the loaded page) so 699
// sessions stay findable. Debounced; loaded sessions are matched instantly
@ -492,8 +503,8 @@ export function ChatSidebar({
}, [trimmedQuery, sortedSessions, serverMatches, sessionByAnyId])
const unpinnedAgentSessions = useMemo(
() => sortedSessions.filter(s => !pinnedRealIdSet.has(s.id)),
[sortedSessions, pinnedRealIdSet]
() => sortedSessions.filter(s => !isPinnedSession(s)),
[sortedSessions, isPinnedSession]
)
useEffect(() => {
@ -615,13 +626,18 @@ export function ChatSidebar({
const sorted = sortProjectsForOverview(
projectTree
.filter(node => !(node.isAuto && dismissed.has(node.id)))
.map(project => ({
...project,
// Home is synthetic, so its name is ours to translate — every other
// label is a repo basename or a name the user typed.
label: project.isNoProject ? s.projects.home : project.label,
repos: orderRepos(project.repos)
})),
.map(project =>
excludeProjectSessions(
{
...project,
// Home is synthetic, so its name is ours to translate — every other
// label is a repo basename or a name the user typed.
label: project.isNoProject ? s.projects.home : project.label,
repos: orderRepos(project.repos)
},
isPinnedSession
)
),
activeProjectId
)
@ -629,7 +645,16 @@ export function ChatSidebar({
// (default) returns `sorted` untouched; projects the user hasn't ordered yet
// keep their sorted position rather than jumping the hand-picked list.
return orderProjectsByIds(sorted, projectOrderIds)
}, [showAllProfiles, projectTree, dismissedAutoProjects, orderRepos, activeProjectId, projectOrderIds, s])
}, [
showAllProfiles,
projectTree,
dismissedAutoProjects,
orderRepos,
activeProjectId,
projectOrderIds,
isPinnedSession,
s
])
// The overview only renders in grouped mode; the model stays live regardless
// so scoping is consistent across views.
@ -690,11 +715,16 @@ export function ChatSidebar({
// The live-session overlay (creates/evictions) is applied per-repo in
// RepoFlatSection, AFTER the visual git-worktree lanes are merged in (so
// out-of-tree worktrees can be placed). Here we just order the snapshot.
// out-of-tree worktrees can be placed). Here we just order the snapshot and
// drop pinned rows — the hydrated lanes come straight from the backend, so
// they haven't been through projectModel's filter.
// The label comes from the overview node either way — that's the model's
// presentation copy (Home is translated there), not the raw payload's.
return { ...hydrated, label: overviewEnteredProject.label, repos: orderRepos(hydrated.repos) }
}, [overviewEnteredProject, enteredProjectTree, orderRepos])
return excludeProjectSessions(
{ ...hydrated, label: overviewEnteredProject.label, repos: orderRepos(hydrated.repos) },
isPinnedSession
)
}, [overviewEnteredProject, enteredProjectTree, orderRepos, isPinnedSession])
// Overlay live `$sessions` onto the entered project so a just-created session
// (which the backend snapshot hasn't folded in yet) counts as content and
@ -877,6 +907,10 @@ export function ChatSidebar({
}
const bySource = new Map<string, SessionInfo[]>()
// Rows this platform owns that the Pinned section is showing instead. The
// backend's per-platform total counts them, so discount it or "load more"
// promises rows that will never appear.
const pinnedBySource = new Map<string, number>()
for (const session of messagingSessions) {
const sourceId = normalizeSessionSource(session.source)
@ -885,6 +919,12 @@ export function ChatSidebar({
continue
}
if (isPinnedSession(session)) {
pinnedBySource.set(sourceId, (pinnedBySource.get(sourceId) ?? 0) + 1)
continue
}
const list = bySource.get(sourceId) ?? []
list.push(session)
bySource.set(sourceId, list)
@ -894,13 +934,14 @@ export function ChatSidebar({
.map(([sourceId, list]) => {
const ordered = [...list].sort((a, b) => sessionTime(b) - sessionTime(a))
const known = messagingPlatformTotals[sourceId]
const total = Math.max(ordered.length, known ?? 0)
const unpinnedKnown = known == null ? null : Math.max(0, known - (pinnedBySource.get(sourceId) ?? 0))
const total = Math.max(ordered.length, unpinnedKnown ?? 0)
return {
// Known exact total → more exist iff total exceeds loaded; otherwise
// the seed fetch was capped, so assume more until a per-platform load
// resolves the count.
hasMore: known != null ? known > ordered.length : messagingTruncated,
hasMore: unpinnedKnown != null ? unpinnedKnown > ordered.length : messagingTruncated,
label: sessionSourceLabel(sourceId) ?? sourceId,
sessions: ordered,
sourceId,
@ -908,7 +949,7 @@ export function ChatSidebar({
}
})
.sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0]))
}, [messagingSessions, messagingPlatformTotals, messagingTruncated])
}, [messagingSessions, messagingPlatformTotals, messagingTruncated, isPinnedSession])
// ALL-profiles view: one collapsible group per profile, color on the header
// (not on every row). Default profile floats to the top, the rest alpha.

View file

@ -11,6 +11,7 @@ export { ProjectBackRow, ProjectOverviewRow } from './overview-row'
export { ProjectMenu } from './project-menu'
export { SidebarWorkspaceGroup } from './workspace-group'
export {
excludeProjectSessions,
overlayLiveLanes,
overlayLivePreviews,
sessionRecency,

View file

@ -5,6 +5,7 @@ import type { ProjectInfo, SessionInfo } from '@/types/hermes'
import {
baseName,
excludeProjectSessions,
kanbanWorktreeDir,
liveSessionProjectId,
mergeRepoWorktreeGroups,
@ -865,3 +866,102 @@ describe('overlayLivePreviews', () => {
expect(previews[NO_PROJECT_ID].map(s => s.id)).toEqual(['fresh'])
})
})
describe('excludeProjectSessions', () => {
it('drops matching rows from every lane and recounts the subtree', () => {
const keep = makeSession('/www/app', { id: 'keep' })
const pinnedRow = makeSession('/www/app', { id: 'pinned' })
const project = projectNode({
id: '/www/app',
repos: [
{
id: '/www/app',
label: 'app',
path: '/www/app',
sessionCount: 2,
groups: [lane({ id: 'main', isMain: true, label: 'main', path: '/www/app', sessions: [keep, pinnedRow] })]
}
],
sessionCount: 2
})
const filtered = excludeProjectSessions(project, session => session.id === 'pinned')
expect(filtered.repos[0].groups[0].sessions.map(s => s.id)).toEqual(['keep'])
expect(filtered.repos[0].sessionCount).toBe(1)
expect(filtered.sessionCount).toBe(1)
})
it('keeps a lane the filter emptied — a worktree is structure, not a row', () => {
const pinnedRow = makeSession('/www/app/wt', { id: 'pinned' })
const project = projectNode({
id: '/www/app',
previewSessions: [pinnedRow],
repos: [
{
id: '/www/app',
label: 'app',
path: '/www/app',
sessionCount: 1,
groups: [lane({ id: 'wt', label: 'wt', path: '/www/app/wt', sessions: [pinnedRow] })]
}
],
sessionCount: 1
})
const filtered = excludeProjectSessions(project, session => session.id === 'pinned')
expect(filtered.repos[0].groups.map(g => g.id)).toEqual(['wt'])
expect(filtered.repos[0].groups[0].sessions).toEqual([])
expect(filtered.previewSessions).toEqual([])
expect(filtered.sessionCount).toBe(0)
})
it('returns the same node when nothing matches (memo-stable)', () => {
const project = projectNode({
id: '/www/app',
previewSessions: [makeSession('/www/app', { id: 'keep' })],
repos: [
{
id: '/www/app',
label: 'app',
path: '/www/app',
sessionCount: 1,
groups: [
lane({ id: 'main', isMain: true, label: 'main', sessions: [makeSession('/www/app', { id: 'keep' })] })
]
}
],
sessionCount: 1
})
expect(excludeProjectSessions(project, () => false)).toBe(project)
})
it('survives the live overlay: a lane left empty by the filter is not pruned', () => {
// The two run in sequence on an entered project (filter, then overlay), and
// the overlay drops lanes it empties — it must not take the filter's with it.
const pinnedRow = makeSession('/www/app/wt', { id: 'pinned' })
const project = projectNode({
id: '/www/app',
repos: [
{
id: '/www/app',
label: 'app',
path: '/www/app',
sessionCount: 1,
groups: [lane({ id: 'wt', label: 'wt', path: '/www/app/wt', sessions: [pinnedRow] })]
}
],
sessionCount: 1
})
const filtered = excludeProjectSessions(project, session => session.id === 'pinned')
const overlaid = overlayLiveLanes(filtered, [], new Set(['someone-else']))
expect(overlaid.repos[0].groups.map(g => g.id)).toEqual(['wt'])
})
})

View file

@ -497,6 +497,10 @@ export function overlayRepoLanes(
): SidebarWorkspaceTree {
const repoRootKey = pathKey(repo.path)
let changed = false
// Lanes that arrived with no rows are not eviction casualties — they're real
// structure (a `git worktree list` lane, or one whose sessions are pinned
// away). The prune below is only allowed to drop lanes IT emptied.
const emptyOnInput = new Set(repo.groups.filter(g => !g.sessions.length).map(g => g.id))
// Snapshot lanes minus anything the user just deleted/archived.
const lanes = repo.groups.map(g => {
@ -577,7 +581,7 @@ export function overlayRepoLanes(
// Drop lanes emptied by eviction (the server only emits non-empty lanes; the
// git-worktree enhancer re-adds any still-real worktree as an empty lane).
const groups = sortWorktreeGroups(lanes.filter(g => g.sessions.length > 0))
const groups = sortWorktreeGroups(lanes.filter(g => g.sessions.length > 0 || emptyOnInput.has(g.id)))
return { ...repo, groups, sessionCount: groups.reduce((n, g) => n + g.sessions.length, 0) }
}
@ -610,6 +614,65 @@ function overlayHomeLane(
}
}
/**
* Drop matching sessions from every lane (and the overview preview) of a
* project subtree, recounting as lanes shrink. Used to keep pinned sessions out
* of the project lists: a pin belongs to the Pinned section, not to both. The
* predicate rather than an id set lets the caller match a pin on its
* durable lineage-root id as well as the live one.
*
* Lanes SURVIVE being emptied. A worktree is structure (it exists on disk, you
* can still start work in it); pinning its last chat must not delete the branch
* from the tree same reason the `git worktree list` enhancer injects lanes
* that never had a session. Only the rows move. Memo-stable: returns the same
* ref when nothing matched.
*/
export function excludeProjectSessions(
project: SidebarProjectTree,
isExcluded: (session: SessionInfo) => boolean
): SidebarProjectTree {
let changed = false
const repos = project.repos.map(repo => {
let repoChanged = false
const groups = repo.groups.map(group => {
const sessions = group.sessions.filter(session => !isExcluded(session))
if (sessions.length === group.sessions.length) {
return group
}
repoChanged = true
return { ...group, sessions }
})
if (!repoChanged) {
return repo
}
changed = true
return { ...repo, groups, sessionCount: groups.reduce((n, group) => n + group.sessions.length, 0) }
})
const previewSessions = project.previewSessions?.filter(session => !isExcluded(session))
changed ||= previewSessions?.length !== project.previewSessions?.length
if (!changed) {
return project
}
return {
...project,
previewSessions,
repos,
sessionCount: repos.reduce((n, repo) => n + repo.sessionCount, 0)
}
}
/** Project-level overlay: {@link overlayRepoLanes} across every repo subtree. */
export function overlayLiveLanes(
project: SidebarProjectTree,

View file

@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useEffect, useRef, useState } from 'react'
import { openSession } from '@/app/open-session'
import {
closeAllTreeTabs,
closeOtherTreeTabs,
@ -37,8 +38,8 @@ import {
setSessions
} from '@/store/session'
import { $sessionColorOverrides, setSessionColorOverride } from '@/store/session-color'
import { $sessionTiles, openSessionTile } from '@/store/session-states'
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
import { $sessionTiles } from '@/store/session-states'
import { canOpenSessionWindow } from '@/store/windows'
import type { SessionTitleResponse } from '../../types'
@ -169,8 +170,9 @@ function useSessionActions({
onSelect: () => {
triggerHaptic('selection')
// Stack into the MAIN zone as a tab (center dock; the strip
// sticky-shows on gain) — the door to the tab bar.
openSessionTile(sessionId, 'center')
// sticky-shows on gain) — the door to the tab bar. Focuses first
// if the session is already on screen.
openSession(sessionId, () => undefined, 'tab')
}
})
]
@ -183,7 +185,7 @@ function useSessionActions({
label: r.newWindow,
onSelect: () => {
triggerHaptic('selection')
void openSessionInNewWindow(sessionId)
openSession(sessionId, () => undefined, 'window')
}
})
]

View file

@ -4,6 +4,7 @@ import type * as React from 'react'
import { ProfileTag } from '@/app/chat/profile-tag'
import { startSessionDrag } from '@/app/chat/session-drag'
import { PlatformAvatar } from '@/app/messaging/platform-icon'
import { openSession } from '@/app/open-session'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
@ -14,8 +15,7 @@ import { triggerHaptic } from '@/lib/haptics'
import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
import { coarseElapsed } from '@/lib/time'
import { cn } from '@/lib/utils'
import { $attentionSessionIds, openSessionTile } from '@/store/session-states'
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
import { $attentionSessionIds } from '@/store/session-states'
import { SessionStatusDot } from '../session-status-dot'
@ -174,18 +174,18 @@ export function SidebarSessionRow({
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
openSessionTile(session.id, 'center')
openSession(session.id, () => undefined, 'tab')
}
}}
onClick={event => {
const mod = event.metaKey || event.ctrlKey
// ⇧⌘-click → pop into its own window (needs standalone windows).
if (mod && event.shiftKey && canOpenSessionWindow()) {
if (mod && event.shiftKey) {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
void openSessionInNewWindow(session.id)
openSession(session.id, () => undefined, 'window')
return
}
@ -195,7 +195,7 @@ export function SidebarSessionRow({
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
openSessionTile(session.id, 'center')
openSession(session.id, () => undefined, 'tab')
return
}

View file

@ -197,7 +197,14 @@ export function SidebarSessionsSection({
// A defined project list is itself content (even an empty project should
// render as a drill-in row so the user can see it exists).
const hasProjectOverview = Boolean(projectOverview?.length)
const hasProjectContent = Boolean(projectContent && projectContent.sessionCount > 0)
// Lanes count as content even with no rows left in them: the backend only
// emits a lane that has sessions, so a lane surviving with zero rows means
// they were filtered out (pinned) — the branch is real and must still render.
// A genuinely empty project has no lanes at all and keeps its empty state.
const hasProjectContent = Boolean(
projectContent && (projectContent.sessionCount > 0 || projectContent.repos.some(repo => repo.groups.length > 0))
)
const showEmptyState =
forceEmptyState || (!hasGroupedSessions && !hasProjectOverview && !hasProjectContent && sessions.length === 0)

View file

@ -1,7 +1,7 @@
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { Dialog as DialogPrimitive } from 'radix-ui'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { HUD_HEADING, HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from '@/app/floating-hud'
@ -66,6 +66,7 @@ import { luminance } from '@/themes/color'
import { type ThemeMode, useTheme } from '@/themes/context'
import { isUserTheme, resolveTheme } from '@/themes/user-themes'
import { openSession, openSessionIntentFromModifiers } from '../open-session'
import {
AGENTS_ROUTE,
ARTIFACTS_ROUTE,
@ -75,7 +76,6 @@ import {
navigateToWorkspacePage,
NEW_CHAT_ROUTE,
PROFILES_ROUTE,
sessionRoute,
SETTINGS_ROUTE,
SKILLS_ROUTE,
STARMAP_ROUTE
@ -99,6 +99,12 @@ interface PaletteItem {
keepOpen?: boolean
keywords?: string[]
label: string
/**
* When set, /-select (or -Enter) opens a new tab and -select pops a
* window matching sidebar session rows. Plain select stays in-place.
* Receives the last selector event so the modifiers can be read.
*/
runWithEvent?: (event?: { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean }) => void
/** Action to run when selected. Mutually exclusive with `to`. */
run?: () => void
/** Open a nested palette page (VS Code-style "choose X → options"). */
@ -308,6 +314,22 @@ export function CommandPalette() {
const [search, setSearch] = useState('')
const [page, setPage] = useState<string | null>(null)
// cmdk's onSelect doesn't forward the triggering event — keep the last
// click/keydown modifiers so session rows can honour ⌘-Enter / ⌘-click.
const lastSelectMods = useRef<{ ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }>({
ctrlKey: false,
metaKey: false,
shiftKey: false
})
const noteSelectMods = (event: { ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }) => {
lastSelectMods.current = {
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
shiftKey: event.shiftKey
}
}
// Server-backed sources for the type-to-search groups, fetched lazily while
// the palette is open. react-query handles caching/dedup/staleness.
const configQuery = useQuery({
@ -357,6 +379,15 @@ export function CommandPalette() {
const go = useCallback((path: string) => () => navigateToWorkspacePage(navigate, path), [navigate])
// Sessions: plain select = open in-place (focus existing tile/main, else main);
// ⌘/⌃-select / ⌘-Enter = new tab; ⇧⌘ = own window. Same door as the sidebar.
const goSession = useCallback(
(sessionId: string) => (event?: { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean }) => {
openSession(sessionId, navigate, openSessionIntentFromModifiers(event))
},
[navigate]
)
// Step up one nested page (or back to the root list), clearing the filter so
// the parent page doesn't reopen mid-search.
const goBack = useCallback(() => {
@ -625,7 +656,7 @@ export function CommandPalette() {
id: `goto-${directId}`,
keywords: ['session', 'id', 'go to', directId],
label: `${t.commandCenter.goToSession} ${directId}`,
run: go(sessionRoute(directId))
runWithEvent: goSession(directId)
}
]
})
@ -713,7 +744,7 @@ export function CommandPalette() {
...(session.git_branch ? [session.git_branch] : [])
],
label: session.title,
run: go(sessionRoute(session.id))
runWithEvent: goSession(session.id)
}))
})
}
@ -768,6 +799,7 @@ export function CommandPalette() {
availableThemes,
configFieldLabel,
go,
goSession,
mcpServers,
mode,
resolvedMode,
@ -872,7 +904,14 @@ export function CommandPalette() {
return
}
item.run?.()
if (item.runWithEvent) {
item.runWithEvent(lastSelectMods.current)
} else {
item.run?.()
}
// Clear stashed modifiers so a plain Enter after a ⌘-click isn't sticky.
lastSelectMods.current = { ctrlKey: false, metaKey: false, shiftKey: false }
if (!item.keepOpen) {
closeCommandPalette()
@ -909,6 +948,10 @@ export function CommandPalette() {
<CommandInput
className={HUD_TEXT}
onKeyDown={event => {
// Capture modifiers before cmdk's Enter fires onSelect (which
// swipes the inviting MouseEvent and hands us nothing).
noteSelectMods(event)
if (!activePage) {
return
}
@ -962,6 +1005,7 @@ export function CommandPalette() {
className={cn(HUD_ITEM, HUD_TEXT)}
key={item.id}
keywords={item.keywords}
onMouseDown={noteSelectMods}
onSelect={() => handleSelect(item)}
value={paletteValue(item)}
>

View file

@ -8,7 +8,7 @@ import { PALETTE_AREA, type PaletteContribution } from '@/app/command-palette/co
import { type StatusbarItem } from '@/app/shell/statusbar-controls'
import { IdleMount } from '@/components/idle-mount'
import { toggleLayoutEditMode } from '@/components/pane-shell/edit-mode'
import { allPaneIds, group, split } from '@/components/pane-shell/tree/model'
import { allPaneIds, group, groupLeafIds, split } from '@/components/pane-shell/tree/model'
import { LayoutTreeRoot } from '@/components/pane-shell/tree/renderer'
import type { DoubleTapContext } from '@/components/pane-shell/tree/renderer/drag-session'
import {
@ -41,6 +41,7 @@ import { sessionTitle as storedSessionTitle } from '@/lib/chat-runtime'
import { LayoutDashboard, PanelBottom } from '@/lib/icons'
import { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions'
import { Codecs, persistentAtom } from '@/lib/persisted'
import { pruneComposerPopoutZones } from '@/store/composer-popout'
import {
$fileBrowserOpen,
$panesFlipped,
@ -415,6 +416,15 @@ watchContributedPanes()
watchSessionTiles()
watchRouteTiles()
// Composer pop-out state is keyed by layout zone, so drop entries for zones the
// user has since closed or merged away — otherwise a long-lived install keeps a
// row for every split it has ever had.
$layoutTree.subscribe(tree => {
if (tree) {
pruneComposerPopoutZones(groupLeafIds(tree))
}
})
// Mirror sidebar pins into the backend keep-flag so the auto-archive sweep
// never hides a pinned chat (and pre-existing pins migrate transparently).
watchSessionPins()

View file

@ -1,6 +1,7 @@
import { useEffect, useRef } from 'react'
import { closeActiveTab } from '@/app/chat/close-tab'
import { openSession } from '@/app/open-session'
import { storedSessionIdForNotification } from '@/lib/session-ids'
import { respondToApprovalAction } from '@/store/native-notifications'
import { $activeGatewayProfile } from '@/store/profile'
@ -123,12 +124,13 @@ export function useDesktopIntegrations({
}
}, [resumeExhaustedSessionId])
// Native-notification click -> jump to the session (runtime id translated to
// the stored id the chat route is keyed by); action buttons resolve in place.
// Native-notification click -> jump to the session WHERE IT ALREADY IS (open
// tile / main) instead of forcing main. Runtime id is translated to the
// stored id the chat route is keyed by; action buttons resolve in place.
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onFocusSession?.(sessionId => {
if (sessionId) {
navigate(sessionRoute(storedSessionIdForNotification(sessionId, runtimeIdByStoredSessionId.current)))
openSession(storedSessionIdForNotification(sessionId, runtimeIdByStoredSessionId.current), navigate)
}
})

View file

@ -53,7 +53,6 @@ import {
setBusy,
setMessages
} from '@/store/session'
import { focusedSessionNeedsRoute, focusOpenSession } from '@/store/session-states'
import { clearSessionTodos, setSessionTodos, todosForHydration } from '@/store/todos'
import { isSecondaryWindow } from '@/store/windows'
import { useSkinCommand } from '@/themes/use-skin-command'
@ -66,21 +65,14 @@ import { useGatewayRequest } from '../gateway/hooks/use-gateway-request'
import { useKeybinds } from '../hooks/use-keybinds'
import { ModelPickerOverlay } from '../model-picker-overlay'
import { ModelVisibilityOverlay } from '../model-visibility-overlay'
import { openSession } from '../open-session'
import { PetGenerateOverlay } from '../pet-generate/pet-generate-overlay'
import { FileActionDialogs } from '../right-sidebar/file-actions'
import { RemoteFolderPicker } from '../right-sidebar/files/remote-picker'
import { resetProjectTreeState } from '../right-sidebar/files/use-project-tree'
import { PersistentTerminal } from '../right-sidebar/terminal/persistent'
import { closeAllTerminals } from '../right-sidebar/terminal/terminals'
import {
$workspaceIsPage,
CRON_ROUTE,
navigateToWorkspacePage,
routeSessionId,
sessionRoute,
SETTINGS_ROUTE,
syncWorkspaceRoute
} from '../routes'
import { CRON_ROUTE, navigateToWorkspacePage, routeSessionId, SETTINGS_ROUTE, syncWorkspaceRoute } from '../routes'
import { SessionPickerOverlay } from '../session-picker-overlay'
import { SessionSwitcher } from '../session-switcher'
import { useBackgroundQueueDrain } from '../session/hooks/use-background-queue-drain'
@ -820,15 +812,8 @@ export function ContribWiring({ children }: { children: ReactNode }) {
onRemoveAttachment: id => void composer.removeAttachment(id),
onRestoreToMessage: restoreToMessage,
// Already on screen (open tile, or the main session)? Jump to its tab;
// otherwise load it into main. From a full page (artifacts, skills, …) a
// `'main'` hit still has to route back: fronting the workspace tab alone
// leaves the page showing, so clicking the ACTIVE session was a no-op and
// the user had to bounce off another row to get back to the chat.
onResumeSession: sessionId => {
if (focusedSessionNeedsRoute(focusOpenSession(sessionId), $workspaceIsPage.get())) {
navigate(sessionRoute(sessionId))
}
},
// otherwise load it into main. Same door every other session link uses.
onResumeSession: sessionId => openSession(sessionId, navigate),
onRetryResume: sessionId => void resumeSession(sessionId, true),
onSteer: steerPrompt,
onSubmit: submitText,
@ -965,7 +950,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
/>
)}
<ModelPickerOverlay gateway={gateway || undefined} onSelect={selectModel} profile={activeGatewayProfile} />
<SessionPickerOverlay onResume={resumeSession} />
<SessionPickerOverlay onResume={sessionId => openSession(sessionId, navigate)} />
<ModelVisibilityOverlay
gateway={gateway || undefined}
onOpenProviders={openProviderSettings}
@ -1007,7 +992,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
onClose={closeOverlayToPreviousRoute}
onDeleteSession={removeSession}
onNavigateRoute={path => navigateToWorkspacePage(navigate, path)}
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
onOpenSession={sessionId => openSession(sessionId, navigate)}
/>
</Suspense>
)}
@ -1022,7 +1007,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
<Suspense fallback={null}>
<CronView
onClose={closeOverlayToPreviousRoute}
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
onOpenSession={sessionId => openSession(sessionId, navigate)}
/>
</Suspense>
)}

View file

@ -53,6 +53,7 @@ import { openNewWindow } from '@/store/windows'
import { useTheme } from '@/themes/context'
import { requestComposerFocus, requestVoiceToggle } from '../chat/composer/focus'
import { openSession } from '../open-session'
import {
AGENTS_ROUTE,
ARTIFACTS_ROUTE,
@ -103,7 +104,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
const goToSession = (sessionId: null | string) => {
if (sessionId) {
navigate(sessionRoute(sessionId))
openSession(sessionId, navigate)
}
}

View file

@ -0,0 +1,116 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const focusOpenSession = vi.fn()
const openSessionTile = vi.fn()
const openSessionInNewWindow = vi.fn()
const canOpenSessionWindow = vi.fn(() => true)
const workspaceIsPageGet = vi.fn(() => false)
vi.mock('@/store/session-states', () => ({
focusedSessionNeedsRoute: (focused: 'main' | 'tile' | null, workspaceIsPage: boolean) =>
!focused || (focused === 'main' && workspaceIsPage),
focusOpenSession: (...args: unknown[]) => focusOpenSession(...args),
openSessionTile: (...args: unknown[]) => openSessionTile(...args)
}))
vi.mock('@/store/windows', () => ({
canOpenSessionWindow: () => canOpenSessionWindow(),
openSessionInNewWindow: (...args: unknown[]) => openSessionInNewWindow(...args)
}))
vi.mock('./routes', () => ({
$workspaceIsPage: { get: () => workspaceIsPageGet() },
sessionRoute: (id: string) => `/c/${encodeURIComponent(id)}`
}))
import { openSession, openSessionIntentFromModifiers } from './open-session'
describe('openSessionIntentFromModifiers', () => {
it('defaults to in-place', () => {
expect(openSessionIntentFromModifiers()).toBe('in-place')
expect(openSessionIntentFromModifiers(null)).toBe('in-place')
expect(openSessionIntentFromModifiers({})).toBe('in-place')
})
it('reads ⌘/⌃ as tab and ⇧+mod as window', () => {
expect(openSessionIntentFromModifiers({ metaKey: true })).toBe('tab')
expect(openSessionIntentFromModifiers({ ctrlKey: true })).toBe('tab')
expect(openSessionIntentFromModifiers({ metaKey: true, shiftKey: true })).toBe('window')
expect(openSessionIntentFromModifiers({ shiftKey: true })).toBe('in-place')
})
})
describe('openSession', () => {
const navigate = vi.fn()
beforeEach(() => {
navigate.mockClear()
focusOpenSession.mockReset()
openSessionTile.mockReset()
openSessionInNewWindow.mockReset()
canOpenSessionWindow.mockReturnValue(true)
workspaceIsPageGet.mockReturnValue(false)
})
it('in-place focuses an existing tile and does not navigate', () => {
focusOpenSession.mockReturnValue('tile')
openSession('s1', navigate)
expect(focusOpenSession).toHaveBeenCalledWith('s1')
expect(navigate).not.toHaveBeenCalled()
expect(openSessionTile).not.toHaveBeenCalled()
})
it('in-place focuses main when already selected and not on a page', () => {
focusOpenSession.mockReturnValue('main')
openSession('s1', navigate)
expect(navigate).not.toHaveBeenCalled()
})
it('in-place routes when the main session is covered by a page', () => {
focusOpenSession.mockReturnValue('main')
workspaceIsPageGet.mockReturnValue(true)
openSession('s1', navigate)
expect(navigate).toHaveBeenCalledWith('/c/s1')
})
it('in-place routes when the session is not on screen', () => {
focusOpenSession.mockReturnValue(null)
openSession('s1', navigate)
expect(navigate).toHaveBeenCalledWith('/c/s1')
})
it('tab focuses an existing open session instead of stacking another', () => {
focusOpenSession.mockReturnValue('tile')
openSession('s1', navigate, 'tab')
expect(focusOpenSession).toHaveBeenCalledWith('s1')
expect(openSessionTile).not.toHaveBeenCalled()
expect(navigate).not.toHaveBeenCalled()
})
it('tab opens a stacked session tile when not on screen', () => {
focusOpenSession.mockReturnValue(null)
openSession('s1', navigate, 'tab')
expect(openSessionTile).toHaveBeenCalledWith('s1', 'center')
expect(navigate).not.toHaveBeenCalled()
})
it('window pops out when the bridge supports it', () => {
openSession('s1', navigate, 'window')
expect(openSessionInNewWindow).toHaveBeenCalledWith('s1')
expect(openSessionTile).not.toHaveBeenCalled()
})
it('window falls back to a tab when pop-out is unavailable', () => {
canOpenSessionWindow.mockReturnValue(false)
focusOpenSession.mockReturnValue(null)
openSession('s1', navigate, 'window')
expect(openSessionInNewWindow).not.toHaveBeenCalled()
expect(openSessionTile).toHaveBeenCalledWith('s1', 'center')
})
it('no-ops on an empty id', () => {
openSession('', navigate)
expect(navigate).not.toHaveBeenCalled()
expect(focusOpenSession).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,90 @@
/**
* One door for "open this session" every surface (sidebar, K, notifications,
* session switcher, refs, cron/artifacts) goes through here so a chat that's
* already a tile (or the main tab) is JUMPED TO instead of yanked into main.
*
* Intents:
* - `in-place` (click / Enter) focus existing tile/main if on screen; else
* load into main (same as the left sessions sidebar).
* - `tab` (/-click / -Enter / session refs) focus if already on screen,
* else open as a stacked session tab (never steals main from under you).
* - `window` (-click) pop into its own window; falls back to `tab` when
* the bridge has no session-window support.
*/
import { focusedSessionNeedsRoute, focusOpenSession, openSessionTile } from '@/store/session-states'
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
import { $workspaceIsPage, sessionRoute } from './routes'
export type OpenSessionIntent = 'in-place' | 'tab' | 'window'
export type OpenSessionNavigate = (to: string, options?: { replace?: boolean }) => void
/** Read modifiers the way session rows do — meta OR ctrl for tab, +shift for window. */
export function openSessionIntentFromModifiers(
event?: null | { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean }
): OpenSessionIntent {
if (!event) {
return 'in-place'
}
const mod = Boolean(event.metaKey || event.ctrlKey)
if (mod && event.shiftKey) {
return 'window'
}
if (mod) {
return 'tab'
}
return 'in-place'
}
/**
* @param navigate Required for `in-place` (route into main when not on screen).
* `tab` / `window` ignore it pass a no-op when you don't have a router handle.
*/
export function openSession(
storedSessionId: string,
navigate: OpenSessionNavigate,
intent: OpenSessionIntent = 'in-place'
): void {
if (!storedSessionId) {
return
}
let resolved: OpenSessionIntent = intent
if (resolved === 'window') {
if (canOpenSessionWindow()) {
void openSessionInNewWindow(storedSessionId)
return
}
// No pop-out support → treat like a new tab.
resolved = 'tab'
}
if (resolved === 'tab') {
// Already on screen? Front it. openSessionTile would no-op on main without
// focusing, or try to relocate an existing tile — neither is right for a
// soft "open beside" link.
if (focusOpenSession(storedSessionId)) {
return
}
openSessionTile(storedSessionId, 'center')
return
}
// Already on screen (open tile, or the main session)? Jump to its tab;
// otherwise load it into main. From a full page (artifacts, skills, …) a
// `'main'` hit still has to route back: fronting the workspace tab alone
// leaves the page showing.
if (focusedSessionNeedsRoute(focusOpenSession(storedSessionId), $workspaceIsPage.get())) {
navigate(sessionRoute(storedSessionId))
}
}

View file

@ -10,7 +10,7 @@ import { $attentionSessionIds, $workingSessionIds } from '@/store/session-states
import { $switcherIndex, $switcherOpen, $switcherSessions, closeSwitcher } from '@/store/session-switcher'
import { HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from './floating-hud'
import { sessionRoute } from './routes'
import { openSession } from './open-session'
// Compact session-switcher HUD — keyboard-driven from `use-keybinds`, rows
// clickable via mousedown (Ctrl+click on macOS). No Dialog: Tab stays global.
@ -39,7 +39,7 @@ export function SessionSwitcher() {
const pick = (sessionId: string) => {
closeSwitcher()
navigate(sessionRoute(sessionId))
openSession(sessionId, navigate)
}
return createPortal(

View file

@ -15,6 +15,7 @@ import { resolveGatewayEventSessionId } from '@/lib/gateway-events'
import { triggerHaptic } from '@/lib/haptics'
import { modelOptionsQueryKey } from '@/lib/model-options'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { invalidateSlashCompletions } from '@/lib/slash-completion-cache'
import { type AgentNoticePayload, clearAgentNotice, nativeNoticeInput, showAgentNotice } from '@/store/agent-notices'
import { reconcileApprovalModeForProfile } from '@/store/approval-mode'
import { billingCtaLabel, clearBillingBlock, runBillingRecovery, setBillingBlock } from '@/store/billing-block'
@ -756,6 +757,13 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
}
}
// The agent just created/deleted/renamed a skill, which adds or removes
// its `/name` command. Drop the composer's cached `/` list so the new
// skill is offerable now rather than after the hour-long TTL.
if (payload?.name === 'skill_manage') {
invalidateSlashCompletions()
}
if (typeof payload?.inline_diff === 'string' && payload.inline_diff.trim()) {
recordToolDiff(payload.tool_id || payload.name || '', payload.inline_diff)
}

View file

@ -1215,9 +1215,9 @@ describe('usePromptActions slash.exec dispatch payloads', () => {
it("sends a skill's kickoff into the TAB that invoked it, not the foreground chat", async () => {
// `/work` in a fresh ⌘T tab: slash.exec returns a skill dispatch whose
// `message` is the kickoff prompt. The dispatcher resolved the tab as its
// target, printed "⚡ loading skill" there — then submitted the kickoff
// with no target at all, so submit re-resolved from activeSessionIdRef and
// fired it as a user message into whatever conversation was on screen.
// target, then submitted the kickoff with no target at all, so submit
// re-resolved from activeSessionIdRef and fired it as a user message into
// whatever conversation was on screen.
const tabRuntimeId = 'tab-runtime'
const tabStoredId = 'tab-stored'
@ -1262,6 +1262,56 @@ describe('usePromptActions slash.exec dispatch payloads', () => {
$queuedPromptsBySession.set({})
})
it('renders a skill turn as its invocation — the expanded body never reaches a bubble', async () => {
// A `/skill` dispatch's `message` is the whole skill body (model-facing
// scaffolding). The agent must receive it verbatim; every UI surface —
// the user bubble and any system line — must show only `/work fix it`.
const skillBody =
'[IMPORTANT: The user has invoked the "work" skill, indicating they want you to follow its instructions.\n' +
'The full skill content is loaded below.]\n\nSPIN UP A WORKTREE, never the primary checkout.\n\n' +
'The user has provided the following instruction alongside the skill invocation: fix it'
const states: Record<string, unknown>[] = []
const submitted: (Record<string, unknown> | undefined)[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === 'prompt.submit') {
submitted.push(params)
}
return (
method === 'slash.exec' ? { type: 'skill', name: 'work', message: skillBody, display: '/work fix it' } : {}
) as never
})
let handle: HarnessHandle | null = null
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={s => states.push(s)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
await handle!.submitText('/work fix it')
// The agent still gets the full skill.
expect(submitted).toEqual([expect.objectContaining({ text: skillBody })])
const rendered = states.flatMap(state => {
const messages = Array.isArray(state.messages)
? (state.messages as Array<{ parts?: Array<{ text?: string }> }>)
: []
return messages.flatMap(message => (message.parts ?? []).map(part => part.text ?? ''))
})
expect(rendered).toContain('/work fix it')
expect(rendered.join('\n')).not.toContain('SPIN UP A WORKTREE')
expect(rendered.join('\n')).not.toContain('IMPORTANT: The user has invoked')
})
it('slash status header carries the command token, not the full invocation', async () => {
// `/goal <long prose>` used to echo the entire invocation in the mono
// header AND the goal text again in the backend notice right under it.
@ -1555,6 +1605,7 @@ describe('usePromptActions submit / queue drain semantics', () => {
expect(requestGateway).toHaveBeenCalledWith(
'prompt.submit',
{
queued: true,
session_id: RUNTIME_SESSION_ID,
text: 'queued message'
},
@ -1590,6 +1641,7 @@ describe('usePromptActions submit / queue drain semantics', () => {
expect(requestGateway).toHaveBeenCalledWith(
'prompt.submit',
{
queued: true,
session_id: 'rt-session-a',
text: 'queued for background session'
},
@ -1646,6 +1698,7 @@ describe('usePromptActions submit / queue drain semantics', () => {
expect(requestGateway).toHaveBeenCalledWith(
'prompt.submit',
{
queued: true,
session_id: 'rt-session-a-rebound',
text: 'queued for background session'
},
@ -1696,6 +1749,7 @@ describe('usePromptActions submit / queue drain semantics', () => {
expect(requestGateway).toHaveBeenCalledWith(
'prompt.submit',
{
queued: true,
session_id: RUNTIME_SESSION_ID,
text: 'please send me'
},
@ -2473,11 +2527,13 @@ describe('usePromptActions sleep/wake session recovery', () => {
expect(ok).toBe(true)
expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit'])
expect(calls[0]?.params).toEqual({
queued: true,
session_id: 'rt-background-stale',
text: 'queued background message after wake'
})
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' })
expect(calls[2]?.params).toEqual({
queued: true,
session_id: RECOVERED_SESSION_ID,
text: 'queued background message after wake'
})
@ -3788,7 +3844,7 @@ describe('uploadComposerAttachment remote read failures', () => {
it('turns the raw 16MB IPC cap error into a friendly remote-gateway message', async () => {
// electron/hardening.ts rejects the readFileDataUrl IPC with this exact
// shape when a file exceeds DATA_URL_READ_MAX_BYTES.
// shape when a file exceeds the configured data-URL read cap.
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: {

View file

@ -1,3 +1,4 @@
import { skillInvocationText } from '@hermes/shared'
import { type MutableRefObject, useCallback, useRef } from 'react'
import { getProfiles } from '@/hermes'
@ -264,9 +265,12 @@ export function useSlashCommand(deps: SlashCommandDeps) {
return
}
if (dispatch.type === 'skill') {
renderSlashOutput(`⚡ loading skill: ${dispatch.name}`)
}
// A skill/bundle dispatch's `message` is the expanded skill body —
// model-facing scaffolding. Never render it; the bubble shows the
// invocation the gateway projected, or one read from the payload
// when the backend is older than this app.
const projected = 'display' in dispatch ? dispatch.display?.trim() : ''
const displayText = projected || skillInvocationText(message) || undefined
// Gate on the TARGET session's own busy state, not the foreground
// view's — see isTargetSessionBusy. `busyRef` mirrors whatever chat
@ -286,7 +290,7 @@ export function useSlashCommand(deps: SlashCommandDeps) {
// whichever chat is now in front.
const queueKey = resolveComposerSessionKey(storedSessionId, $sessions.get()) || storedSessionId || sessionId
if (enqueueQueuedPrompt(queueKey, { attachments: [], text: message })) {
if (enqueueQueuedPrompt(queueKey, { attachments: [], text: message, displayText })) {
renderSlashOutput('session busy — message queued to send when the current turn finishes')
} else {
renderSlashOutput('session busy — /interrupt the current turn before sending this command')
@ -299,12 +303,11 @@ export function useSlashCommand(deps: SlashCommandDeps) {
// same pair the output writer and the busy gate above already use.
// Bare `submitPromptText(message)` let submit re-resolve from
// `activeSessionIdRef`, which names the FOREGROUND chat: a `/work`
// typed into a fresh ⌘T tab loaded the skill in that tab, printed
// "⚡ loading skill" there, then fired its kickoff as a user message
// into whatever conversation was on screen. Every other target the
// dispatcher serves (tile, background queue drain, a session created
// by this very call) had the same leak.
await submitPromptText(message, { sessionId, storedSessionId })
// typed into a fresh ⌘T tab loaded the skill in that tab, then fired
// its kickoff as a user message into whatever conversation was on
// screen. Every other target the dispatcher serves (tile, background
// queue drain, a session created by this very call) had the same leak.
await submitPromptText(message, { sessionId, storedSessionId, displayText })
}
try {

View file

@ -268,10 +268,16 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
const optimisticId = `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
// What the bubble shows. A `/skill` send carries the whole expanded
// skill body as its text — model-facing scaffolding — so the dispatcher
// hands us the invocation to render instead. Everything else shows what
// was typed.
const bubbleText = options?.displayText ?? visibleText
const buildUserMessage = (): ChatMessage => ({
id: optimisticId,
role: 'user',
parts: [textPart(visibleText || (attachmentRefs.length ? '' : attachments.map(a => a.label).join(', ')))],
parts: [textPart(bubbleText || (attachmentRefs.length ? '' : attachments.map(a => a.label).join(', ')))],
attachmentRefs
})
@ -466,7 +472,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
if (!sessionId) {
try {
sessionId = await createBackendSessionForSend(visibleText)
sessionId = await createBackendSessionForSend(bubbleText)
} catch (err) {
dropOptimistic(null)
releaseBusy()
@ -543,7 +549,13 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
const submitParams = (targetId: string) => ({
session_id: targetId,
text,
...(interrupted && { interrupted })
...(interrupted && { interrupted }),
// A queue drain is a "run after" message, never a live-turn
// correction. The flag tells the gateway's busy path to hold it for
// the next turn untouched — without it, losing the settle race
// (client saw idle, server still unwinding) redirects or interrupts
// the live turn with text the user explicitly queued.
...(options?.fromQueue && { queued: true })
})
// On sleep/wake the gateway's in-memory session may have been cleared

View file

@ -168,7 +168,7 @@ export async function readFileDataUrlForAttach(filePath: string): Promise<string
}
// The readFileDataUrl IPC base64-loads the whole file into memory and is
// hard-capped (DATA_URL_READ_MAX_BYTES, 16 MB) in electron/hardening.ts, which
// hard-capped in the main process (default 16 MB; Settings → Chat), which
// rejects with a raw "file is too large (N bytes; limit M bytes)" string. In
// remote mode every attachment's bytes go through that read, so a big file
// surfaces that internal message verbatim in the failure toast. Translate it
@ -393,6 +393,11 @@ export interface SubmitTextOptions {
* (queue drain, steer, external submit requests): the check is a no-op
* without it. */
composerScope?: string | null
/** What the transcript shows for this send, when it differs from the text
* the agent receives. A `/skill` invocation expands into the whole skill
* body model-facing scaffolding the UI must never render so the slash
* dispatcher passes the invocation (`/work fix the leak`) here. */
displayText?: string
fromQueue?: boolean
/** Runtime session id to submit into. Queue drains pass this so a
* backgrounded/source session cannot be replaced by the current foreground

View file

@ -1253,6 +1253,71 @@ describe('branchStoredSession desktop source tagging', () => {
})
})
// ── Main/tile dedup (the "same session open in main AND its own tab" bug) ─────
// A session is EITHER the main thread OR a tile, never both. openSessionTile
// enforces this from the tile side; resumeSession enforces it from the main
// side by dropping an existing tile when the session loads into main (cold-start
// restore, a pasted/⌘K route, a notification jump), so it can't render twice.
describe('resumeSession drops a redundant tile when the session loads into main', () => {
afterEach(() => {
cleanup()
setActiveSessionId(null)
setResumeFailedSessionId(null)
setMessages([])
setSessions([])
$sessionTiles.set([])
vi.restoreAllMocks()
})
it('closes the tile so the session is not open in both main and its own tab', async () => {
// The session is already an open tile (e.g. persisted across a restart)...
$sessionTiles.set([{ storedSessionId: 'stored-1' }])
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === 'session.resume') {
return { session_id: 'runtime-1', resumed: params?.session_id, messages: [], info: {} } as never
}
return {} as never
})
vi.mocked(getSessionMessages).mockResolvedValue({ messages: [] } as never)
let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) | null = null
render(<ResumeHarness onReady={r => (resume = r)} requestGateway={requestGateway} />)
await waitFor(() => expect(resume).not.toBeNull())
// ...and now it loads into main.
await resume!('stored-1', true)
// Its tile is gone — main owns the session, so it renders exactly once.
expect($sessionTiles.get().some(t => t.storedSessionId === 'stored-1')).toBe(false)
expect($selectedStoredSessionId.get()).toBe('stored-1')
})
it('leaves OTHER sessions tiles untouched', async () => {
$sessionTiles.set([{ storedSessionId: 'stored-1' }, { storedSessionId: 'stored-2' }])
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === 'session.resume') {
return { session_id: 'runtime-1', resumed: params?.session_id, messages: [], info: {} } as never
}
return {} as never
})
vi.mocked(getSessionMessages).mockResolvedValue({ messages: [] } as never)
let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) | null = null
render(<ResumeHarness onReady={r => (resume = r)} requestGateway={requestGateway} />)
await waitFor(() => expect(resume).not.toBeNull())
await resume!('stored-1', true)
// Only the resumed session's tile closes; the sibling tile stays put.
expect($sessionTiles.get().map(t => t.storedSessionId)).toEqual(['stored-2'])
})
})
// ── Warm-cache mapping integrity (the "open chat A, chat B loads" bug) ─────────
// resumeSession's warm fast-path maps storedSessionId -> runtimeId -> cached
// state. A reaped/respawned pooled backend re-mints runtime ids, so a recycled

View file

@ -57,6 +57,7 @@ import {
setYoloActive
} from '@/store/session'
import {
$sessionTiles,
closeSessionTile,
dropSessionState,
openSessionTile,
@ -557,6 +558,18 @@ export function useSessionActions({
resetViewSync()
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
// A session is EITHER the main thread OR a tile — never both. openSessionTile
// enforces this from the tile side (it refuses to tile the selected session);
// this enforces it from the main side. Loading an existing session into main
// (cold-start restore, a pasted/⌘K route, a notification jump) while it's also
// an open tile would paint the same transcript twice — the workspace pane from
// the route and the tile pane in parallel, both fighting one runtime. Drop the
// now-redundant tile so main owns it. Runs before the async awaits below (and
// before the selection listener homes focus) so the tile is gone the same tick
// the route takes over; the warm cache/runtime binding survives for main to reuse.
if ($sessionTiles.get().some(t => t.storedSessionId === storedSessionId)) {
closeSessionTile(storedSessionId)
}
// Optimistically clear any prior resume-failure latch for this session:
// we're attempting a fresh resume, so the self-heal in use-route-resume
// must not keep treating it as stranded. It's re-armed below only if THIS

View file

@ -64,10 +64,11 @@ function ThemePreview({ name, mode }: { name: string; mode: 'light' | 'dark' })
)
}
// UI scale presets, as zoom percentages. 100 is the browser-default size;
// the ids double as the percent values sent to the main process. A Cmd/Ctrl
// +/- step landing between presets highlights nothing, and the row
// description keeps showing the exact current percent.
// UI scale presets, as zoom percentages. 100 is Chromium's actual-size
// baseline; the shipped default is the 90% preset. Ids double as the percent
// values sent to the main process. A Cmd/Ctrl +/- step landing between
// presets highlights nothing, and the row description keeps showing the
// exact current percent.
const UI_SCALE_PRESETS = ['90', '100', '110', '125', '150', '175'] as const
type UiScalePreset = (typeof UI_SCALE_PRESETS)[number]

View file

@ -5,8 +5,19 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { getElevenLabsVoices, getHermesConfigSchema, saveHermesConfig } from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import {
$dataUrlReadMaxMb,
clampDataUrlReadMaxMb,
DATA_URL_READ_DEFAULT_MAX_MB,
DATA_URL_READ_MAX_MAX_MB,
DATA_URL_READ_MIN_MAX_MB,
refreshDataUrlReadMaxMb,
setDataUrlReadMaxMb
} from '@/store/data-url-read-max'
import { $keepAwake, setKeepAwake } from '@/store/keep-awake'
import { notify, notifyError } from '@/store/notifications'
import { repoDiscoveryPolicyFromConfig, repoDiscoveryPolicySignature, scanAndRecordRepos } from '@/store/projects'
@ -21,7 +32,7 @@ import { enumOptionsFor, getNested, isExternalMemoryProvider, sectionFieldEntrie
import { MemoryConnect } from './memory/connect'
import { ProviderConfigPanel } from './memory/provider-config-panel'
import { ModelSettings, ModelSettingsSkeleton } from './model-settings'
import { EmptyState, SettingsContent, SettingsSkeleton, ToggleRow } from './primitives'
import { EmptyState, ListRow, SettingsContent, SettingsSkeleton, ToggleRow } from './primitives'
import { QuickEntrySettings } from './quick-entry-settings'
// On the Voice page, only surface the sub-fields of the *selected* TTS/STT
@ -305,9 +316,13 @@ export function ConfigSettings({
<QuickEntrySettings />
</>
)}
{visibleFields.length === 0 ? (
{/* Device-local attach/preview byte cap (main-process IPC guard). Chat is
where image-attachment behavior already lives, so this sits above the
schema fields for that section. */}
{activeSectionId === 'chat' ? <AttachmentSizeSetting /> : null}
{visibleFields.length === 0 && activeSectionId !== 'chat' ? (
<EmptyState description={c.emptyDesc} title={c.emptyTitle} />
) : (
) : visibleFields.length === 0 ? null : (
<div className="grid gap-1">
{visibleFields.map(([key, field]) => (
<div className="scroll-mt-6 rounded-lg" id={`setting-field-${key}`} key={key}>
@ -345,3 +360,73 @@ export function ConfigSettings({
</SettingsContent>
)
}
/** Free-form MB cap for Desktop's data-URL attach/preview path (main-process). */
function AttachmentSizeSetting() {
const { t } = useI18n()
const c = t.settings.config
const stored = useStore($dataUrlReadMaxMb)
const [draft, setDraft] = useState(String(stored))
useEffect(() => {
void refreshDataUrlReadMaxMb()
}, [])
useEffect(() => {
setDraft(String(stored))
}, [stored])
const commit = () => {
// An empty draft means "reset to the default", not the 1 MB floor
// (Number('') === 0 would otherwise clamp down to the floor).
const applied = draft.trim() === '' ? DATA_URL_READ_DEFAULT_MAX_MB : clampDataUrlReadMaxMb(draft)
// Unchanged: snap the draft back to the stored value and skip the
// pointless IPC write + haptic.
if (applied === stored) {
setDraft(String(stored))
return
}
void setDataUrlReadMaxMb(applied).then(next => {
setDraft(String(next))
// On a bridge write failure the store keeps the old value; only
// celebrate when the new cap actually landed.
if (next === applied) {
triggerHaptic('selection')
}
})
}
return (
<ListRow
action={
<div className="flex items-center gap-2">
<Input
aria-label={c.attachmentSizeLabel}
className="w-20"
inputMode="numeric"
max={DATA_URL_READ_MAX_MAX_MB}
min={DATA_URL_READ_MIN_MAX_MB}
onBlur={commit}
onChange={event => setDraft(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
event.currentTarget.blur()
}
}}
type="number"
value={draft}
/>
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{c.attachmentSizeUnit}
</span>
</div>
}
description={c.attachmentSizeDesc}
title={c.attachmentSizeTitle}
/>
)
}

View file

@ -178,7 +178,7 @@ describe('ModelMenuPanel provider collapse', () => {
})
})
it('auto-expands the active provider even when collapsed', async () => {
it('collapses the active provider too (no forced auto-expand)', async () => {
$currentProvider.set('deepseek')
$currentModel.set('deepseek-v4-pro')
const { content } = renderPanel()
@ -186,8 +186,11 @@ describe('ModelMenuPanel provider collapse', () => {
const header = await content.findByText('DeepSeek')
fireEvent.click(header)
// Should still show models because it's the active provider
expect(content.queryByText('Deepseek V4 Pro')).not.toBeNull()
// The current provider is collapsible like any other — clicking its header
// hides its models rather than forcing them to stay open.
await vi.waitFor(() => {
expect(content.queryByText('Deepseek V4 Pro')).toBeNull()
})
})
it('bypasses collapse when search is active', async () => {

View file

@ -4,6 +4,7 @@ import { createContext, useContext, useMemo, useState } from 'react'
import { useSessionView } from '@/app/chat/session-view'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import {
DropdownMenuGroup,
DropdownMenuItem,
@ -18,7 +19,6 @@ import {
import { Skeleton } from '@/components/ui/skeleton'
import type { HermesGateway } from '@/hermes'
import { useI18n } from '@/i18n'
import { ChevronDown, ChevronRight } from '@/lib/icons'
import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options'
import { currentPickerSelection, displayModelName, modelDisplayParts } from '@/lib/model-status-label'
import { DEFAULT_REASONING_EFFORT, reasoningEffortLabel } from '@/lib/reasoning-effort'
@ -244,25 +244,22 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re
{groups.map(group => {
const slug = group.provider.slug
// Collapsed when stored + no active search + not the current provider.
const collapsed = collapsedProviders.includes(slug) && !search && slug !== optionsProvider
// Collapsed when the user stored it (and not while searching, which
// spans every model regardless of collapse state).
const collapsed = collapsedProviders.includes(slug) && !search
return (
<DropdownMenuGroup className="py-0.5" key={slug}>
<DropdownMenuItem
className={cn(dropdownMenuSectionLabel, 'cursor-pointer hover:bg-(--ui-control-active-background)')}
className="group/label flex w-full items-center gap-1 px-2 pb-0.5 pt-0.5 text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary) cursor-pointer !bg-transparent focus:!bg-transparent"
onSelect={event => {
event.preventDefault()
toggleCollapsedProvider(slug)
}}
textValue=""
>
{collapsed ? (
<ChevronRight className="size-2.5 shrink-0" />
) : (
<ChevronDown className="size-2.5 shrink-0" />
)}
{group.provider.name}
<span className="truncate">{group.provider.name}</span>
<DisclosureCaret className="shrink-0 text-(--ui-text-tertiary) opacity-0 transition group-hover/label:opacity-100" open={!collapsed} size="0.625rem" />
</DropdownMenuItem>
{!collapsed &&
group.families.map(family => {

View file

@ -24,6 +24,7 @@ import { useI18n } from '@/i18n'
import { isDesktopToolsetVisible } from '@/lib/desktop-toolsets'
import { compactNumber } from '@/lib/format'
import { queryClient, writeCache } from '@/lib/query-client'
import { invalidateSlashCompletions } from '@/lib/slash-completion-cache'
import { normalize } from '@/lib/text'
import { $gateway } from '@/store/gateway'
import { notify, notifyError } from '@/store/notifications'
@ -222,6 +223,8 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
queryClient.invalidateQueries({ queryKey: TOOLSETS_QUERY_KEY })
])
invalidateSlashCompletions()
// An explicit refresh is the one time we bypass the analytics TTL — but
// only if the badges are already on screen; otherwise let the lazy load
// pick it up when Toolsets is first shown. Guard the async set against a
@ -336,6 +339,9 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
try {
await toggleSkill(skill.name, enabled)
// A disabled skill loses its `/name` command, so the composer's cached
// `/` list has to be dropped along with the row repaint.
invalidateSlashCompletions()
} catch (err) {
setSkills(
current => current?.map(row => (row.name === skill.name ? { ...row, enabled: !enabled } : row)) ?? current
@ -390,6 +396,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
} catch (err) {
notifyError(err, t.skills.failedToUpdate(mode === 'skills' ? t.skills.tabSkills : t.skills.tabToolsets))
} finally {
invalidateSlashCompletions()
setBulkBusy(false)
}
}
@ -674,6 +681,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
const snapshot = skills
setSkills(current => current?.filter(skill => skill.name !== name) ?? current)
invalidateSlashCompletions()
if (skillEditor?.name === name) {
setSkillEditor(null)

View file

@ -132,12 +132,17 @@ export interface SkillCommandDispatchResponse {
type: 'skill'
name: string
message?: string
/** The invocation the UI renders (`/work fix the leak`). `message` is the
* expanded skill body model-facing scaffolding no surface may show. */
display?: string
}
export interface SendCommandDispatchResponse {
type: 'send'
message: string
notice?: string
/** Set for a skill-bundle send: see SkillCommandDispatchResponse.display. */
display?: string
}
export interface PrefillCommandDispatchResponse {

View file

@ -1,118 +1,24 @@
import { useStore } from '@nanostores/react'
import { Leva, useControls } from 'leva'
import { type CSSProperties, useEffect, useState } from 'react'
import { $backdrop } from '@/store/backdrop'
const BLEND_MODES = [
'normal',
'multiply',
'screen',
'overlay',
'darken',
'lighten',
'color-dodge',
'color-burn',
'hard-light',
'soft-light',
'difference',
'exclusion',
'hue',
'saturation',
'color',
'luminosity'
] as const
type BlendMode = (typeof BLEND_MODES)[number]
const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}`
export function Backdrop() {
const [controlsOpen, setControlsOpen] = useState(false)
const on = useStore($backdrop)
useEffect(() => {
if (!import.meta.env.DEV) {
return
}
const onKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null
const editing =
target?.isContentEditable ||
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement
if (editing || event.repeat || event.altKey || event.ctrlKey || event.metaKey) {
return
}
if (event.shiftKey && event.code === 'KeyY') {
setControlsOpen(open => !open)
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
const shape = useControls(
'UI / Shape',
{ radiusScalar: { value: 0.2, min: 0, max: 2, step: 0.1, label: 'radius scalar' } },
{ collapsed: true }
)
useEffect(() => {
document.documentElement.style.setProperty('--radius-scalar', String(shape.radiusScalar))
}, [shape.radiusScalar])
const statue = useControls(
'Backdrop / Statue',
{
enabled: { value: true, label: 'on' },
opacity: { value: 0.025, min: 0, max: 1, step: 0.005 },
blendMode: { value: 'difference' as BlendMode, options: BLEND_MODES, label: 'blend' },
invert: { value: true, label: 'invert color' },
saturate: { value: 1, min: 0, max: 3, step: 0.05, label: 'saturate' },
brightness: { value: 1, min: 0, max: 2, step: 0.05, label: 'brightness' },
objectPosition: {
value: 'top left',
options: ['top left', 'top right', 'bottom left', 'bottom right', 'center', 'top', 'bottom', 'left', 'right'],
label: 'position'
},
scale: { value: 160, min: 100, max: 300, step: 5, label: 'height (dvh)' }
},
{ collapsed: true }
)
if (!on) {
return null
}
return (
<>
<Leva collapsed hidden={!import.meta.env.DEV || !controlsOpen} titleBar={{ title: 'backdrop', drag: true }} />
{on && statue.enabled && (
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-2"
style={{
mixBlendMode: statue.blendMode as CSSProperties['mixBlendMode'],
opacity: statue.opacity
}}
>
<img
alt=""
className="w-auto min-w-dvw object-cover"
fetchPriority="low"
src={assetPath('ds-assets/filler-bg0.jpg')}
style={{
height: `${statue.scale}dvh`,
objectPosition: statue.objectPosition,
filter: `invert(calc(${statue.invert ? 1 : 0} * var(--backdrop-invert-mul, 1))) saturate(${statue.saturate}) brightness(${statue.brightness})`
}}
/>
</div>
)}
</>
<div aria-hidden className="pointer-events-none absolute inset-0 z-2 opacity-[0.025] mix-blend-difference">
<img
alt=""
className="h-[160dvh] w-auto min-w-dvw object-cover object-left-top [filter:invert(var(--backdrop-invert-mul,1))]"
fetchPriority="low"
src={assetPath('ds-assets/filler-bg0.jpg')}
/>
</div>
)
}

View file

@ -74,8 +74,14 @@ describe('inline skill references', () => {
expect(skills('roughly 3 /4 of it')).toEqual([])
})
it('does not chip a leading slash — that is a command invocation, not prose', () => {
expect(skills('/clean')).toEqual([])
it('chips a leading slash, which now reaches the transcript as a skill invocation', () => {
// #71664 asserted the opposite, and was right at the time: a leading slash
// only ever EXECUTED, so it never reached a rendered message as text —
// the turn that reached the bubble was the expanded skill body. Projecting
// a skill turn back onto `/work fix it` changes that precondition, so the
// invocation now has to chip like any other skill reference.
expect(skills('/clean')).toEqual(['/clean'])
expect(skills('/work fix the leak')).toEqual(['/work'])
})
it('parses a skill chip alongside an @ reference', () => {

View file

@ -177,16 +177,22 @@ const HERMES_DIRECTIVE_RE = new RegExp(
'g'
)
// A skill referenced mid-prose (`clean this up with /clean`). The composer
// inserts it as a pill, so the sent message renders it as one too rather than
// flattening back to raw text. Only matches after whitespace — a leading `/`
// is a command invocation, which never reaches a rendered message as text.
// A skill referenced in a sent message — either the invocation that opens it
// (`/work fix the leak`, which is all a skill turn ever renders as) or one
// named mid-prose (`clean this up with /clean`). The composer inserts both as
// pills, so the sent message renders them as pills too rather than flattening
// back to raw text.
//
// #71664 deliberately excluded a LEADING slash, and was right then: a command
// only ever executed, so it never reached a rendered message as text. Skill
// turns now project back onto their invocation, so that precondition is gone
// and `^` joins the lookbehind.
//
// Unlike the composer's caret-anchored trigger, this scans finished text, so
// it must reject a token that continues into a path: `/usr/local/bin` would
// otherwise chip as `/usr`. `(?![\w-]*\/)` requires the token to end at
// something other than another slash.
const SLASH_SKILL_RE = /(?<=\s)\/([a-zA-Z][\w-]*)(?![\w-]*\/)/g
const SLASH_SKILL_RE = /(?<=^|\s)\/([a-zA-Z][\w-]*)(?![\w-]*\/)/g
const TRAILING_PUNCTUATION_RE = /[,.;!?]+$/
@ -483,10 +489,10 @@ const DirectiveImage: FC<{ id: string; label: string }> = ({ id, label }) => {
)
}
/** Opens the referenced session as a tab same as middle-clicking its sidebar
* row. The tile store loads on click, not at import: the composer's rich
* editor pulls this module in, and a static import would boot the profile
* store (and its REST routing) along with it. */
/** Opens the referenced session the way a sidebar -click would: jump to it if
* it's already a tile/main, otherwise open a stacked tab (never steals main
* from under the chat you're reading). Lazy-imports so the composer's rich
* editor can pull this module in without booting the profile/REST stack. */
function openSessionRef(value: string) {
const { sessionId } = parseSessionRefValue(value)
@ -495,7 +501,8 @@ function openSessionRef(value: string) {
}
triggerHaptic('selection')
void import('@/store/session-states').then(({ openSessionTile }) => openSessionTile(sessionId, 'center'))
// navigate is unused for the `tab` intent (focus-or-tile only).
void import('@/app/open-session').then(({ openSession }) => openSession(sessionId, () => undefined, 'tab'))
}
/** A `@session:<profile>/<id>` reference in the user transcript (directive

View file

@ -6,29 +6,28 @@ import { __resetSessionLinkTitleCache } from '@/lib/session-link-title'
import { DirectiveContent } from './directive-text'
import { MarkdownTextContent } from './markdown-text'
const openSessionTile = vi.fn()
const openSession = vi.fn()
vi.mock('@/store/session-states', async importOriginal => ({
...(await importOriginal<Record<string, unknown>>()),
openSessionTile: (...args: unknown[]) => openSessionTile(...args)
vi.mock('@/app/open-session', () => ({
openSession: (...args: unknown[]) => openSession(...args)
}))
afterEach(() => {
cleanup()
openSessionTile.mockClear()
openSession.mockClear()
__resetSessionLinkTitleCache()
})
// Both surfaces render a session ref differently — an inline link in agent
// prose, a chip in the user's own message — but either one opens the session
// it names, the way its sidebar row would.
// it names via the shared door (focus if on screen, else a stacked tab).
describe('session refs open the session', () => {
it('opens the session from an agent-written link', async () => {
render(<MarkdownTextContent isRunning={false} text="Picked up in @session:work/20260101_abc123 last night." />)
fireEvent.click(await screen.findByTitle('work/20260101_abc123'))
await vi.waitFor(() => expect(openSessionTile).toHaveBeenCalledWith('20260101_abc123', 'center'))
await vi.waitFor(() => expect(openSession).toHaveBeenCalledWith('20260101_abc123', expect.any(Function), 'tab'))
})
it('opens the session from a chip in the user transcript', async () => {
@ -39,6 +38,6 @@ describe('session refs open the session', () => {
expect(chip.tagName).toBe('BUTTON')
fireEvent.click(chip)
await vi.waitFor(() => expect(openSessionTile).toHaveBeenCalledWith('20260101_abc123', 'center'))
await vi.waitFor(() => expect(openSession).toHaveBeenCalledWith('20260101_abc123', expect.any(Function), 'tab'))
})
})

View file

@ -8,6 +8,7 @@ import { type ComponentProps, type FC, type ReactNode, useEffect, useRef, useSta
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/markdown-text'
import { DelegateTool } from '@/components/assistant-ui/tool/delegate'
import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool/fallback'
import { formatElapsed, useElapsedSeconds, useMeasuredDuration } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
@ -36,12 +37,26 @@ const ImageGenerateTool: FC<ToolCallMessagePartProps> = props => {
)
}
const DelegateToolPart: FC<ToolCallMessagePartProps> = props => {
// A call that failed outright dispatched nothing — there are no children to
// list, only an error. The generic row extracts and expands it properly.
if (props.isError) {
return <ToolFallback {...props} />
}
return <DelegateTool args={props.args} result={props.result} toolCallId={props.toolCallId} />
}
const ChainToolFallback: FC<ToolCallMessagePartProps> = props => {
// todo parts are hoisted to a dedicated panel above the message content.
if (props.toolName === 'todo') {
return null
}
if (props.toolName === 'delegate_task') {
return <DelegateToolPart {...props} />
}
if (props.toolName === 'image_generate') {
return <ImageGenerateTool {...props} />
}

View file

@ -30,6 +30,7 @@ import {
type InlineRefInput,
insertInlineRefsIntoEditor
} from '@/app/chat/composer/inline-refs'
import { chipTypedPathOnSpace, pathifyRefs } from '@/app/chat/composer/path-refs'
import {
composerPlainText,
insertComposerContentsAtCaret,
@ -543,7 +544,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
recordUndoPoint()
// Links land as `@url:` chips, same as the main composer.
insertComposerContentsAtCaret(event.currentTarget, linkifyUrls(pastedText))
insertComposerContentsAtCaret(event.currentTarget, pathifyRefs(linkifyUrls(pastedText)))
syncDraftFromEditor(event.currentTarget)
}
@ -685,6 +686,15 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
return
}
// Same for a bare `@path`.
if (withUndoPoint(() => chipTypedPathOnSpace(event))) {
event.preventDefault()
rememberInitialDraft()
syncDraftFromEditor(event.currentTarget)
return
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
submitEdit(event.currentTarget)

View file

@ -0,0 +1,133 @@
import { describe, expect, it } from 'vitest'
import type { SubagentProgress } from '@/store/subagents'
import { delegateGoals, delegateRowsFromCall, mergeDelegateRows } from './delegate-model'
const subagent = (overrides: Partial<SubagentProgress>): SubagentProgress => ({
filesRead: [],
filesWritten: [],
goal: 'Research Cursor',
id: 'sub-1',
parentId: null,
startedAt: 0,
status: 'running',
stream: [],
taskCount: 1,
taskIndex: 0,
updatedAt: 0,
...overrides
})
describe('delegateGoals', () => {
it('reads a batch in task order and a single goal alike', () => {
expect(delegateGoals({ tasks: [{ goal: 'A' }, { goal: 'B' }] })).toEqual(['A', 'B'])
expect(delegateGoals({ goal: 'Solo' })).toEqual(['Solo'])
expect(delegateGoals('{"goal":"Serialized"}')).toEqual(['Serialized'])
})
})
describe('delegateRowsFromCall', () => {
it('reads as running before a result and parked once dispatched', () => {
const args = { tasks: [{ goal: 'A' }, { goal: 'B' }] }
expect(delegateRowsFromCall(args, undefined).map(r => r.status)).toEqual(['running', 'running'])
expect(delegateRowsFromCall(args, { status: 'dispatched', goals: ['A', 'B'] }).map(r => r.status)).toEqual([
'dispatched',
'dispatched'
])
})
it('takes status, model and duration from each settled result', () => {
const rows = delegateRowsFromCall(
{ tasks: [{ goal: 'A' }, { goal: 'B' }] },
{
results: [
{ status: 'completed', summary: 'found it', model: 'anthropic/claude-opus-5', duration_seconds: 12 },
{ status: 'failed', summary: 'nope' }
]
}
)
expect(rows.map(r => r.status)).toEqual(['completed', 'failed'])
expect(rows[0]).toMatchObject({ activity: ['found it'], durationSeconds: 12, model: 'anthropic/claude-opus-5' })
})
it('still lists a background dispatch whose goals only survive in the result', () => {
expect(delegateRowsFromCall({}, { status: 'dispatched', goals: ['A', 'B'] }).map(r => r.goal)).toEqual(['A', 'B'])
})
})
describe('mergeDelegateRows', () => {
it('joins fallback rows by the tool call id they were keyed with', () => {
const rows = delegateRowsFromCall({ tasks: [{ goal: 'A' }, { goal: 'B' }] }, undefined, 'call-7')
const merged = mergeDelegateRows(
rows,
[
subagent({ id: 'delegate-tool:call-7:1', goal: 'B', status: 'completed' }),
subagent({ id: 'delegate-tool:call-7:0', goal: 'A', model: 'gpt-5' })
],
'call-7'
)
expect(merged.map(r => r.status)).toEqual(['running', 'completed'])
expect(merged[0]!.model).toBe('gpt-5')
})
it('joins native events by goal text and prefers their live state', () => {
const rows = delegateRowsFromCall({ tasks: [{ goal: 'Research Cursor' }] }, undefined, 'call-1')
const merged = mergeDelegateRows(
rows,
[
subagent({
goal: 'Research Cursor',
model: 'anthropic/claude-opus-5',
sessionId: 'child-1',
stream: [
{ at: 1, kind: 'tool', text: 'Read File("a.ts")' },
{ at: 2, kind: 'progress', text: 'comparing' }
]
})
],
'call-1'
)
expect(merged[0]).toMatchObject({
activity: ['Read File("a.ts")', 'comparing'],
model: 'anthropic/claude-opus-5',
sessionId: 'child-1',
status: 'running'
})
})
it('never lets a second delegation claim another call\u2019s workers', () => {
const rows = delegateRowsFromCall({ tasks: [{ goal: 'C' }] }, undefined, 'call-2')
// Two unrelated children in the session, neither matching this call's goal.
const merged = mergeDelegateRows(
rows,
[subagent({ id: 'other-a', goal: 'A' }), subagent({ id: 'other-b', goal: 'B' })],
'call-2'
)
expect(merged[0]!.goal).toBe('C')
expect(merged[0]!.model).toBeUndefined()
})
it('falls back to task order only when both sides agree on the shape', () => {
const rows = delegateRowsFromCall({ tasks: [{ goal: 'A' }, { goal: 'B' }] }, undefined, 'call-3')
const merged = mergeDelegateRows(
rows,
[
subagent({ id: 'x', goal: 'renamed A', taskIndex: 0, model: 'm0' }),
subagent({ id: 'y', goal: 'renamed B', taskIndex: 1, model: 'm1' })
],
'call-3'
)
expect(merged.map(r => r.model)).toEqual(['m0', 'm1'])
})
})

View file

@ -0,0 +1,150 @@
import { normalize } from '@/lib/text'
import type { SubagentProgress, SubagentStatus } from '@/store/subagents'
import { firstStringField, numberValue, parseMaybeObject } from './fallback-model'
/**
* A delegation runs somewhere the transcript can't see: the tool call carries
* the goals it dispatched, the subagent store carries what those children are
* actually doing, and the tool result carries how they finished. One row is
* all three of those views of the same child.
*/
export interface DelegateRow {
/** Latest relayed activity, oldest → newest. The card tickers the tail. */
activity: string[]
durationSeconds?: number
goal: string
id: string
model?: string
/** The child's own session id, when it reported one — opens its window. */
sessionId?: string
status: DelegateRowStatus
}
/**
* `dispatched` is the state the other two sources can't describe: a background
* delegation whose children outlived the turn, seen from a transcript that has
* been reloaded since. It is running, but nothing here is watching it, so it
* must not spin.
*/
export type DelegateRowStatus = SubagentStatus | 'dispatched'
const field = (record: Record<string, unknown>, key: string): string => firstStringField(record, [key])
/** The goals a `delegate_task` call dispatched, in task order. */
export function delegateGoals(args: unknown): string[] {
const record = parseMaybeObject(args)
const tasks = Array.isArray(record.tasks) ? record.tasks : []
if (tasks.length > 0) {
return tasks.map((task, index) => field(parseMaybeObject(task), 'goal') || `Task ${index + 1}`)
}
const goal = field(record, 'goal')
return goal ? [goal] : []
}
function resultRows(result: unknown): Record<string, unknown>[] {
const record = parseMaybeObject(result)
const results = Array.isArray(record.results) ? record.results : []
return results.map(parseMaybeObject)
}
function dispatchedGoals(result: unknown): string[] {
const record = parseMaybeObject(result)
if (field(record, 'status') !== 'dispatched') {
return []
}
return Array.isArray(record.goals) ? record.goals.filter((goal): goal is string => typeof goal === 'string') : []
}
/**
* The rows a call describes on its own before any live subagent state is
* layered on. This is what a rehydrated transcript has to work with: the goals
* it dispatched, and whatever the result said about how they went.
*
* A call with no result yet is still being placed, so its rows read as
* running; the moment a background dispatch answers, they drop to parked.
*/
export function delegateRowsFromCall(args: unknown, result: unknown, toolCallId = ''): DelegateRow[] {
const goals = delegateGoals(args)
const finished = resultRows(result)
const dispatched = dispatchedGoals(result)
const titles = goals.length > 0 ? goals : dispatched.length > 0 ? dispatched : finished.map(() => 'Delegated task')
const idle: DelegateRowStatus = result === undefined ? 'running' : 'dispatched'
return titles.map((goal, index) => {
const entry = finished[index]
const summary = entry ? field(entry, 'summary') : ''
return {
activity: summary ? [summary] : [],
durationSeconds: entry ? (numberValue(entry.duration_seconds) ?? undefined) : undefined,
goal,
id: `${toolCallId}:${index}`,
model: entry ? field(entry, 'model') || undefined : undefined,
status: entry ? (field(entry, 'status') === 'failed' ? 'failed' : 'completed') : idle
}
})
}
function fromSubagent(live: SubagentProgress, fallbackId: string, fallbackGoal: string): DelegateRow {
return {
activity: live.stream.map(entry => entry.text).filter(Boolean),
durationSeconds: live.durationSeconds,
goal: live.goal || fallbackGoal,
id: live.id || fallbackId,
model: live.model,
sessionId: live.sessionId,
status: live.status
}
}
/**
* Layer the session's live subagents over the rows a call describes.
*
* Three joins, narrowest first. The delegate fallback (used when the gateway
* relays no native `subagent.*` events) keys its rows off the tool call id, so
* those match exactly. Native events carry no tool linkage, but they do carry
* the goal string verbatim from the same arguments this call was built from.
* Failing both, task order is how the delegate tool numbers its children but
* only trust it when the two sides agree on how many there are, or a second
* delegation in the same turn will claim the first one's workers.
*
* Live state wins wherever it exists: a settled result tells you a child
* finished, but only the store knows what it is doing right now.
*/
export function mergeDelegateRows(
rows: readonly DelegateRow[],
live: readonly SubagentProgress[],
toolCallId = ''
): DelegateRow[] {
if (live.length === 0) {
return [...rows]
}
const unclaimed = [...live]
const claim = (predicate: (candidate: SubagentProgress) => boolean): SubagentProgress | undefined => {
const index = unclaimed.findIndex(predicate)
return index >= 0 ? unclaimed.splice(index, 1)[0] : undefined
}
const prefix = toolCallId ? `delegate-tool:${toolCallId}:` : ''
const byId = rows.map((_row, index) => (prefix ? claim(c => c.id === `${prefix}${index}`) : undefined))
const byGoal = rows.map((row, index) => byId[index] ?? claim(c => normalize(c.goal) === normalize(row.goal)))
const sameShape = rows.length === live.length
return rows.map((row, index) => {
const matched = byGoal[index] ?? (sameShape ? claim(c => c.taskIndex === index) : undefined)
return matched ? fromSubagent(matched, row.id, row.goal) : row
})
}
export const isDelegateRowLive = (status: DelegateRowStatus): boolean => status === 'running' || status === 'queued'

View file

@ -0,0 +1,158 @@
'use client'
import { useStore } from '@nanostores/react'
import { type FC, type ReactNode, useMemo } from 'react'
import { useSessionView } from '@/app/chat/session-view'
import { useElapsedSeconds } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
import { SCAFFOLD_LABEL_CLASS, SCAFFOLD_META_CLASS } from '@/components/chat/scaffold-row'
import { FadeText } from '@/components/ui/fade-text'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { useI18n } from '@/i18n'
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
import { displayModelName } from '@/lib/model-status-label'
import { useSessionSlice } from '@/lib/use-session-slice'
import { cn } from '@/lib/utils'
import { $subagentsBySession } from '@/store/subagents'
import { openSessionInNewWindow } from '@/store/windows'
import {
type DelegateRow,
delegateRowsFromCall,
type DelegateRowStatus,
isDelegateRowLive,
mergeDelegateRows
} from './delegate-model'
import { formatDurationSeconds, type ToolPart } from './fallback-model'
import { ToolRunTicker } from './run-ticker'
// Activity lines kept mounted behind the visible one. Enough for the reel to
// read as motion, few enough that a chatty child doesn't hold a hundred rows
// in the DOM per subagent.
const TICKER_DEPTH = 6
function statusGlyph(status: DelegateRowStatus, label: string): ReactNode {
if (isDelegateRowLive(status)) {
return (
<GlyphSpinner ariaLabel={label} className="size-3.5 text-[0.95rem] text-(--ui-text-tertiary)" spinner="breathe" />
)
}
if (status === 'failed' || status === 'interrupted') {
return <AlertCircle aria-label={label} className="size-3.5 text-destructive" />
}
if (status === 'dispatched') {
// Parked, not watched: the children outlived the turn that spawned them
// and nothing in this transcript is streaming their progress. A spinner
// here would claim a liveness we can't back up.
return <span aria-hidden className="size-1.5 rounded-full bg-(--ui-text-tertiary)" />
}
return <CheckCircle2 aria-label={label} className="size-3.5 text-emerald-600/85 dark:text-emerald-400/85" />
}
/**
* One delegated child: who it is on the first line, what it is doing on the
* second.
*
* The title carries the goal and the model running it the two things that
* identify a child you didn't dispatch yourself with the elapsed time
* trailing while it works. Underneath, a single ticking line of its relayed
* activity, so a fan-out of five children costs ten lines of transcript
* whatever they get up to.
*/
function DelegateRowView({ row }: { row: DelegateRow }) {
const { t } = useI18n()
const copy = t.assistant.tool
const { sessionId } = row
const live = isDelegateRowLive(row.status)
const elapsed = useElapsedSeconds(live, `delegate:${row.id}`)
const activity = row.activity.slice(-TICKER_DEPTH)
const statusLabel = live
? copy.statusRunning
: row.status === 'failed' || row.status === 'interrupted'
? copy.statusError
: copy.statusDone
const meta = [
row.model ? displayModelName(row.model) : '',
!live && row.durationSeconds ? formatDurationSeconds(row.durationSeconds) : ''
].filter(Boolean)
// Only a child that reported its own session id has somewhere to go.
const open = sessionId ? () => void openSessionInNewWindow(sessionId, { watch: true }) : undefined
return (
<div className="grid min-w-0 max-w-full gap-0.5" data-conversation-scaffold="">
<div className="flex min-w-0 max-w-full items-center gap-1.5">
<span className="grid size-3.5 shrink-0 place-items-center">{statusGlyph(row.status, statusLabel)}</span>
<button
className={cn(
SCAFFOLD_LABEL_CLASS,
'min-w-0 truncate text-left transition-colors',
open ? 'hover:text-foreground focus-visible:text-foreground focus-visible:outline-none' : 'cursor-default'
)}
disabled={!open}
onClick={open}
type="button"
>
{row.goal}
</button>
{meta.length > 0 && <span className={SCAFFOLD_META_CLASS}>{meta.join(' · ')}</span>}
{live && <ActivityTimerText className={cn(SCAFFOLD_META_CLASS, 'ml-auto')} seconds={elapsed} />}
</div>
{activity.length > 0 && (
<div className="min-w-0 max-w-full pl-5">
<ToolRunTicker>
{activity.map((text, index) => (
<FadeText
className={cn(SCAFFOLD_LABEL_CLASS, 'text-(--conversation-scaffold-meta)', live && 'shimmer')}
key={`${row.id}:${index}`}
>
{text}
</FadeText>
))}
</ToolRunTicker>
</div>
)}
</div>
)
}
/**
* A `delegate_task` call, as the fan-out it is.
*
* The generic tool row can only say "Delegated 2 tasks" and hand over a blob
* of JSON the work itself happens in child sessions the transcript never
* sees. This lists those children instead, joining what the call dispatched to
* what the subagent store knows about them, so a delegation reads like the
* several agents it actually is.
*
* A card, never folded into a run summary: the point of the block is the live
* list, and a ticker cycling one line across five children would show four of
* them nothing.
*/
export const DelegateTool: FC<Pick<ToolPart, 'args' | 'result' | 'toolCallId'>> = ({ args, result, toolCallId }) => {
const sessionId = useStore(useSessionView().$runtimeId)
const live = useSessionSlice($subagentsBySession, sessionId)
const rows = useMemo(
() => mergeDelegateRows(delegateRowsFromCall(args, result, toolCallId), live, toolCallId),
[args, live, result, toolCallId]
)
if (rows.length === 0) {
return null
}
return (
<div className="grid min-w-0 gap-(--tool-row-gap)" data-delegate-card="" data-slot="tool-block">
{rows.map(row => (
<DelegateRowView key={row.id} row={row} />
))}
</div>
)
}

View file

@ -5,10 +5,8 @@ import { useStore } from '@nanostores/react'
import {
Children,
createContext,
type CSSProperties,
type FC,
Fragment,
isValidElement,
type PropsWithChildren,
type ReactNode,
useContext,
@ -68,6 +66,7 @@ import {
type ToolTitleAction
} from './fallback-model'
import { isToolCallPart, summarizeToolRun } from './run-summary'
import { ToolRunTicker } from './run-ticker'
// `true` when a ToolEntry is rendered inside an embedding wrapper that owns
// the per-row chrome (timer / preview). The flat ToolGroupSlot sets this
@ -708,12 +707,13 @@ function TerminalTranscript({ command, exitCode }: TerminalTranscriptProps) {
// - File edits are the deliverable, not scaffolding. The diff is what the
// user reviews, so it stays visible at its place in the turn, live and
// settled, the way a PR shows its changes.
// - `clarify` and `image_generate` bypass ToolEntry to render their own
// markup: a question the user has to answer, an image they asked for.
// - `clarify`, `image_generate` and `delegate_task` bypass ToolEntry to
// render their own markup: a question the user has to answer, an image
// they asked for, the several agents a fan-out is running.
//
// Everything else is ephemeral activity — reads, searches, commands — which is
// what a run summarizes and what the live ticker cycles through.
const CARD_TOOLS = new Set(['clarify', 'image_generate'])
const CARD_TOOLS = new Set(['clarify', 'delegate_task', 'image_generate'])
export function isCardTool(toolName: string): boolean {
return CARD_TOOLS.has(toolName) || isFileEditTool(toolName)
@ -761,26 +761,7 @@ export function splitRunItems(toolNames: readonly string[]): RunItem[] {
* touches thirty files reads as one line ticking over in place instead of a
* list growing down the page. When the run settles the ticker goes away and
* the summary above it is all that's left.
*
* Rows are clipped to a uniform line box so the reel's offset stays exact
* whatever a row happens to contain.
*/
function ToolRunTicker({ children }: { children: ReactNode }) {
const rows = Children.toArray(children)
return (
<div className="tool-ticker" data-tool-ticker="">
<div className="tool-ticker__reel" style={{ '--tool-ticker-index': rows.length - 1 } as CSSProperties}>
{rows.map((row, index) => (
<div className="tool-ticker__row" key={isValidElement(row) ? (row.key ?? index) : index}>
{row}
</div>
))}
</div>
</div>
)
}
// The one grey line that stands in for a run of tool calls — "Explored 3
// files, ran 5 commands". Live, it narrates in the present tense above the
// ticker and offers no toggle, since there is nothing settled to unfold yet.

View file

@ -0,0 +1,28 @@
import { Children, type CSSProperties, isValidElement, type ReactNode } from 'react'
/**
* A one-line window over a growing list of rows.
*
* Each new row slides the one before it up and out, so activity that would
* otherwise grow down the page reads as a single line ticking over in place.
* Rows are clipped to a uniform line box so the reel's offset stays exact
* whatever a row happens to contain.
*
* Shared by the tool run (its calls) and a delegation card (each subagent's
* relayed stream), which are the same thing seen from two sides.
*/
export function ToolRunTicker({ children }: { children: ReactNode }) {
const rows = Children.toArray(children)
return (
<div className="tool-ticker" data-tool-ticker="">
<div className="tool-ticker__reel" style={{ '--tool-ticker-index': rows.length - 1 } as CSSProperties}>
{rows.map((row, index) => (
<div className="tool-ticker__row" key={isValidElement(row) ? (row.key ?? index) : index}>
{row}
</div>
))}
</div>
</div>
)
}

View file

@ -55,7 +55,7 @@ export function DisclosureRow({
'flex h-(--conversation-line-height) shrink-0 items-center justify-center transition-opacity duration-150',
open
? 'opacity-80'
: 'opacity-0 group-hover/disclosure-row:opacity-80 group-focus-within/disclosure-row:opacity-80'
: 'opacity-(--disclosure-caret-rest) group-hover/disclosure-row:opacity-80 group-focus-within/disclosure-row:opacity-80'
)}
>
<DisclosureCaret open={open} />

View file

@ -3,11 +3,14 @@ import { useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { Switch } from '@/components/ui/switch'
import type { HermesGateway } from '@/hermes'
import { useI18n } from '@/i18n'
import { Search } from '@/lib/icons'
import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options'
import { displayModelName, modelDisplayParts } from '@/lib/model-status-label'
import { normalize } from '@/lib/text'
@ -16,9 +19,11 @@ import {
collapseModelFamilies,
effectiveVisibleKeys,
modelVisibilityKey,
setProviderVisibility,
setVisibleModels,
toggleModelVisibility
} from '@/store/model-visibility'
import { $collapsedProviders, toggleCollapsedProvider } from '@/store/provider-collapse'
import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
interface ModelVisibilityDialogProps {
@ -42,6 +47,7 @@ export function ModelVisibilityDialog({
const copy = t.modelVisibility
const [search, setSearch] = useState('')
const stored = useStore($visibleModels)
const collapsedProviders = useStore($collapsedProviders)
const modelOptions = useQuery({
queryKey: modelOptionsQueryKey(profile, sessionId),
@ -60,6 +66,10 @@ export function ModelVisibilityDialog({
setVisibleModels(toggleModelVisibility($visibleModels.get(), providers, provider.slug, model))
}
const toggleProvider = (provider: ModelOptionProvider, next: boolean) => {
setVisibleModels(setProviderVisibility($visibleModels.get(), providers, provider.slug, next))
}
const q = normalize(search)
const matches = (provider: ModelOptionProvider, model: string) =>
@ -72,7 +82,8 @@ export function ModelVisibilityDialog({
<DialogTitle className="text-[0.8125rem]">{copy.title}</DialogTitle>
</DialogHeader>
<div className="px-3 py-1.5">
<div className="flex items-center gap-1.5 px-3 py-1.5">
<Search className="pointer-events-none size-3.5 shrink-0 text-muted-foreground/70" />
<input
autoFocus
className="h-5 w-full bg-transparent text-xs text-foreground placeholder:text-(--ui-text-tertiary) focus:outline-none"
@ -96,28 +107,49 @@ export function ModelVisibilityDialog({
return null
}
const allFamilies = collapseModelFamilies(provider.models ?? [])
const onCount = allFamilies.filter(family =>
visible.has(modelVisibilityKey(provider.slug, family.id))
).length
const checkState = onCount === 0 ? false : onCount === allFamilies.length ? true : 'indeterminate'
const collapsed = collapsedProviders.includes(provider.slug) && !q
return (
<div className="py-0.5" key={provider.slug}>
<div className="px-3 pb-0.5 pt-1 text-[0.625rem] font-medium uppercase tracking-wide text-(--ui-text-tertiary)">
{provider.name}
<div className="flex items-center gap-2 px-3 pb-0.5 pt-1">
<button
className="group/label flex w-full items-center gap-1 pb-0.5 pt-0.5 text-left text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary) hover:bg-transparent"
onClick={() => toggleCollapsedProvider(provider.slug)}
type="button"
>
<span className="min-w-0 truncate">{provider.name}</span>
<DisclosureCaret
className="shrink-0 opacity-0 transition group-hover/label:opacity-100"
open={!collapsed}
size="0.625rem"
/>
</button>
<Checkbox checked={checkState} onCheckedChange={next => toggleProvider(provider, next !== false)} />
</div>
{models.map(family => {
const { name, tag } = modelDisplayParts(family.id)
const key = modelVisibilityKey(provider.slug, family.id)
{!collapsed &&
models.map(family => {
const { name, tag } = modelDisplayParts(family.id)
const key = modelVisibilityKey(provider.slug, family.id)
return (
<label
className="flex cursor-pointer items-center gap-2 px-3 py-1 text-xs hover:bg-accent/50"
key={key}
>
<span className="min-w-0 flex-1 truncate">
{name}
{tag ? <span className="text-(--ui-text-tertiary)"> {tag}</span> : null}
</span>
<Switch checked={visible.has(key)} onCheckedChange={() => toggle(provider, family.id)} />
</label>
)
})}
return (
<label
className="flex cursor-pointer items-center gap-2 px-3 py-1 text-xs"
key={key}
>
<span className="min-w-0 flex-1 truncate">
{name}
{tag ? <span className="text-(--ui-text-tertiary)"> {tag}</span> : null}
</span>
<Switch checked={visible.has(key)} onCheckedChange={() => toggle(provider, family.id)} size="xs" />
</label>
)
})}
</div>
)
})

View file

@ -28,6 +28,20 @@ export const PaneVisibleContext = createContext(true)
export const usePaneVisible = (): boolean => useContext(PaneVisibleContext)
/** Fallback group key for a surface rendered outside the layout tree (secondary
* windows, plain routes) one bucket, since there are no sibling zones there
* to tell apart. */
export const NO_PANE_GROUP = 'window'
/** The layout-tree GROUP (zone) a pane is rendered in the identity of "this
* set of tabs". Panes stacked as tabs share one group; each split zone is its
* own. State that should be per-zone rather than per-window or per-tab keys off
* this (see the composer pop-out). Follows a pane dragged between zones,
* because the provider is the zone that renders it. */
export const PaneGroupContext = createContext(NO_PANE_GROUP)
export const usePaneGroup = (): string => useContext(PaneGroupContext)
/** `querySelectorAll` minus anything inside an inactive tab. */
export const queryAllVisible = <T extends HTMLElement>(selector: string, root: ParentNode = document): T[] =>
[...root.querySelectorAll<T>(selector)].filter(el => !el.closest(HIDDEN_PANE))

View file

@ -0,0 +1,98 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// A `placement: 'floating'` pane must never enter the layout tree. Adoption is
// what turns a contribution into a track (and therefore into something that
// steals width from a zone), so this exercises the real `adoptContributedPanes`
// path via `watchContributedPanes` rather than asserting on the filter itself.
describe('floating panes stay out of the layout tree', () => {
beforeEach(() => {
window.localStorage.clear()
vi.resetModules()
})
afterEach(() => {
vi.resetModules()
})
async function setup() {
const tree = await import('@/components/pane-shell/tree/store')
const model = await import('@/components/pane-shell/tree/model')
const { registry } = await import('@/contrib/registry')
registry.register({
id: 'workspace',
area: 'panes',
title: 'chat',
data: { placement: 'main' },
render: () => null
})
tree.declareDefaultTree(model.group(['workspace'], { id: 'grp-main' }))
return { model, registry, tree }
}
it('does not adopt a floating pane, while still adopting a docked one', async () => {
const { model, registry, tree } = await setup()
registry.register({
id: 'hud',
area: 'panes',
title: 'HUD',
data: { placement: 'floating', anchor: 'top-right', width: '240px' },
render: () => null
})
registry.register({
id: 'files',
area: 'panes',
title: 'Files',
data: { placement: 'right' },
render: () => null
})
tree.watchContributedPanes()
const ids = model.allPaneIds(tree.$layoutTree.get()!)
expect(ids).not.toContain('hud')
// Control: a normal placement DOES get adopted through the same pass, so
// the exclusion is specific to 'floating', not a broken adoption run.
expect(ids).toContain('files')
})
it('keeps the main zone unsplit when only a floating pane registers', async () => {
const { model, registry, tree } = await setup()
registry.register({
id: 'hud',
area: 'panes',
title: 'HUD',
data: { placement: 'floating' },
render: () => null
})
tree.watchContributedPanes()
const root = tree.$layoutTree.get()!
// Still the single group it was declared as — no track was created.
expect(root.type).toBe('group')
expect(model.allPaneIds(root)).toEqual(['workspace'])
})
it('survives a registry change without ever adopting the floating pane', async () => {
const { model, registry, tree } = await setup()
registry.register({ id: 'hud', area: 'panes', data: { placement: 'floating' }, render: () => null })
tree.watchContributedPanes()
// A later registration re-runs adoption via the registry subscription.
registry.register({ id: 'terminal', area: 'panes', data: { placement: 'bottom' }, render: () => null })
const ids = model.allPaneIds(tree.$layoutTree.get()!)
expect(ids).toContain('terminal')
expect(ids).not.toContain('hud')
})
})

View file

@ -0,0 +1,83 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// ⌘W / ⌘T / ⌘⇧T / the strip "+" used to hardcode the workspace's zone while
// ⌘1…⌘9 followed the interacted zone — so every tab verb missed a SECOND chat
// zone the user was working in. They now resolve the same focused zone.
describe('focused chat zone drives the tab verbs', () => {
beforeEach(() => {
window.localStorage.clear()
vi.resetModules()
})
afterEach(() => {
vi.resetModules()
})
async function setup() {
const tree = await import('@/components/pane-shell/tree/store')
const model = await import('@/components/pane-shell/tree/model')
const { registry } = await import('@/contrib/registry')
for (const id of ['workspace', 'files', 'session-tile:a', 'session-tile:b']) {
registry.register({
area: 'panes',
data: id === 'workspace' ? { placement: 'main', uncloseable: true } : { placement: 'main' },
id,
render: () => null,
title: id
})
}
// Two chat zones side by side: main holds the workspace, the second holds
// its own session tabs (a split-off stack).
tree.declareDefaultTree(
model.split('row', [
model.group(['workspace'], { active: 'workspace', id: 'grp-main' }),
model.group(['session-tile:a', 'session-tile:b'], { active: 'session-tile:b', id: 'grp-side' })
])
)
return { model, tree }
}
it('⌘T anchors the new tab to the focused zone, not always the workspace', async () => {
const { tree } = await setup()
tree.noteActiveTreeGroup('grp-side')
expect(tree.focusedSessionTabAnchor()).toBe('session-tile:b')
tree.noteActiveTreeGroup('grp-main')
expect(tree.focusedSessionTabAnchor()).toBe('workspace')
})
it('a non-chat zone (files/terminal focus) falls back to the workspace', async () => {
const { model, tree } = await setup()
tree.declareDefaultTree(
model.split('row', [
model.group(['workspace'], { active: 'workspace', id: 'grp-main' }),
model.group(['files'], { active: 'files', id: 'grp-files' })
])
)
tree.noteActiveTreeGroup('grp-files')
expect(tree.focusedSessionTabAnchor()).toBe('workspace')
// ⌘W must not close the file tree just because it holds focus.
expect(tree.closeFocusedSessionTab()).toBe(false)
})
it('⌘W closes the focused zone active tab and leaves the workspace alone', async () => {
const { model, tree } = await setup()
tree.noteActiveTreeGroup('grp-side')
expect(tree.closeFocusedSessionTab()).toBe(true)
expect(model.allPaneIds(tree.$layoutTree.get()!)).not.toContain('session-tile:b')
// Back on main: the uncloseable workspace is a no-op (the caller promotes
// a stacked session instead of closing the window).
tree.noteActiveTreeGroup('grp-main')
expect(tree.closeFocusedSessionTab()).toBe(false)
expect(model.allPaneIds(tree.$layoutTree.get()!)).toContain('workspace')
})
})

View file

@ -0,0 +1,203 @@
/**
* Live-behavior test for FloatingPanes: mounts the REAL component into a real
* DOM and drives real pointer/resize events. This is the closest thing to
* "running it" without an Electron main process it exercises the rendered
* element's computed geometry, not the pure geometry module (that's
* floating-rect.test.ts).
*/
import { act, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { registry } from '@/contrib/registry'
import { FloatingPanes } from './floating-panes'
let root: null | Root = null
let container: HTMLDivElement | null = null
let disposers: (() => void)[] = []
function render(ui: ReactNode) {
container = document.createElement('div')
document.body.append(container)
root = createRoot(container)
act(() => {
root!.render(ui)
})
}
const card = () => document.querySelector<HTMLElement>('[data-floating-pane="hud"]')
const grab = () => card()!.querySelector('header')!
/** jsdom has no real pointer events; PointerEvent falls back to MouseEvent. */
function pointer(target: Element, type: string, x: number, y: number) {
const event = new MouseEvent(type, { bubbles: true, clientX: x, clientY: y })
Object.defineProperty(event, 'pointerId', { value: 1 })
act(() => {
target.dispatchEvent(event)
})
}
function resizeWindow(width: number, height: number) {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: width, writable: true })
Object.defineProperty(window, 'innerHeight', { configurable: true, value: height, writable: true })
act(() => {
window.dispatchEvent(new Event('resize'))
})
}
function registerHud(data: Record<string, unknown>) {
disposers.push(
registry.register({
area: 'panes',
data,
id: 'hud',
render: () => <p data-testid="hud-body">live</p>,
title: 'HUD'
})
)
}
describe('FloatingPanes (live DOM)', () => {
beforeEach(() => {
window.localStorage.clear()
resizeWindow(1440, 900)
// setPointerCapture / releasePointerCapture don't exist in jsdom.
Element.prototype.setPointerCapture = vi.fn()
Element.prototype.releasePointerCapture = vi.fn()
})
afterEach(() => {
if (root) {
act(() => root!.unmount())
}
container?.remove()
root = null
container = null
disposers.forEach(dispose => dispose())
disposers = []
})
it('mounts a fixed card in the anchored corner with the pane body inside', () => {
registerHud({ anchor: 'top-right', height: '132px', placement: 'floating', width: '224px' })
render(<FloatingPanes />)
const el = card()!
expect(el).toBeTruthy()
expect(el.className).toContain('fixed')
// 1440 - 224 - 12 margin = 1204; titlebar 34 + 12 = 46.
expect(el.style.left).toBe('1204px')
expect(el.style.top).toBe('46px')
expect(el.style.width).toBe('224px')
expect(document.querySelector('[data-testid="hud-body"]')?.textContent).toBe('live')
})
it('renders nothing for a non-floating placement', () => {
registerHud({ placement: 'right', width: '224px' })
render(<FloatingPanes />)
expect(card()).toBeNull()
})
it('moves with a real pointer drag on the header', () => {
registerHud({ anchor: 'top-left', height: '132px', placement: 'floating', width: '224px' })
render(<FloatingPanes />)
expect(card()!.style.left).toBe('12px')
pointer(grab(), 'pointerdown', 100, 100)
pointer(grab(), 'pointermove', 260, 240)
pointer(grab(), 'pointerup', 260, 240)
expect(card()!.style.left).toBe('172px')
expect(card()!.style.top).toBe('186px')
})
it('persists the dragged position across a remount', () => {
registerHud({ anchor: 'top-left', height: '132px', placement: 'floating', width: '224px' })
render(<FloatingPanes />)
pointer(grab(), 'pointerdown', 100, 100)
pointer(grab(), 'pointermove', 300, 300)
pointer(grab(), 'pointerup', 300, 300)
const moved = card()!.style.left
act(() => root!.unmount())
container!.remove()
render(<FloatingPanes />)
expect(card()!.style.left).toBe(moved)
})
it('rides the right edge when the window shrinks', () => {
registerHud({ anchor: 'top-right', height: '132px', placement: 'floating', width: '224px' })
render(<FloatingPanes />)
expect(card()!.style.left).toBe('1204px')
resizeWindow(1000, 700)
// Tracks the edge: 1000 - 224 - 12 = 764.
expect(card()!.style.left).toBe('764px')
})
it('never lets a drag push the card under the titlebar', () => {
registerHud({ anchor: 'top-left', height: '132px', placement: 'floating', width: '224px' })
render(<FloatingPanes />)
pointer(grab(), 'pointerdown', 100, 100)
pointer(grab(), 'pointermove', 100, -900)
pointer(grab(), 'pointerup', 100, -900)
expect(Number.parseFloat(card()!.style.top)).toBeGreaterThanOrEqual(34)
})
it('collapses to the header and drops the body, and does not drag from the button', () => {
registerHud({ anchor: 'top-left', height: '132px', placement: 'floating', width: '224px' })
render(<FloatingPanes />)
const before = card()!.style.left
const toggle = card()!.querySelector('button')!
// The button is inside the drag handle — [data-floating-no-drag] must
// stop it starting a drag.
pointer(toggle, 'pointerdown', 100, 100)
pointer(grab(), 'pointermove', 400, 400)
pointer(grab(), 'pointerup', 400, 400)
expect(card()!.style.left).toBe(before)
act(() => {
toggle.click()
})
expect(document.querySelector('[data-testid="hud-body"]')).toBeNull()
expect(card()!.style.height).toBe('')
})
it('renders one card per floating contribution', () => {
registerHud({ anchor: 'top-left', placement: 'floating', width: '224px' })
disposers.push(
registry.register({
area: 'panes',
data: { anchor: 'bottom-right', placement: 'floating', width: '200px' },
id: 'hud2',
render: () => null,
title: 'HUD 2'
})
)
render(<FloatingPanes />)
expect(document.querySelectorAll('[data-floating-pane]').length).toBe(2)
})
})

View file

@ -0,0 +1,206 @@
/**
* Floating panes the tree's non-tiling placement.
*
* `placement: 'floating'` opts a pane OUT of the layout tree: it never becomes
* a track, never takes width from a zone, and never appears in a tab strip.
* The tree renders it as a fixed card above itself, draggable by its header,
* with position + collapse persisted per pane id. The geometry rules live in
* floating-rect.ts.
*/
import { useStore } from '@nanostores/react'
import { type PointerEvent as ReactPointerEvent, useCallback, useEffect, useRef, useState } from 'react'
import { HUD_SURFACE } from '@/app/floating-hud'
import { TITLEBAR_HEIGHT } from '@/app/shell/titlebar'
import { Codicon } from '@/components/ui/codicon'
import { ContribBoundary } from '@/contrib/react/boundary'
import { useContributions } from '@/contrib/react/use-contributions'
import type { Contribution } from '@/contrib/types'
import { readJson, writeJson } from '@/lib/storage'
import { cn } from '@/lib/utils'
import { $hiddenTreePanes } from '../store'
import {
anchoredRect,
clampFloatingRect,
FLOATING_PLACEMENT,
floatingPx,
type FloatingRect,
type FloatingViewport,
reflowRect
} from './floating-rect'
import { paneChrome } from './track-model'
const POSITIONS_KEY = 'hermes.desktop.floatingPanes.v1'
const DEFAULT_SIZE = { width: 240, height: 180 }
interface StoredRect {
x: number
y: number
collapsed?: boolean
}
const readStored = (): Record<string, StoredRect> => readJson<Record<string, StoredRect>>(POSITIONS_KEY) ?? {}
const viewportNow = (): FloatingViewport => ({
width: window.innerWidth,
height: window.innerHeight,
top: TITLEBAR_HEIGHT
})
function FloatingPane({ pane }: { pane: Contribution }) {
const chrome = paneChrome(pane)
const anchor = chrome.anchor ?? 'top-right'
const size = {
width: floatingPx(chrome.width, DEFAULT_SIZE.width),
height: floatingPx(chrome.height, DEFAULT_SIZE.height)
}
const [rect, setRect] = useState<FloatingRect>(() => {
const stored = readStored()[pane.id]
const spawned = anchoredRect(anchor, size, viewportNow())
return stored ? { ...spawned, x: stored.x, y: stored.y } : spawned
})
const [collapsed, setCollapsed] = useState(() => readStored()[pane.id]?.collapsed ?? false)
const drag = useRef<{ x: number; y: number } | null>(null)
const viewport = useRef<FloatingViewport>(viewportNow())
const persist = useCallback(
(next: FloatingRect, nextCollapsed: boolean) => {
writeJson(POSITIONS_KEY, { ...readStored(), [pane.id]: { x: next.x, y: next.y, collapsed: nextCollapsed } })
},
[pane.id]
)
// Track the viewport so an edge-anchored pane rides its edge on resize.
// The previous-size read lives in the handler (not a useEffect body): it's
// window geometry, not a mirrored reactive value.
const handleResize = useCallback(() => {
const next = viewportNow()
setRect(current => reflowRect(current, anchor, viewport.current, next))
viewport.current = next
}, [anchor])
useEffect(() => {
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [handleResize])
const onPointerDown = useCallback((event: ReactPointerEvent<HTMLElement>) => {
if ((event.target as HTMLElement).closest('[data-floating-no-drag]')) {
return
}
event.currentTarget.setPointerCapture(event.pointerId)
drag.current = { x: event.clientX, y: event.clientY }
event.preventDefault()
}, [])
const onPointerMove = useCallback((event: ReactPointerEvent<HTMLElement>) => {
const from = drag.current
if (!from) {
return
}
drag.current = { x: event.clientX, y: event.clientY }
setRect(current =>
clampFloatingRect(
{ ...current, x: current.x + event.clientX - from.x, y: current.y + event.clientY - from.y },
viewport.current
)
)
}, [])
const onPointerUp = useCallback(
(event: ReactPointerEvent<HTMLElement>) => {
if (!drag.current) {
return
}
drag.current = null
event.currentTarget.releasePointerCapture?.(event.pointerId)
setRect(current => {
persist(current, collapsed)
return current
})
},
[collapsed, persist]
)
const toggleCollapsed = () =>
setCollapsed(current => {
persist(rect, !current)
return !current
})
return (
<div
className={cn('pointer-events-auto fixed z-45 flex flex-col overflow-hidden', HUD_SURFACE)}
data-floating-pane={pane.id}
style={{
left: rect.x,
top: rect.y,
width: size.width,
height: collapsed ? undefined : size.height
}}
>
{/* Header IS the drag handle — the floating equivalent of a tab strip. */}
<header
className="flex shrink-0 cursor-grab items-center justify-between gap-2 px-2.5 py-1.5 text-[0.6875rem] text-(--ui-text-secondary) select-none"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
style={{ touchAction: 'none' }}
>
<span className="truncate font-medium">{pane.title ?? pane.id}</span>
<button
className="rounded p-0.5 text-(--ui-text-quaternary) transition-colors hover:text-(--ui-text-primary)"
data-floating-no-drag=""
onClick={toggleCollapsed}
type="button"
>
<Codicon name={collapsed ? 'chevron-down' : 'chevron-up'} size="0.75rem" />
</button>
</header>
{!collapsed && (
<div className="min-h-0 flex-1 overflow-auto">
<ContribBoundary id={pane.id}>{pane.render?.()}</ContribBoundary>
</div>
)}
</div>
)
}
/** Every `placement: 'floating'` contribution, rendered above the tree. */
export function FloatingPanes() {
const panes = useContributions('panes')
const hidden = useStore($hiddenTreePanes)
const floating = panes.filter(pane => paneChrome(pane).placement === FLOATING_PLACEMENT && !hidden.has(pane.id))
if (floating.length === 0) {
return null
}
return (
<>
{floating.map(pane => (
<FloatingPane key={`${pane.source ?? 'core'}:${pane.id}`} pane={pane} />
))}
</>
)
}

View file

@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest'
import { anchoredRect, clampFloatingRect, floatingPx, reflowRect } from './floating-rect'
const viewport = { width: 1440, height: 900, top: 34 }
const size = { width: 240, height: 180 }
describe('anchoredRect', () => {
it('spawns in the named corner, inset by the margin', () => {
expect(anchoredRect('top-right', size, viewport)).toMatchObject({ x: 1188, y: 46 })
expect(anchoredRect('top-left', size, viewport)).toMatchObject({ x: 12, y: 46 })
expect(anchoredRect('bottom-right', size, viewport)).toMatchObject({ x: 1188, y: 708 })
expect(anchoredRect('bottom-left', size, viewport)).toMatchObject({ x: 12, y: 708 })
})
it('never spawns over the reserved top chrome', () => {
for (const anchor of ['top-left', 'top-right', 'bottom-left', 'bottom-right'] as const) {
expect(anchoredRect(anchor, size, { width: 320, height: 120, top: 34 }).y).toBeGreaterThanOrEqual(34)
}
})
})
describe('clampFloatingRect', () => {
it('keeps a grabbable sliver on screen at either horizontal edge', () => {
expect(clampFloatingRect({ ...size, x: 5000, y: 100 }, viewport).x).toBe(1392)
expect(clampFloatingRect({ ...size, x: -5000, y: 100 }, viewport).x).toBe(-192)
})
it('hard-bounds the top so the drag handle is always reachable', () => {
expect(clampFloatingRect({ ...size, x: 100, y: -500 }, viewport).y).toBe(34)
expect(clampFloatingRect({ ...size, x: 100, y: 5000 }, viewport).y).toBe(852)
})
it('pins to the top-left rather than inverting when larger than the viewport', () => {
const tiny = { width: 200, height: 100, top: 34 }
const rect = clampFloatingRect({ x: 0, y: 0, width: 400, height: 400 }, tiny)
expect(rect.x).toBeLessThanOrEqual(tiny.width)
expect(rect.y).toBe(34)
})
it('leaves an in-bounds rect untouched', () => {
const rect = { ...size, x: 400, y: 200 }
expect(clampFloatingRect(rect, viewport)).toEqual(rect)
})
})
describe('reflowRect', () => {
const shrunk = { width: 1000, height: 700, top: 34 }
it('rides the right edge when right-anchored', () => {
const start = anchoredRect('top-right', size, viewport)
expect(reflowRect(start, 'top-right', viewport, shrunk).x).toBe(748)
})
it('rides the bottom edge when bottom-anchored', () => {
const start = anchoredRect('bottom-left', size, viewport)
expect(reflowRect(start, 'bottom-left', viewport, shrunk).y).toBe(508)
})
it('holds position when left/top-anchored, but still clamps into view', () => {
const start = { ...size, x: 40, y: 60 }
expect(reflowRect(start, 'top-left', viewport, shrunk)).toMatchObject({ x: 40, y: 60 })
expect(reflowRect({ ...size, x: 1300, y: 60 }, 'top-left', viewport, shrunk).x).toBe(952)
})
it('is identity for an in-bounds rect when the viewport did not change', () => {
const rect = { ...size, x: 300, y: 300 }
expect(reflowRect(rect, 'top-right', viewport, viewport)).toEqual(rect)
})
})
describe('floatingPx', () => {
it('accepts authored px strings and raw numbers, falling back otherwise', () => {
expect(floatingPx('216px', 240)).toBe(216)
expect(floatingPx(300, 240)).toBe(300)
expect(floatingPx(undefined, 240)).toBe(240)
expect(floatingPx('auto', 240)).toBe(240)
expect(floatingPx(Number.NaN, 240)).toBe(240)
})
})

View file

@ -0,0 +1,109 @@
/**
* Floating-pane geometry pure, so the clamping/anchoring rules are testable
* without a DOM.
*
* A floating pane is NOT a track in the layout tree: it never takes width from
* a zone. It's a fixed-position card the tree renders above itself. Its whole
* contract is "stay a sane rect inside the viewport", which is this module.
*/
/** Corner a floating pane spawns from (and re-anchors to on reset). */
export type FloatingAnchor = 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right'
export interface FloatingRect {
x: number
y: number
width: number
height: number
}
export interface FloatingViewport {
width: number
height: number
/** Chrome reserved at the top (the 34px titlebar) — panes never cover it. */
top: number
}
/** Keep this much of the card on screen when clamping, so it stays grabbable. */
const MIN_VISIBLE = 48
/** Gap between a spawned pane and the viewport edge it anchors to. */
export const FLOATING_MARGIN = 12
/** The one non-tiling placement — see renderer/floating-panes.tsx. */
export const FLOATING_PLACEMENT = 'floating'
export const clamp = (n: number, lo: number, hi: number): number => Math.min(Math.max(n, lo), hi)
/**
* Clamp a rect into the viewport. Horizontal keeps `MIN_VISIBLE` px on screen
* from either edge (a card can hang off the right, never vanish); vertical is
* hard-bounded by the reserved chrome so the drag handle is always reachable.
*
* Both axes clamp `lo` before `hi`, so a card wider/taller than the viewport
* pins to the top-left rather than inverting.
*/
export function clampFloatingRect(rect: FloatingRect, viewport: FloatingViewport): FloatingRect {
const maxX = Math.max(MIN_VISIBLE - rect.width, viewport.width - MIN_VISIBLE)
const maxY = Math.max(viewport.top, viewport.height - MIN_VISIBLE)
return {
...rect,
x: clamp(rect.x, Math.min(MIN_VISIBLE - rect.width, maxX), maxX),
y: clamp(rect.y, viewport.top, maxY)
}
}
/** Spawn position for an anchor — the corner, inset by `FLOATING_MARGIN`. */
export function anchoredRect(
anchor: FloatingAnchor,
size: { width: number; height: number },
viewport: FloatingViewport
): FloatingRect {
const right = anchor === 'bottom-right' || anchor === 'top-right'
const bottom = anchor === 'bottom-left' || anchor === 'bottom-right'
const rect = {
...size,
x: right ? viewport.width - size.width - FLOATING_MARGIN : FLOATING_MARGIN,
y: bottom ? viewport.height - size.height - FLOATING_MARGIN : viewport.top + FLOATING_MARGIN
}
return clampFloatingRect(rect, viewport)
}
/**
* Re-clamp on viewport resize. A pane anchored to a right/bottom edge TRACKS
* that edge (shrinking the window keeps it in the corner) instead of being
* dragged inward only when it would fall off matching how the pet and every
* OS HUD behave.
*/
export function reflowRect(
rect: FloatingRect,
anchor: FloatingAnchor,
previous: FloatingViewport,
next: FloatingViewport
): FloatingRect {
const right = anchor === 'bottom-right' || anchor === 'top-right'
const bottom = anchor === 'bottom-left' || anchor === 'bottom-right'
return clampFloatingRect(
{
...rect,
x: right ? rect.x + (next.width - previous.width) : rect.x,
y: bottom ? rect.y + (next.height - previous.height) : rect.y
},
next
)
}
/** Parse an authored CSS px length (`'216px'`, `216`) with a fallback. */
export function floatingPx(value: number | string | undefined, fallback: number): number {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : fallback
}
const parsed = Number.parseFloat(value ?? '')
return Number.isFinite(parsed) ? parsed : fallback
}

View file

@ -29,6 +29,7 @@ import { $layoutTree, trackActiveTreeGroup } from '../store'
import { ZoneEditor } from '../zone-editor'
import { TreeEditBar } from './edit-bar'
import { FloatingPanes } from './floating-panes'
import { NarrowOverlays } from './narrow-overlays'
import { TreeNode } from './tree-node'
@ -73,6 +74,8 @@ export function LayoutTreeRoot({ children }: { children?: ReactNode }) {
`}</style>
<TreeNode node={tree} root rootRow={tree.type === 'split' && tree.orientation === 'row'} />
<NarrowOverlays />
{/* Non-tiling panes: fixed cards above the tree, outside every zone. */}
<FloatingPanes />
<TreeEditBar />
<ZoneEditor />
{children}

View file

@ -15,6 +15,7 @@ import type { GroupNode, LayoutNode } from '../model'
import { allPaneIds } from '../model'
import type { DoubleTapContext } from './drag-session'
import type { FloatingAnchor } from './floating-rect'
export const MIN_PANE_PX = 80
@ -34,13 +35,20 @@ export interface PaneSizing {
}
/** Chrome behavior flags a pane contributes. Read via `paneChrome`. */
interface PaneChrome {
interface PaneChrome extends PaneSizing {
/** Leaves the grid on narrow viewports; revealed as an edge overlay. */
collapsible?: boolean
/** Extra ids accepted from PANE_TOGGLE_REVEAL_EVENT (the real app's pane
* ids, e.g. `chat-sidebar` for `sessions`). */
revealAliases?: string[]
/** Tiling role in the tree, or `'floating'` the one NON-tiling placement:
* the pane is excluded from the tree entirely and rendered as a fixed card
* above it (see renderer/floating-panes.tsx). A floating pane takes no
* space from any zone, has no tab, and can't be docked or split. */
placement?: string
/** Spawn corner for `placement: 'floating'` (default `'top-right'`). The
* pane also TRACKS that corner's edges when the window resizes. */
anchor?: FloatingAnchor
/** No Close in the tab menu the one surface the app can't lose (the
* main workspace). Session tiles share `placement: 'main'` but close. */
uncloseable?: boolean

View file

@ -24,7 +24,7 @@ import { cn } from '@/lib/utils'
import { $layoutEditMode } from '../../edit-mode'
import { useWindowControlsOverlap } from '../../geometry'
import { hiddenPaneProps, PaneVisibleContext } from '../../pane-visibility'
import { hiddenPaneProps, PaneGroupContext, PaneVisibleContext } from '../../pane-visibility'
import type { DropPosition, GroupNode, RootEdge } from '../model'
import { adjacentGroup } from '../model'
import {
@ -39,7 +39,9 @@ import {
collapseTreePane,
dismissTreePane,
isCollapsePane,
isSessionStripPane,
moveTreePane,
noteActiveTreeGroup,
restoreTreePane,
SESSION_TILE_DRAG,
setTreeGroupHeaderHidden,
@ -507,16 +509,26 @@ export function TreeGroup({
return <Fragment key={paneId}>{chrome.tabWrap ? chrome.tabWrap(tab) : tab}</Fragment>
})}
{/* Plain "+" after the last tab of the MAIN strip (the workspace
zone) always shown, no tab/button chrome, just the glyph.
Creates a new session tab (mirrors T) via the app-registered
action; hidden when unwired or the zone is minimized. */}
{node.panes.includes('workspace') && newSessionTabAction && !node.minimized && (
{/* Plain "+" after the last tab of a CHAT strip (the workspace
zone, or any zone holding session tabs) always shown, no
tab/button chrome, just the glyph. Creates a new session tab
(mirrors T) via the app-registered action; the pointerdown
focuses this zone first, so the tab lands in THIS strip.
Hidden when unwired or the zone is minimized. */}
{shown.some(isSessionStripPane) && newSessionTabAction && !node.minimized && (
<button
aria-label={t.zones.newSessionTab}
className="grid size-7 shrink-0 place-items-center self-center bg-transparent text-(--ui-text-quaternary) transition-colors hover:text-foreground [-webkit-app-region:no-drag]"
onClick={() => newSessionTabAction()}
onPointerDown={e => e.stopPropagation()}
onPointerDown={e => {
e.stopPropagation()
// The action docks into the FOCUSED chat zone; clicking a
// background strip's "+" must make THAT zone the focused
// one first, or the tab opens in whichever zone was last
// clicked. (pointerdown's own focus tracking would land
// after the click handler reads the anchor.)
noteActiveTreeGroup(node.id)
}}
title={t.zones.newSessionTab}
type="button"
>
@ -567,10 +579,14 @@ export function TreeGroup({
>
{pane?.render ? (
// Visibility flows to the pane so a kept-alive chat surface
// can gate its hot (per-token) subscriptions while hidden.
<PaneVisibleContext.Provider value={isActive}>
<ContribBoundary id={pane.id}>{pane.render()}</ContribBoundary>
</PaneVisibleContext.Provider>
// can gate its hot (per-token) subscriptions while hidden;
// the group id identifies the ZONE it lives in, for state
// that is per-zone rather than per-tab (composer pop-out).
<PaneGroupContext.Provider value={node.id}>
<PaneVisibleContext.Provider value={isActive}>
<ContribBoundary id={pane.id}>{pane.render()}</ContribBoundary>
</PaneVisibleContext.Provider>
</PaneGroupContext.Provider>
) : (
isActive && (
<div className="p-3 font-mono text-[11px] text-(--ui-text-quaternary)">
@ -712,7 +728,7 @@ function ZoneDropOverlay({ node }: { node: GroupNode }) {
// now (stack into its tabs / split its edges); only a CHAT zone's center is
// a link-to-chat (the composer overlay owns that visual).
const sessionDrag = dragging === SESSION_TILE_DRAG
const chatZone = node.panes.some(p => p === 'workspace' || p.startsWith('session-tile:'))
const chatZone = node.panes.some(isSessionStripPane)
const isDragSource = node.panes.includes(dragging)

View file

@ -21,6 +21,7 @@ import {
findGroup,
findGroupOfPane,
groupLeafIds,
type GroupNode,
insertAtGroup,
isLayoutNode,
type LayoutNode,
@ -38,6 +39,7 @@ import {
splitGroupZone as splitGroupZoneOp,
type SplitNode
} from './model'
import { FLOATING_PLACEMENT } from './renderer/floating-rect'
import { rootChildSide } from './renderer/track-model'
// v2: v1 trees were saved against placeholder panes with index-order zone
@ -281,12 +283,49 @@ const isUncloseablePane = (paneId: string): boolean =>
(registry.getArea('panes').find(c => c.id === paneId)?.data as { uncloseable?: boolean } | undefined)?.uncloseable
)
/** W "main tabs always": close the MAIN (workspace) zone's active tab, unless
* it's the uncloseable workspace itself. Returns false when there's nothing to
* close, so W stays a no-op it never closes the window. */
export function closeWorkspaceTab(): boolean {
/** A pane that belongs to a CHAT tab strip — the workspace or a session tile. */
export const isSessionStripPane = (paneId: string): boolean =>
paneId === 'workspace' || paneId.startsWith('session-tile:')
/** The zone the session-tab verbs (⌘W / T / T / the strip's "+") act on:
* the FOCUSED zone when it hosts a chat strip, else the workspace's zone.
* Same source 19 indexes ($activeTreeGroup), so the number keys and the
* tab verbs can't disagree about which strip is "the" strip. Focus parked in
* the sidebar / terminal / files must NOT retarget them those zones fall
* back to main rather than letting W close the file tree. */
function focusedSessionGroup(): GroupNode | null {
const tree = $layoutTree.get()
const active = tree ? findGroupOfPane(tree, 'workspace')?.active : null
if (!tree) {
return null
}
const groupId = $activeTreeGroup.get()
const focused = groupId ? findGroup(tree, groupId) : null
return focused?.panes.some(isSessionStripPane) ? focused : findGroupOfPane(tree, 'workspace')
}
/** The pane a NEW session tab should dock beside (T): the focused chat zone's
* active session pane, else its first. Null when no zone hosts a chat strip
* the caller falls back to the workspace. */
export function focusedSessionTabAnchor(): null | string {
const group = focusedSessionGroup()
if (!group) {
return null
}
const active = group.active
return active && isSessionStripPane(active) ? active : (group.panes.find(isSessionStripPane) ?? null)
}
/** W: close the FOCUSED chat zone's active tab, unless it's the uncloseable
* workspace itself. Returns false when there's nothing to close, so W stays a
* no-op it never closes the window. */
export function closeFocusedSessionTab(): boolean {
const active = focusedSessionGroup()?.active
if (!active || isUncloseablePane(active)) {
return false
@ -350,14 +389,52 @@ export function treePanesWithPrefix(prefix: string): string[] {
* An atom so the strip re-renders when the action becomes available. */
export const $newSessionTabAction = atom<(() => void) | null>(null)
/** 19: activate the Nth tab of the FOCUSED zone (the interaction tracker's
* group), but only when it's a real tab strip (2 panes). Returns false so the
* caller falls back to its default (profile switch) the number keys mean
* "switch tab" only while a multi-tab zone holds focus. */
/**
* Keyboard slots (19, Tab) must index the SAME tabs the strip paints
* chrome-hidden panes (files in Focus layout), unregistered ones, and
* narrow-collapsed collapsibles stay in `group.panes` but aren't chips. Walking
* the raw array made 2 land on what the strip called tab 1 after a hidden
* pane sat earlier in the list (classic after-W-shift offset).
*/
function shownPanesInGroup(group: { panes: readonly string[] }): string[] {
const hidden = $hiddenTreePanes.get()
const registered = registry.getArea('panes')
const paneFor = (id: string) => registered.find(c => c.id === id)
return group.panes.filter(id => {
const pane = paneFor(id)
if (!pane) {
return false
}
if (hidden.has(id)) {
return false
}
// Match TreeGroup's paneShown for the narrow breakpoint — collapsible
// panes drop out of the strip when the viewport collapses them.
if (
typeof window !== 'undefined' &&
window.matchMedia?.(SIDEBAR_COLLAPSE_MEDIA_QUERY).matches &&
Boolean((pane.data as { collapsible?: boolean } | undefined)?.collapsible)
) {
return false
}
return true
})
}
/** 19: activate the Nth *visible* tab of the FOCUSED zone (the interaction
* tracker's group), but only when it's a real tab strip (2 shown panes).
* Returns false so the caller falls back to its default (profile switch)
* the number keys mean "switch tab" only while a multi-tab zone holds focus. */
export function activateTreeTabSlot(slot: number): boolean {
const groupId = $activeTreeGroup.get()
const tree = $layoutTree.get()
const panes = (groupId && tree ? findGroup(tree, groupId)?.panes : null) ?? []
const group = groupId && tree ? findGroup(tree, groupId) : null
const panes = group ? shownPanesInGroup(group) : []
if (panes.length < 2 || slot < 1 || slot > panes.length) {
return false
@ -368,20 +445,23 @@ export function activateTreeTabSlot(slot: number): boolean {
return true
}
/** ⌃Tab / Tab: cycle the FOCUSED zone's tabs (wrapping) but only a
* session/main strip with 2 tabs. Returns false so the caller falls back to
* the recent-session switcher when the focus isn't a chat tab strip. */
/** ⌃Tab / Tab: cycle the FOCUSED zone's *visible* tabs (wrapping) but only a
* session/main strip with 2 shown tabs. Returns false so the caller falls
* back to the recent-session switcher when the focus isn't a chat tab strip. */
export function cycleTreeTabInFocusedZone(direction: 1 | -1): boolean {
const groupId = $activeTreeGroup.get()
const tree = $layoutTree.get()
const group = groupId && tree ? findGroup(tree, groupId) : null
const panes = group?.panes ?? []
const panes = group ? shownPanesInGroup(group) : []
if (panes.length < 2 || !panes.some(id => id === 'workspace' || id.startsWith('session-tile:'))) {
if (panes.length < 2 || !panes.some(isSessionStripPane)) {
return false
}
const idx = Math.max(0, panes.indexOf(group!.active ?? ''))
// Active may itself be hidden (Files collapsed mid-cycle) — treat it as
// missing so the step starts from a real chip rather than landing on a ghost.
const current = Math.max(0, panes.indexOf(group!.active ?? ''))
const idx = panes.includes(group!.active ?? '') ? current : 0
const nextId = panes[(idx + direction + panes.length) % panes.length]
activateTreePane(group!.id, nextId)
@ -837,7 +917,14 @@ function adoptContributedPanes(): void {
}
const dismissed = $dismissedPanes.get()
const missing = panes.filter(c => !inTree.has(c.id) && !dismissed.has(c.id))
// `placement: 'floating'` opts OUT of the tree entirely — those panes render
// as fixed cards above it (renderer/floating-panes.tsx). Adopting one would
// turn it into a track that steals width from a zone, which is the whole
// thing floating exists to avoid.
const missing = panes.filter(
c => !inTree.has(c.id) && !dismissed.has(c.id) && placementOf(c.id) !== FLOATING_PLACEMENT
)
if (missing.length === 0) {
return

View file

@ -0,0 +1,81 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// ⌘1…⌘9 / ⌃Tab must index the same tabs the strip paints. A chrome-hidden
// pane (Focus layout's `files`) stays in `group.panes` but isn't a chip —
// indexing the raw array made ⌘2 land on the strip's first session tab.
describe('activateTreeTabSlot indexes shown panes only', () => {
beforeEach(() => {
window.localStorage.clear()
vi.resetModules()
})
afterEach(() => {
vi.resetModules()
})
async function setup() {
const tree = await import('@/components/pane-shell/tree/store')
const model = await import('@/components/pane-shell/tree/model')
const { registry } = await import('@/contrib/registry')
for (const id of ['workspace', 'files', 'session-tile:a', 'session-tile:b']) {
registry.register({
area: 'panes',
data: id === 'files' ? { collapsible: true, placement: 'side' } : { placement: 'main' },
id,
render: () => null,
title: id
})
}
// Focus-ish packing: files rides with workspace; two session tiles stacking
// of top of that — pane order in the tree: workspace, files, A, B.
tree.declareDefaultTree(
model.group(['workspace', 'files', 'session-tile:a', 'session-tile:b'], {
active: 'workspace',
id: 'grp-main'
})
)
tree.noteActiveTreeGroup('grp-main')
tree.setTreePaneHidden('files', true)
const activeOf = () => model.findGroup(tree.$layoutTree.get()!, 'grp-main')?.active ?? null
return { activeOf, tree }
}
it('⌘1 is workspace and ⌘2 is the first SESSION tab when files is hidden', async () => {
const { activeOf, tree } = await setup()
expect(tree.activateTreeTabSlot(1)).toBe(true)
expect(activeOf()).toBe('workspace')
expect(tree.activateTreeTabSlot(2)).toBe(true)
expect(activeOf()).toBe('session-tile:a')
expect(tree.activateTreeTabSlot(3)).toBe(true)
expect(activeOf()).toBe('session-tile:b')
})
it('does not claim a slot past the visible count (falls through to profile switch)', async () => {
const { tree } = await setup()
// Shown: workspace + A + B → 3. Slot 4 would have been `B` on the raw array
// (workspace, files, A, B) before this fix — now it correctly refuses.
expect(tree.activateTreeTabSlot(4)).toBe(false)
})
it('⌃Tab cycles only visible chips', async () => {
const { activeOf, tree } = await setup()
expect(tree.cycleTreeTabInFocusedZone(1)).toBe(true)
expect(activeOf()).toBe('session-tile:a')
expect(tree.cycleTreeTabInFocusedZone(1)).toBe(true)
expect(activeOf()).toBe('session-tile:b')
expect(tree.cycleTreeTabInFocusedZone(1)).toBe(true)
expect(activeOf()).toBe('workspace')
})
})

View file

@ -8,7 +8,7 @@ function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxP
return (
<CheckboxPrimitive.Root
className={cn(
'peer size-4 shrink-0 rounded-sm border border-input shadow-xs outline-none transition-shadow focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
'group peer size-4 shrink-0 rounded-sm border border-input shadow-xs outline-none transition-shadow focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=indeterminate]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
className
)}
data-slot="checkbox"
@ -18,7 +18,8 @@ function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxP
className="flex items-center justify-center text-current"
data-slot="checkbox-indicator"
>
<Codicon name="check" size="0.875rem" />
<Codicon className="hidden group-data-[state=checked]:block" name="check" size="0.875rem" />
<Codicon className="hidden group-data-[state=indeterminate]:block" name="dash" size="0.875rem" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)

View file

@ -9,7 +9,7 @@ export function LogView({ className, ...props }: ComponentProps<'div'>) {
return (
<div
className={cn(
'overflow-auto rounded-lg border border-(--ui-stroke-tertiary) px-2.5 py-1.5 font-mono text-[0.6875rem] leading-[1.5] whitespace-pre-wrap break-words text-(--ui-text-tertiary) [scrollbar-width:thin]',
'overflow-auto rounded-lg border border-(--ui-stroke-tertiary) px-2.5 py-1.5 font-mono text-[0.6875rem] leading-[1.5] whitespace-pre-wrap break-words text-(--ui-text-tertiary)',
className
)}
data-selectable-text="true"

View file

@ -6,6 +6,11 @@ import { suppressNonKeyboardFocusOpen } from './tooltip'
// their trigger on close, which left tips stuck open after a mouse pick (e.g.
// the composer model pill). The trigger's focus handler must preventDefault —
// which Radix's composed handler honors — for non-keyboard focus only.
//
// The regression these guard: `:focus-visible` on its own says "true" for a
// mouse pick out of a Radix menu, because the menu's autofocus/arrow-key
// navigation puts Chromium in keyboard modality before focus returns to the
// trigger. Only the last real input device separates the two cases.
const focusEvent = (matchesImpl: (selector: string) => boolean) => {
const preventDefault = vi.fn()
@ -19,30 +24,59 @@ const focusEvent = (matchesImpl: (selector: string) => boolean) => {
}
}
describe('suppressNonKeyboardFocusOpen', () => {
it('suppresses the focus-open when focus is not keyboard-visible (menu close restore)', () => {
const { event, preventDefault } = focusEvent(selector => selector !== ':focus-visible')
const focusVisible = (selector: string) => selector === ':focus-visible'
const notFocusVisible = (selector: string) => selector !== ':focus-visible'
suppressNonKeyboardFocusOpen(event)
describe('suppressNonKeyboardFocusOpen', () => {
it('suppresses the focus-open when a mouse pick restores focus to the trigger', () => {
// The menu leaves Chromium in keyboard modality, so :focus-visible matches
// even though the user clicked — this is the model-pill bug.
const { event, preventDefault } = focusEvent(focusVisible)
suppressNonKeyboardFocusOpen(event, 'pointer')
expect(preventDefault).toHaveBeenCalledOnce()
})
it('suppresses the focus-open when focus is not keyboard-visible', () => {
const { event, preventDefault } = focusEvent(notFocusVisible)
suppressNonKeyboardFocusOpen(event, 'pointer')
expect(preventDefault).toHaveBeenCalledOnce()
})
it('keeps the focus-open for keyboard (Tab) focus — a11y path', () => {
const { event, preventDefault } = focusEvent(selector => selector === ':focus-visible')
const { event, preventDefault } = focusEvent(focusVisible)
suppressNonKeyboardFocusOpen(event)
suppressNonKeyboardFocusOpen(event, 'keyboard')
expect(preventDefault).not.toHaveBeenCalled()
})
it('fails open when :focus-visible is unsupported', () => {
const { event, preventDefault } = focusEvent(() => {
it('still suppresses a programmatic focus that is not focus-visible in keyboard modality', () => {
const { event, preventDefault } = focusEvent(notFocusVisible)
suppressNonKeyboardFocusOpen(event, 'keyboard')
expect(preventDefault).toHaveBeenCalledOnce()
})
it('falls back to the modality when :focus-visible is unsupported', () => {
const unsupported = () => {
throw new Error('unsupported selector')
})
}
suppressNonKeyboardFocusOpen(event)
const keyboard = focusEvent(unsupported)
expect(preventDefault).not.toHaveBeenCalled()
suppressNonKeyboardFocusOpen(keyboard.event, 'keyboard')
expect(keyboard.preventDefault).not.toHaveBeenCalled()
const pointer = focusEvent(unsupported)
suppressNonKeyboardFocusOpen(pointer.event, 'pointer')
expect(pointer.preventDefault).toHaveBeenCalledOnce()
})
})

View file

@ -2,6 +2,7 @@ import { Tooltip as TooltipPrimitive } from 'radix-ui'
import * as React from 'react'
import { useI18n } from '@/i18n'
import { type InputModality, lastInputModality } from '@/lib/input-modality'
import { useKeybindHint } from '@/lib/keybinds/use-keybind-hint'
import { cn } from '@/lib/utils'
@ -47,17 +48,25 @@ function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root
// covers clicks on the trigger itself). Menus and dialogs return focus to
// their trigger when they close, so "open the model menu, pick a model" left
// the trigger's tip stuck open over the fresh selection. Gate focus-opens to
// KEYBOARD focus (:focus-visible): Chromium keeps modality, so a mouse pick's
// focus restore is suppressed while Tab-focus still shows the tip for a11y.
// preventDefault doesn't cancel the focus itself — Radix's composed handler
// just skips its onOpen when the event is defaultPrevented.
export function suppressNonKeyboardFocusOpen(event: React.FocusEvent<HTMLElement>): void {
let keyboardFocus = true
// KEYBOARD focus so a mouse pick's focus restore is suppressed while Tab-focus
// still shows the tip for a11y. preventDefault doesn't cancel the focus itself
// — Radix's composed handler just skips its onOpen when defaultPrevented.
//
// `:focus-visible` ALONE is not that gate. Radix menus autofocus their content
// and keyboard-navigate their items, so Chromium is in keyboard modality by the
// time a mouse pick restores focus and matches `:focus-visible` — the model
// pill's tip reopened over every selection. Qualify it with the device behind
// the last real interaction, which a mouse pick reports as `pointer`.
export function suppressNonKeyboardFocusOpen(
event: React.FocusEvent<HTMLElement>,
modality: InputModality = lastInputModality()
): void {
let keyboardFocus = modality === 'keyboard'
try {
keyboardFocus = event.currentTarget.matches(':focus-visible')
keyboardFocus &&= event.currentTarget.matches(':focus-visible')
} catch {
// Selector unsupported (older jsdom) — keep Radix's default focus-open.
// Selector unsupported (older jsdom) — fall back to the modality alone.
}
if (!keyboardFocus) {

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