mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
Merge upstream main into fix/hermes-relay-anthropic-context
Signed-off-by: Alex Fournier <afournier@nvidia.com>
This commit is contained in:
commit
b1a5d67e71
214 changed files with 11437 additions and 977 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
94
apps/desktop/electron/dev-cdp.test.ts
Normal file
94
apps/desktop/electron/dev-cdp.test.ts
Normal 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)
|
||||
})
|
||||
108
apps/desktop/electron/dev-cdp.ts
Normal file
108
apps/desktop/electron/dev-cdp.ts
Normal 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 }
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ⌘1…⌘9 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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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 docked↔floating 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 docked↔floating 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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>(
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
103
apps/desktop/src/app/chat/composer/path-refs.test.ts
Normal file
103
apps/desktop/src/app/chat/composer/path-refs.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
103
apps/desktop/src/app/chat/composer/path-refs.ts
Normal file
103
apps/desktop/src/app/chat/composer/path-refs.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
||||
|
|
|
|||
54
apps/desktop/src/app/chat/runtime-repository.test.ts
Normal file
54
apps/desktop/src/app/chat/runtime-repository.test.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -128,7 +128,6 @@ function TileChat({
|
|||
$awaitingInput: sessionAwaitingInput(runtimeId),
|
||||
$messages: view.$messages,
|
||||
attachments,
|
||||
popoutAllowed: false,
|
||||
target: `tile:${storedSessionId}`
|
||||
}),
|
||||
[attachments, runtimeId, storedSessionId, view.$messages]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
})
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
116
apps/desktop/src/app/open-session.test.ts
Normal file
116
apps/desktop/src/app/open-session.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
90
apps/desktop/src/app/open-session.ts
Normal file
90
apps/desktop/src/app/open-session.ts
Normal 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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
150
apps/desktop/src/components/assistant-ui/tool/delegate-model.ts
Normal file
150
apps/desktop/src/components/assistant-ui/tool/delegate-model.ts
Normal 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'
|
||||
158
apps/desktop/src/components/assistant-ui/tool/delegate.tsx
Normal file
158
apps/desktop/src/components/assistant-ui/tool/delegate.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
28
apps/desktop/src/components/assistant-ui/tool/run-ticker.tsx
Normal file
28
apps/desktop/src/components/assistant-ui/tool/run-ticker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ⌘1…⌘9 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)
|
||||
|
||||
/** ⌘1…⌘9: 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 (⌘1…⌘9, ⌃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
|
||||
})
|
||||
}
|
||||
|
||||
/** ⌘1…⌘9: 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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue