Merge remote-tracking branch 'origin/main' into HEAD

# Conflicts:
#	tests/run_agent/test_run_agent_codex_responses.py
This commit is contained in:
joaomarcos
2026-07-31 02:43:12 -03:00
2562 changed files with 54252 additions and 392295 deletions
+28
View File
@@ -53,7 +53,19 @@ jobs:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# Retry once on transient Docker Hub / buildkit pull failures
# (connection reset, auth token timeout, rate limiting). The action
# generates a unique builder name per invocation so the retry doesn't
# collide with the failed first attempt. A genuine persistent failure
# still fails the job — only the first attempt has continue-on-error.
# Refs: docker/setup-buildx-action#510
- name: Set up Docker Buildx
id: buildx
continue-on-error: true
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Set up Docker Buildx (retry)
if: steps.buildx.outcome == 'failure'
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
# Build once, load into the local daemon for testing. Cached
@@ -146,7 +158,15 @@ jobs:
- name: Checkout trusted source
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# Retry once on transient Docker Hub / buildkit pull failures.
# See build job for rationale; same pattern.
- name: Set up Docker Buildx
id: buildx
continue-on-error: true
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Set up Docker Buildx (retry)
if: steps.buildx.outcome == 'failure'
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Log in to Docker Hub
@@ -208,7 +228,15 @@ jobs:
pattern: digest-*
merge-multiple: true
# Retry once on transient Docker Hub / buildkit pull failures.
# See build job for rationale; same pattern.
- name: Set up Docker Buildx
id: buildx
continue-on-error: true
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Set up Docker Buildx (retry)
if: steps.buildx.outcome == 'failure'
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Log in to Docker Hub
+16 -2
View File
@@ -97,9 +97,18 @@ jobs:
# fails if the lock is out of sync with pyproject.toml), giving a
# reproducible env. It also creates .venv itself, so no separate
# `uv venv` step is needed.
#
# The trailing extras beyond all/dev are the lazy-install features
# (tools/lazy_deps.py) that tests exercise for real: provider.anthropic,
# stt/tts.mistral, image.fal, terminal.modal, terminal.daytona,
# memory.hindsight, search.parallel. The hermetic test env forbids
# mid-run pip installs (HERMES_DISABLE_LAZY_INSTALLS=1 in
# tests/conftest.py), so the SDKs those tests need must be in the
# venv up front — resolved from uv.lock like everything else, which
# also honors the exact supply-chain pins these extras carry.
uses: ./.github/actions/retry
with:
command: uv sync --locked --python 3.11 --extra all --extra dev
command: uv sync --locked --python 3.11 --extra all --extra dev --extra anthropic --extra mistral --extra fal --extra modal --extra daytona --extra hindsight --extra parallel-web
- name: Minimize uv cache
# Optimized for CI: prunes pre-built wheels that are cheap to
@@ -216,9 +225,14 @@ jobs:
# fails if the lock is out of sync with pyproject.toml), giving a
# reproducible env. It also creates .venv itself, so no separate
# `uv venv` step is needed.
#
# Same extras as the test job's sync above: the hermetic test env
# forbids mid-run pip installs (HERMES_DISABLE_LAZY_INSTALLS=1 in
# tests/conftest.py), so lazy-install SDKs exercised by tests must be
# in the venv up front.
uses: ./.github/actions/retry
with:
command: uv sync --locked --python 3.11 --extra all --extra dev
command: uv sync --locked --python 3.11 --extra all --extra dev --extra anthropic --extra mistral --extra fal --extra modal --extra daytona --extra hindsight --extra parallel-web
- name: Minimize uv cache
# Optimized for CI: prunes pre-built wheels that are cheap to
+4
View File
@@ -190,3 +190,7 @@ infographics/
infograficos/
infografico/
native/fts5_cjk/*.so
# Runtime marker written by hermes update when a lazy dependency refresh is
# interrupted; consumed by launch-time recovery. Never commit it (was tracked
# by accident via 3a69e34702, removed in the #72002 salvage).
.lazy-refresh-incomplete
-2
View File
@@ -1,2 +0,0 @@
started=1785343166.9711895
pid=2093896
+146
View File
@@ -0,0 +1,146 @@
# Message reactions (desktop tapbacks)
Two-way emoji reactions on individual messages in the desktop transcript: the
user reacts to any message, the agent reacts to a user message, and both sides
read the other's reactions as conversational signal.
## What already exists
Hermes already models reactions on the **platform** side — the desktop is the
only surface without them.
| Surface | Reaction support | Where |
|---|---|---|
| Agent → platform message | `send_message(action="react"/"unreact")` | `tools/send_message_tool.py:266` `_handle_react()` |
| Photon / iMessage | tapbacks in + out, routed only for messages we sent | `plugins/platforms/photon/adapter.py:1240-1283` |
| Telegram | `setMessageReaction`, config-gated | `plugins/platforms/telegram/adapter.py:9669+` |
| Slack / Matrix / Feishu / Discord | inbound reaction events → hooks | `gateway/run.py:4688` `_handle_reaction_event()` → `HookRegistry.emit("reaction:added")` |
| Adapter contract | `add_reaction()` / `remove_reaction()` coroutines, `set_reaction_handler()` | `gateway/platforms/base.py:3330` |
| Core "affection" detector | regex on user text → `vibe`, drives CLI pet / TUI heart / desktop hearts | `agent/reactions.py`, `agent/turn_context.py:592-604` |
Two things follow from that table:
1. **The agent-facing verb already exists.** `send_message(action="react")` is
the established shape. A desktop reaction should extend that tool, not add a
new core tool — every new tool ships on every API call (AGENTS.md footprint
ladder).
2. **The inbound convention already exists.** Photon turns a tapback into a
normal message event with `reply_to_message_id` + `reply_to_is_own_message`,
and the gateway prefixes `[Replying to your previous message: "…"]`
(`gateway/run.py:13125-13132`). Desktop reactions should read the same way to
the model.
Nothing exists on the desktop side: `grep -ri reaction` across `apps/desktop`
finds only the pet-overlay hearts.
## Prior art
**iOS Tapback** ([Apple](https://support.apple.com/guide/iphone/react-with-tapbacks-iph018d3c336/ios)):
double-tap or touch-and-hold a message → floating pill above the bubble with
heart / thumbs-up / thumbs-down / haha / ‼️ / ❓, swipe left for suggested emoji
and stickers, or tap the emoji button for the full keyboard. **One tapback per
message per person** — tapping the same one again removes it, tapping a
different one replaces it. Multiple people's tapbacks stack on the badge.
**Platform data models** converge on the same shape:
| Platform | Model | Add / remove |
|---|---|---|
| Slack | `{name, count, users[]}` | [`reactions.add`](https://docs.slack.dev/reference/methods/reactions.add) / `reactions.remove`, emits `reaction_added` |
| Discord | `{emoji, count, me}` on the message object | `PUT`/`DELETE .../reactions/{emoji}/@me` |
| Telegram | `reaction: [{type:"emoji", emoji:"👍"}]` — replaces the whole set | `setMessageReaction`, `is_big` for the big animation |
Telegram's "set the whole array" is the closest match to iOS semantics and the
simplest thing to persist.
**assistant-ui has no reaction primitive.** `@assistant-ui/react` 0.14.24 (MIT,
vendored at `apps/desktop/node_modules`): zero hits for "reaction" in `core/src`,
`react/src`, `dist/`, or the 2.2 MB `llms-full.txt` docs dump. What exists is a
hard-coded binary `FeedbackAdapter` (`"positive" | "negative"`,
`core/src/adapters/feedback.ts`) that throws when unconfigured and only writes
back onto assistant messages. Not usable for emoji, not usable on user messages.
**But `metadata.custom` is the supported extension channel** and this repo
already uses it: `ThreadUserMessage`/`ThreadAssistantMessage`/`ThreadSystemMessage`
all carry `metadata.custom: Record<string, unknown>` (`core/src/types/message.ts:319-366`),
and `chat-runtime.ts:397` already ships `custom: { attachmentRefs }` through it.
**Emoji picker survey** (npm week of 2026-07-22, sizes measured from the
published ESM entry):
| Library | License | Weekly DL | gzip | Headless | Latest |
|---|---|---|---|---|---|
| **frimousse** | MIT | 573k | **8.5 kB** | ✅ fully unstyled, composable parts | 0.3.0 · 2025-07-15 |
| emoji-picker-react | MIT | 1.31M | 87 kB | ❌ own CSS-in-JS (flairup) | 4.19.1 · 2026-04-27 |
| emoji-mart | MIT | 2.22M | ~120 kB w/ data | ❌ Preact + shadow styling | 5.6.0 · **2024-04-25**, 217 open issues |
| emoji-picker-element | Apache-2.0 | 183k | — | ❌ Web Component / Shadow DOM | 1.29.1 · 2026-03-01 |
No picker is currently a dependency (only `emoji-regex`, transitive). Already
paid for and reusable: `radix-ui` (Popover), `motion`, `@tanstack/react-virtual`,
Tailwind v4.
## Recommendation
**Hand-roll the tapback pill; add frimousse only behind the "+".** Six fixed
emoji in a pill is ~40 lines of JSX against existing tokens — pulling 87 kB of
`emoji-picker-react` to render six buttons, plus a CSS engine that fights
`DESIGN.md`, is backwards. frimousse is headless, dependency-free, 10× smaller,
and exposes `emojibaseUrl` so the data can be bundled as a Vite asset instead of
hitting jsDelivr (Electron must work offline).
### Data model
One reaction per author per message, Telegram-style whole-set replacement:
```ts
type MessageReaction = { emoji: string; author: 'user' | 'agent'; at: number }
```
Persisted in the existing `messages.display_metadata` JSON column
(`hermes_state_common.py:215`) — no new table. It already survives insert,
compaction, and every read projection, and
`set_latest_matching_message_display_kind()` (`hermes_state.py:5292`) is the
precedent for stamping metadata onto an already-persisted row.
### Model context
Reactions must reach the model **without breaking prompt caching**. The
`api_messages` build loop strips `display_metadata` from every outgoing copy
(`agent/conversation_loop.py:1443-1446`) precisely so display state never
becomes a provider field. Two candidate paths:
| Path | Cache impact | Notes |
|---|---|---|
| Rewrite the reacted-to message's content to carry the annotation | **Breaks the cached prefix** — mutates past context | Rejected. AGENTS.md: prompt caching is sacred. |
| Deliver the reaction as the *next* turn's leading annotation, mirroring photon | Prefix untouched; only the new turn carries it | Matches `[Replying to your previous message: "…"]` (`gateway/run.py:13125`), which the agent already understands |
The second is the same trick the platform adapters already use, so the model
sees a familiar shape and no existing conversation is rewritten.
### Attach points
| Concern | File | Lines |
|---|---|---|
| Assistant hover bar | `apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx` | 134–175 |
| User hover cluster | `apps/desktop/src/components/assistant-ui/thread/user-message.tsx` | 296–336 |
| Callback threading (ref caveat 79–99) | `apps/desktop/src/components/assistant-ui/thread/index.tsx` | 109–133 |
| `metadata.custom` → runtime | `apps/desktop/src/lib/chat-runtime.ts` | 384–432 |
| RPC client ↔ server pattern | `sidebar/session-actions-menu.tsx:62-89` ↔ `tui_gateway/server.py:8322` | — |
| Persistence | `hermes_state_common.py:192-216`, `hermes_state.py:5292-5324` | — |
| Prompt injection / strip | `agent/conversation_loop.py` | 1430–1529 |
### Known gaps to solve first
- **No durable message id crosses the gateway RPC path.** `_history_to_messages()`
(`tui_gateway/server.py:6545`) builds `{"role", "text"}` and drops the id. The
REST path carries `messages.id` incidentally via `SELECT *` but TS
`SessionMessage` (`types/hermes.ts:513-533`) doesn't declare it. Renderer ids
are ephemeral and change shape between rehydrated (`<ts>-<i>-<role>`), live
(`assistant-<ms>`), and optimistic (`user-<ms>-<rand>`) messages. A reaction
needs a stable key — this is the first thing to fix.
- **WeakMap identity cache** in `apps/desktop/src/app/chat/runtime-repository.ts:26-66`
keys normalized `ThreadMessage` by `ChatMessage` identity. A reaction change
must produce a **new** `ChatMessage` object or the UI renders stale.
- **Rewind rewrites rows** (`replace_messages`), so anything keyed by row id
needs cascade handling — an argument for keeping reactions in
`display_metadata` on the row itself rather than a side table.
+3 -2
View File
@@ -1284,14 +1284,15 @@ def profile_env(tmp_path, monkeypatch):
### Python
**ALWAYS use `scripts/run_tests.sh`** — do not call `pytest` directly. The script enforces
hermetic environment parity with CI (unset credential vars, TZ=UTC, LANG=C.UTF-8,
`-n auto` xdist workers, in-tree subprocess-isolation plugin). Direct `pytest`
per-file subprocess isolation via `scripts/run_tests_parallel.py` — no xdist,
worker count auto-scaled from CPU count). Direct `pytest`
on a 16+ core developer machine with API keys set diverges from CI in ways
that have caused multiple "works locally, fails in CI" incidents (and the reverse).
```bash
scripts/run_tests.sh # full suite, CI-parity
scripts/run_tests.sh tests/gateway/ # one directory
scripts/run_tests.sh tests/agent/test_foo.py::test_x # one test
scripts/run_tests.sh tests/agent/test_foo.py -k test_x # one test (file + -k; the runner is file-granular)
scripts/run_tests.sh -v --tb=long # pass-through pytest flags
```
+3 -2
View File
@@ -201,7 +201,8 @@ ln -sf "$(pwd)/venv/bin/hermes" ~/.local/bin/hermes
### Run tests
```bash
# Preferred — matches CI (hermetic env, 4 xdist workers); see AGENTS.md
# Preferred — matches CI (hermetic `env -i`, per-file subprocess isolation
# via run_tests_parallel.py, worker count auto-scaled); see AGENTS.md
scripts/run_tests.sh
# Alternative (activate the venv first). The wrapper is still recommended
@@ -848,7 +849,7 @@ that touches the OS, assume *any* platform can hit your code path.
Tests that use POSIX-only syscalls need a skip marker. Common ones:
- Symlinks → `@pytest.mark.skipif(sys.platform == "win32", ...)`
- `0o600` file modes → `@pytest.mark.skipif(sys.platform.startswith("win"), ...)`
- `signal.SIGALRM` → Unix-only (see `tests/conftest.py::_enforce_test_timeout`)
- `signal.SIGALRM` → Unix-only (per-test timeouts no longer use it directly; see the win32 timeout-method shim in `tests/conftest.py::pytest_configure`)
- `os.setsid` / `os.fork` → Unix-only
- Live Winsock / Windows-specific regression tests →
`@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific regression")`
+1 -1
View File
@@ -26,7 +26,7 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), OpenR
<tr><td><b>A closed learning loop</b></td><td>Agent-curated memory with periodic nudges. Autonomous skill creation after complex tasks. Skills self-improve during use. FTS5 session search with LLM summarization for cross-session recall. <a href="https://github.com/plastic-labs/honcho">Honcho</a> dialectic user modeling. Compatible with the <a href="https://agentskills.io">agentskills.io</a> open standard.</td></tr>
<tr><td><b>Scheduled automations</b></td><td>Built-in cron scheduler with delivery to any platform. Daily reports, nightly backups, weekly audits — all in natural language, running unattended.</td></tr>
<tr><td><b>Delegates and parallelizes</b></td><td>Spawn isolated subagents for parallel workstreams. Write Python scripts that call tools via RPC, collapsing multi-step pipelines into zero-context-cost turns.</td></tr>
<tr><td><b>Runs anywhere, not just your laptop</b></td><td>Six terminal backends — local, Docker, SSH, Singularity, Modal, and Daytona. Daytona and Modal offer serverless persistence — your agent's environment hibernates when idle and wakes on demand, costing nearly nothing between sessions. Run it on a $5 VPS or a GPU cluster.</td></tr>
<tr><td><b>Runs anywhere, not just your laptop</b></td><td>Seven terminal backends — local, Docker, SSH, Singularity, Modal, Daytona, and Vercel Sandbox. Daytona and Modal offer serverless persistence — your agent's environment hibernates when idle and wakes on demand, costing nearly nothing between sessions. Run it on a $5 VPS or a GPU cluster.</td></tr>
<tr><td><b>Research-ready</b></td><td>Batch trajectory generation, trajectory compression for training the next generation of tool-calling models.</td></tr>
</table>
+8 -11
View File
@@ -455,8 +455,7 @@ def init_agent(
command: str = None,
args: list[str] | None = None,
model: str = "",
max_iterations: int = 500, # Default tool-calling iterations (shared with subagents)
tool_delay: float = 1.0,
max_iterations: int = 90, # Default tool-calling iterations (shared with subagents)
enabled_toolsets: List[str] = None,
disabled_toolsets: List[str] = None,
save_trajectories: bool = False,
@@ -529,8 +528,7 @@ def init_agent(
requested_provider (str): Original provider identity before runtime canonicalization
api_mode (str): API mode override: "chat_completions" or "codex_responses"
model (str): Model name to use (default: "anthropic/claude-opus-4.6")
max_iterations (int): Maximum number of tool calling iterations (default: 500)
tool_delay (float): Delay between tool calls in seconds (default: 1.0)
max_iterations (int): Maximum number of tool calling iterations (default: 90)
enabled_toolsets (List[str]): Only enable tools from these toolsets (optional)
disabled_toolsets (List[str]): Disable tools from these toolsets (optional)
save_trajectories (bool): Whether to save conversation trajectories to JSONL files (default: False)
@@ -576,7 +574,6 @@ def init_agent(
# Shared iteration budget — parent creates, children inherit.
# Consumed by every LLM turn across parent + all subagents.
agent.iteration_budget = iteration_budget or IterationBudget(max_iterations)
agent.tool_delay = tool_delay
agent.save_trajectories = save_trajectories
agent.verbose_logging = verbose_logging
agent.quiet_mode = quiet_mode
@@ -843,7 +840,7 @@ def init_agent(
# sessions with >5-minute pauses between turns (#14971).
agent._cache_ttl = "5m"
try:
from hermes_cli.config import load_config as _load_pc_cfg
from hermes_cli.config import load_config_readonly as _load_pc_cfg
_pc_cfg = _load_pc_cfg().get("prompt_caching", {}) or {}
_ttl = _pc_cfg.get("cache_ttl", "5m")
@@ -1093,7 +1090,7 @@ def init_agent(
# Guardrail config — read from config.yaml at init time.
agent._bedrock_guardrail_config = None
try:
from hermes_cli.config import load_config as _load_br_cfg
from hermes_cli.config import load_config_readonly as _load_br_cfg
_gr = _load_br_cfg().get("bedrock", {}).get("guardrail", {})
if _gr.get("guardrail_identifier") and _gr.get("guardrail_version"):
agent._bedrock_guardrail_config = {
@@ -1164,8 +1161,8 @@ def init_agent(
client_kwargs["default_headers"] = hermes_xai_default_headers()
elif "default_headers" not in client_kwargs:
# Fall back to profile.default_headers for providers that
# declare custom headers (e.g. Kimi User-Agent on non-kimi.com
# endpoints).
# declare custom headers (e.g. Vercel AI Gateway attribution,
# Kimi User-Agent on non-kimi.com endpoints).
try:
from providers import get_provider_profile as _gpf
_ph = _gpf(agent.provider)
@@ -1480,7 +1477,7 @@ def init_agent(
# reads the JSON files directly. See run_agent._save_session_log.
agent._session_json_enabled = False
try:
from hermes_cli.config import load_config as _load_sess_cfg
from hermes_cli.config import load_config_readonly as _load_sess_cfg
_sess_cfg = (_load_sess_cfg().get("sessions") or {})
agent._session_json_enabled = bool(_sess_cfg.get("write_json_snapshots", False))
except Exception:
@@ -1551,7 +1548,7 @@ def init_agent(
# Load config once for memory, skills, and compression sections
try:
from hermes_cli.config import load_config as _load_agent_config
from hermes_cli.config import load_config_readonly as _load_agent_config
_agent_cfg = _load_agent_config()
except Exception:
_agent_cfg = {}
+14 -100
View File
@@ -3331,89 +3331,17 @@ def intent_ack_continuation_enabled(agent) -> bool:
def copy_reasoning_content_for_api(agent, source_msg: dict, api_msg: dict) -> None:
"""Copy provider-facing reasoning fields onto an API replay message."""
if source_msg.get("role") != "assistant":
return
"""Copy provider-facing reasoning fields onto an API replay message.
needs_thinking_pad = agent._needs_thinking_reasoning_pad()
Forwarder — the strip-vs-repad POLICY is owned by
``agent.message_sanitization.apply_reasoning_content_policy`` (audit F4);
this only supplies the agent's cached provider-direction flag.
"""
from agent.message_sanitization import apply_reasoning_content_policy
# 1. Explicit reasoning_content already set.
#
# When the active provider enforces the thinking-mode echo-back
# (DeepSeek / Kimi / MiMo), preserve it verbatim — that includes their
# own space-placeholder written at creation time and any valid reasoning
# from the same provider. Sessions persisted BEFORE #17341 have
# empty-string placeholders pinned at creation time; DeepSeek V4 Pro
# rejects those with HTTP 400, so upgrade "" → " " on replay.
#
# When the active provider does NOT enforce echo-back, strip the field
# entirely. Strict OpenAI-compatible providers (Mistral, Cerebras, Groq,
# SambaNova, …) reject ANY reasoning_content key in input messages with
# HTTP 400/422 ("Extra inputs are not permitted"), even an empty string
# or a single-space pad. This is the cross-provider fallback case: a
# reasoning primary (DeepSeek/Kimi/MiMo) pads history with " ", then a
# fallback to a strict provider replays that pad and 422s. Stripping
# here covers the rebuild path; reapply_reasoning_echo_for_provider()
# covers the already-built api_messages path. Refs #45655.
existing = source_msg.get("reasoning_content")
if isinstance(existing, str):
if not needs_thinking_pad:
api_msg.pop("reasoning_content", None)
elif existing == "":
api_msg["reasoning_content"] = " "
else:
api_msg["reasoning_content"] = existing
return
# 2. Cross-provider poisoned history (#15748): on DeepSeek/Kimi,
# if the source turn has tool_calls AND a 'reasoning' field but no
# 'reasoning_content' key, the 'reasoning' text was written by a
# prior provider (e.g. MiniMax) — DeepSeek's own _build_assistant_message
# pins reasoning_content at creation time for tool-call turns, so the
# shape (reasoning set, reasoning_content absent, tool_calls present)
# is unreachable from same-provider DeepSeek history after this fix.
# Inject a single space to satisfy the API without leaking another
# provider's chain of thought to DeepSeek/Kimi. Space (not "")
# because DeepSeek V4 Pro rejects empty-string reasoning_content
# in thinking mode (refs #17341).
normalized_reasoning = source_msg.get("reasoning")
if (
needs_thinking_pad
and source_msg.get("tool_calls")
and isinstance(normalized_reasoning, str)
and normalized_reasoning
):
api_msg["reasoning_content"] = " "
return
# 3. Healthy session: promote 'reasoning' field to 'reasoning_content'
# for providers that use the internal 'reasoning' key.
# This must happen before the unconditional empty-string fallback so
# genuine reasoning content is not overwritten (#15812 regression in
# PR #15478). Only promote for providers that enforce echo-back —
# strict providers reject the field (refs #45655).
if isinstance(normalized_reasoning, str) and normalized_reasoning:
if needs_thinking_pad:
api_msg["reasoning_content"] = normalized_reasoning
else:
api_msg.pop("reasoning_content", None)
return
# 4. DeepSeek / Kimi thinking mode: all assistant messages need
# reasoning_content. Inject a single space to satisfy the provider's
# requirement when no explicit reasoning content is present. Covers
# both tool-call turns (already-poisoned history with no reasoning
# at all) and plain text turns. Space (not "") because DeepSeek V4
# Pro tightened validation and rejects empty string with HTTP 400
# ("The reasoning content in the thinking mode must be passed back
# to the API"). Refs #17341.
if needs_thinking_pad:
api_msg["reasoning_content"] = " "
return
# 5. reasoning_content was present but not a string (e.g. None after
# context compaction). Don't pass null to the API.
api_msg.pop("reasoning_content", None)
apply_reasoning_content_policy(
source_msg, api_msg, agent._needs_thinking_reasoning_pad()
)
def reapply_reasoning_echo_for_provider(agent, api_messages: list) -> int:
@@ -3445,25 +3373,11 @@ def reapply_reasoning_echo_for_provider(agent, api_messages: list) -> int:
Returns the number of assistant turns whose reasoning_content was added or
removed.
"""
needs_pad = agent._needs_thinking_reasoning_pad()
changed = 0
for api_msg in api_messages:
if api_msg.get("role") != "assistant":
continue
if needs_pad:
if api_msg.get("reasoning_content"):
continue
copy_reasoning_content_for_api(agent, api_msg, api_msg)
if api_msg.get("reasoning_content"):
changed += 1
else:
# Strict provider — strip any stale reasoning_content pad left
# over from a reasoning primary so the fallback request doesn't
# 400/422 on it.
if "reasoning_content" in api_msg:
api_msg.pop("reasoning_content", None)
changed += 1
return changed
from agent.message_sanitization import reapply_reasoning_echo
return reapply_reasoning_echo(
api_messages, agent._needs_thinking_reasoning_pad()
)
def _iter_httpx_pool_objects(http_client: Any):
+29 -18
View File
@@ -543,6 +543,7 @@ _API_KEY_PROVIDER_AUX_MODELS_FALLBACK: Dict[str, str] = {
"kimi-coding-cn": "kimi-k2-turbo-preview",
"gmi": "google/gemini-3.1-flash-lite-preview",
"anthropic": "claude-haiku-4-5-20251001",
"ai-gateway": "google/gemini-3-flash",
"opencode-zen": "gemini-3-flash",
"opencode-go": "glm-5",
"kilocode": "google/gemini-3.6-flash",
@@ -676,15 +677,15 @@ def build_or_headers(or_config: dict | None = None) -> dict:
Overrides ``openrouter.response_cache_ttl`` in config.yaml.
*or_config* is the ``openrouter`` section from config.yaml. When *None*,
falls back to reading config from disk via ``load_config()``.
falls back to reading config from disk via ``load_config_readonly()``.
"""
headers = dict(_OR_HEADERS_BASE)
# Resolve config from disk if not provided.
if or_config is None:
try:
from hermes_cli.config import load_config
or_config = load_config().get("openrouter", {})
from hermes_cli.config import load_config_readonly
or_config = load_config_readonly().get("openrouter", {})
except Exception:
or_config = {}
@@ -729,6 +730,15 @@ def build_nvidia_nim_headers(base_url: str | None) -> dict:
return {}
# Vercel AI Gateway app attribution headers. HTTP-Referer maps to
# referrerUrl and X-Title maps to appName in the gateway's analytics.
from hermes_cli import __version__ as _HERMES_VERSION
_AI_GATEWAY_HEADERS = {
"HTTP-Referer": "https://hermes-agent.nousresearch.com",
"X-Title": "Hermes Agent",
"User-Agent": f"HermesAgent/{_HERMES_VERSION}",
}
# Nous Portal extra_body for product attribution.
# Callers should pass this as extra_body in chat.completions.create()
@@ -2317,8 +2327,8 @@ def _read_main_model() -> str:
if isinstance(override, str) and override.strip():
return override.strip()
try:
from hermes_cli.config import load_config
cfg = load_config()
from hermes_cli.config import load_config_readonly
cfg = load_config_readonly()
model_cfg = cfg.get("model", {})
if isinstance(model_cfg, str) and model_cfg.strip():
return model_cfg.strip()
@@ -2344,8 +2354,8 @@ def _read_main_provider() -> str:
if isinstance(override, str) and override.strip():
return override.strip().lower()
try:
from hermes_cli.config import load_config
cfg = load_config()
from hermes_cli.config import load_config_readonly
cfg = load_config_readonly()
model_cfg = cfg.get("model", {})
if isinstance(model_cfg, dict):
provider = model_cfg.get("provider", "")
@@ -3040,12 +3050,12 @@ def _try_azure_foundry(
try:
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
from hermes_cli.auth import AuthError
from hermes_cli.config import load_config
from hermes_cli.config import load_config_readonly
except ImportError:
return None, None
try:
cfg = load_config()
cfg = load_config_readonly()
model_cfg = cfg.get("model") if isinstance(cfg, dict) else {}
if not isinstance(model_cfg, dict):
model_cfg = {}
@@ -3159,8 +3169,8 @@ def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optiona
# see issue #52608.
base_url = _pool_runtime_base_url(entry, _ANTHROPIC_DEFAULT_BASE_URL) if pool_present else _ANTHROPIC_DEFAULT_BASE_URL
try:
from hermes_cli.config import load_config
cfg = load_config()
from hermes_cli.config import load_config_readonly
cfg = load_config_readonly()
model_cfg = cfg.get("model")
if isinstance(model_cfg, dict):
cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
@@ -4764,10 +4774,10 @@ def _try_main_fallback_chain(
participate in the same order as the main agent.
"""
try:
from hermes_cli.config import load_config
from hermes_cli.config import load_config_readonly
from hermes_cli.fallback_config import get_fallback_chain
chain = get_fallback_chain(load_config())
chain = get_fallback_chain(load_config_readonly())
except Exception as exc:
logger.debug("Auxiliary %s: could not load main fallback chain: %s", task or "call", exc)
return None, None, ""
@@ -5725,7 +5735,8 @@ def resolve_provider_client(
else:
# Fall back to profile.default_headers for providers that declare
# client-level attribution headers on their profile (e.g. GMI
# User-Agent for traffic identification).
# User-Agent for traffic identification, Vercel AI Gateway
# Referer/Title for analytics).
try:
from providers import get_provider_profile as _gpf_main
_ph_main = _gpf_main(provider)
@@ -5986,11 +5997,11 @@ def _main_model_supports_vision(provider: str, model: Optional[str]) -> bool:
"""
try:
from agent.image_routing import _lookup_supports_vision
from hermes_cli.config import load_config
from hermes_cli.config import load_config_readonly
except ImportError:
return True
try:
supports = _lookup_supports_vision(provider, model, load_config())
supports = _lookup_supports_vision(provider, model, load_config_readonly())
except Exception: # pragma: no cover - defensive
return True
if supports is None:
@@ -6959,8 +6970,8 @@ def _get_auxiliary_task_config(task: str) -> Dict[str, Any]:
if not task:
return {}
try:
from hermes_cli.config import load_config
config = load_config()
from hermes_cli.config import load_config_readonly
config = load_config_readonly()
except ImportError:
return {}
aux = config.get("auxiliary", {}) if isinstance(config, dict) else {}
+2 -2
View File
@@ -70,8 +70,8 @@ def _resolve_review_runtime(agent: Any) -> Dict[str, Any]:
"routed": False,
}
try:
from hermes_cli.config import load_config
cfg = load_config()
from hermes_cli.config import load_config_readonly
cfg = load_config_readonly()
except Exception:
return parent
aux = cfg.get("auxiliary", {}) if isinstance(cfg.get("auxiliary"), dict) else {}
+5 -4
View File
@@ -18,6 +18,7 @@ import uuid
from types import SimpleNamespace
from typing import Any, Dict, List, Optional
from agent.message_sanitization import deterministic_call_id
from agent.prompt_builder import DEFAULT_AGENT_IDENTITY
logger = logging.getLogger(__name__)
@@ -182,13 +183,13 @@ def _summarize_user_message_for_log(content: Any, *, sep: str = " ") -> str:
def _deterministic_call_id(fn_name: str, arguments: str, index: int = 0) -> str:
"""Generate a deterministic call_id from tool call content.
Used as a fallback when the API doesn't provide a call_id.
Thin wrapper over the single policy owner
``agent.message_sanitization.deterministic_call_id`` (audit F4) — kept
as a module-level name because run_agent and tests import it from here.
Deterministic IDs prevent cache invalidation — random UUIDs would
make every API call's prefix unique, breaking OpenAI's prompt cache.
"""
seed = f"{fn_name}:{arguments}:{index}"
digest = hashlib.sha256(seed.encode("utf-8", errors="replace")).hexdigest()[:12]
return f"call_{digest}"
return deterministic_call_id(fn_name, arguments, index)
def _clamp_responses_call_id(call_id: str) -> str:
+2 -2
View File
@@ -337,9 +337,9 @@ def _coding_mode(config: Optional[dict[str, Any]]) -> str:
"""Return the normalized ``agent.coding_context`` mode (auto/focus/on/off)."""
if config is None:
try:
from hermes_cli.config import load_config
from hermes_cli.config import load_config_readonly
config = load_config()
config = load_config_readonly()
except Exception:
config = {}
raw = ((config or {}).get("agent", {}) or {}).get("coding_context", "auto")
+83 -6
View File
@@ -170,6 +170,35 @@ def _fresh_compaction_message_copy(msg: Dict[str, Any]) -> Dict[str, Any]:
return fresh
def _template_visible_role(message: Any) -> Optional[str]:
"""Role as counted by strict chat-template alternation checks.
Mistral-family templates (Devstral, Mistral Small 3.x, Magistral)
enforce user/assistant alternation at render time but EXEMPT the tool
flow from the check: ``tool`` results and assistant messages carrying
``tool_calls`` are skipped. A summary role chosen against the *literal*
neighbouring roles can therefore still violate alternation as the
template sees it. The canonical failure: the protected head ends
``[user, assistant(tool_calls), tool]``, so the literal last role is
``tool`` and the summary is pinned to ``role="user"`` -- but the last
role the template counts is ``user``, the template sees user -> user,
and llama.cpp / Mistral-hosted backends reject the ENTIRE request with
a Jinja alternation error (HTTP 500). Because the summary persists in
the stored conversation, every retry replays the same poisoned history
and the session is unrecoverable.
Returns ``None`` for messages the alternation check skips.
"""
if not isinstance(message, dict):
return None
role = message.get("role")
if role == "tool":
return None
if role == "assistant" and message.get("tool_calls"):
return None
return role
def _strip_persistence_markers(messages: List[Dict[str, Any]]) -> None:
"""Enforce the compaction invariant: no assembled message carries a
session-store persistence marker.
@@ -5420,8 +5449,43 @@ This compaction should PRIORITISE preserving all information related to the focu
# last_head_role reads the assembled (post-strip) head; first_tail_role
# reads the assembled (post-strip) tail_messages — a stripped stale
# handoff must not influence alternation-safe role selection.
last_head_role = compressed[-1].get("role", "user") if compressed else "user"
first_tail_role = tail_messages[0].get("role", "user") if tail_messages else None
# Both are TEMPLATE-VISIBLE roles (``_template_visible_role``), not the
# literal list neighbours: strict Mistral-style templates skip tool
# results and assistant tool-call messages when enforcing
# user/assistant alternation, so the summary must alternate against
# the nearest message the template actually counts. Selecting against
# the literal neighbour (previously ``compressed[-1]``) emitted the
# summary as role="user" behind a ``[user, assistant(tool_calls),
# tool]`` head — which every Mistral-strict backend rejects with a
# Jinja alternation 500, permanently poisoning the session.
last_head_role: Optional[str] = "user"
if compressed:
last_head_role = next(
(
role
for role in (
_template_visible_role(m) for m in reversed(compressed)
)
if role is not None
),
# Head holds only template-exempt messages: the summary will
# be the first message the template counts, and the sequence
# must open with "user" (handled below alongside the forced
# cases).
None,
)
first_tail_role = None
if tail_messages:
first_tail_role = next(
(
role
for role in (
_template_visible_role(m) for m in tail_messages
)
if role is not None
),
None,
)
# When the only protected head message is the system prompt, the
# summary becomes the first *visible* message in the API request
# (most adapters — Anthropic, Bedrock — send the system prompt as
@@ -5455,9 +5519,15 @@ This compaction should PRIORITISE preserving all information related to the focu
)
if not _user_survives:
_force_user_leading = True
# Pick a role that avoids consecutive same-role with both neighbors.
# Priority: avoid colliding with head (already committed), then tail.
if last_head_role in {"assistant", "tool"} or _force_user_leading:
# Pick a role that alternates with both template-visible neighbors.
# Priority: alternate against the head (already committed), then tail.
# ``None`` (all-exempt head) means the summary opens the visible
# sequence, which strict templates require to start with "user".
if (
last_head_role is None
or last_head_role in {"assistant", "tool"}
or _force_user_leading
):
summary_role = "user"
else:
summary_role = "assistant"
@@ -5465,7 +5535,14 @@ This compaction should PRIORITISE preserving all information related to the focu
# collide with the head, flip it.
if first_tail_role is not None and summary_role == first_tail_role:
flipped = "assistant" if summary_role == "user" else "user"
if flipped != last_head_role and not _force_user_leading:
# ``last_head_role is None`` (all-exempt head) pins the summary to
# "user" above; flipping to "assistant" would make the visible
# sequence open with "assistant", which strict templates reject.
if (
flipped != last_head_role
and last_head_role is not None
and not _force_user_leading
):
summary_role = flipped
else:
# Both roles would create consecutive same-role messages
+6
View File
@@ -1445,6 +1445,12 @@ def run_conversation(
api_msg.pop("display_kind", None)
api_msg.pop("display_metadata", None)
# Durable row identity stamped by _rows_to_conversation so the
# desktop can address a specific persisted message (reactions).
# Bookkeeping, never a provider field — only the chat-completions
# transport strips underscore keys, so drop it centrally here.
api_msg.pop("_row_id", None)
# Inject ephemeral context into the current turn's user message.
# Sources: memory manager prefetch + plugin pre_llm_call hooks
# with target="user_message" (the default). Both are
+4 -4
View File
@@ -138,8 +138,8 @@ def is_paused() -> bool:
def _load_config() -> Dict[str, Any]:
"""Read curator.* config from ~/.hermes/config.yaml. Tolerates missing file."""
try:
from hermes_cli.config import load_config
cfg = load_config()
from hermes_cli.config import load_config_readonly
cfg = load_config_readonly()
except Exception as e:
logger.debug("Failed to load config for curator: %s", e)
return {}
@@ -1875,9 +1875,9 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]:
_acp_args = None
_model_name = ""
try:
from hermes_cli.config import load_config
from hermes_cli.config import load_config_readonly
from hermes_cli.runtime_provider import resolve_runtime_provider
_cfg = load_config()
_cfg = load_config_readonly()
_binding = _resolve_review_runtime(_cfg)
_provider, _model_name = _binding.provider, _binding.model
_rp = resolve_runtime_provider(
+2 -2
View File
@@ -147,8 +147,8 @@ def _utc_id(now: Optional[datetime] = None) -> str:
def _load_config() -> Dict[str, Any]:
try:
from hermes_cli.config import load_config
cfg = load_config()
from hermes_cli.config import load_config_readonly
cfg = load_config_readonly()
except Exception as e:
logger.debug("Failed to load config for curator backup: %s", e)
return {}
+2 -2
View File
@@ -197,8 +197,8 @@ def _config_language_cached() -> str | None:
(e.g. after the setup wizard).
"""
try:
from hermes_cli.config import load_config
cfg = load_config()
from hermes_cli.config import load_config_readonly
cfg = load_config_readonly()
lang = (cfg.get("display") or {}).get("language")
if lang:
return _normalize_lang(lang)
+2 -2
View File
@@ -91,9 +91,9 @@ def get_active_provider() -> Optional[ImageGenProvider]:
"""
configured: Optional[str] = None
try:
from hermes_cli.config import load_config
from hermes_cli.config import load_config_readonly
cfg = load_config()
cfg = load_config_readonly()
section = cfg.get("image_gen") if isinstance(cfg, dict) else None
if isinstance(section, dict):
raw = section.get("provider")
+2 -2
View File
@@ -196,8 +196,8 @@ class LSPService:
itself returns ``is_active()`` False when LSP is disabled.
"""
try:
from hermes_cli.config import load_config
cfg = load_config()
from hermes_cli.config import load_config_readonly
cfg = load_config_readonly()
except Exception as e: # noqa: BLE001
logger.debug("LSP config load failed: %s", e)
return None
+375
View File
@@ -14,6 +14,7 @@ re-exports from ``run_agent`` remain in place so existing imports
from __future__ import annotations
import hashlib
import json
import logging
import re
@@ -474,4 +475,378 @@ __all__ = [
"_sanitize_tools_non_ascii",
"_strip_images_from_messages",
"_sanitize_structure_non_ascii",
# call_id policy owners (F4 consolidation)
"deterministic_call_id",
"coalesce_tool_call_id",
"uniquify_tool_call_ids",
# reasoning_content policy owners (F4 consolidation)
"reasoning_echo_family",
"matches_reasoning_echo_family",
"needs_reasoning_echo",
"apply_reasoning_content_policy",
"reapply_reasoning_echo",
]
# ---------------------------------------------------------------------------
# call_id policy — single owner (audit F4, incident chain I4)
# ---------------------------------------------------------------------------
#
# Three forked policy sites converged here:
# * agent/codex_responses_adapter.py `_deterministic_call_id` — hash
# synthesis when a provider omits call_id (fa3ab2ffd0 → e45f2b39e2).
# * run_agent.AIAgent._get_tool_call_id_static — `call_id or id`
# coalescing for dicts and SDK objects.
# * run_agent.AIAgent._uniquify_tool_call_ids — duplicate-id repair with
# deterministic `_d<n>` suffixes (#58327 loss class).
#
# NOT consolidated (different scheme on purpose):
# agent/transports/codex_event_projector._deterministic_call_id maps codex
# app-server ITEM ids (`codex_<type>_<item_id>`), not chat tool-call
# content; merging the two would change ids and invalidate prompt caches.
#
# HARD INVARIANT: everything here must stay deterministic (never uuid4) and
# byte-identical for existing inputs — these ids feed prompt-cache prefixes.
def deterministic_call_id(fn_name: str, arguments: str, index: int = 0) -> str:
"""Generate a deterministic call_id from tool call content.
Used as a fallback when the API doesn't provide a call_id.
Deterministic IDs prevent cache invalidation — random UUIDs would
make every API call's prefix unique, breaking OpenAI's prompt cache.
"""
seed = f"{fn_name}:{arguments}:{index}"
digest = hashlib.sha256(seed.encode("utf-8", errors="replace")).hexdigest()[:12]
return f"call_{digest}"
def coalesce_tool_call_id(tc: Any) -> str:
"""Extract the effective call ID from a tool_call entry (dict or object).
Single owner for the ``call_id or id`` coalescing rule: Codex Responses
tool calls carry ``call_id`` (authoritative pairing key), Chat
Completions ones carry ``id`` only. Returns ``""`` when neither is set.
"""
if isinstance(tc, dict):
return (tc.get("call_id", "") or tc.get("id", "") or "").strip()
return (getattr(tc, "call_id", "") or getattr(tc, "id", "") or "").strip()
def uniquify_tool_call_ids(tool_calls: list) -> list:
"""Ensure every tool call in a single assistant turn has a distinct id.
Some models/providers reuse one call id across different calls in a
single batch (observed with native Kimi Responses replays, Ollama-
compatible endpoints, and degraded models at long context; same bug
class as openclaw/openclaw#110518 / #110956). Duplicate ids are lossy
downstream: the pre-API sanitizer keeps only the first call/result
pair per id (#58327), so the later call's result silently vanishes
from every replayed payload, and strict providers (Anthropic
tool_use, DeepSeek) reject duplicate ids outright.
The first occurrence keeps its id; later collisions get a
deterministic ``<id>_d<n>`` suffix — never a random UUID, which would
break prompt-cache prefix stability across replays. Mutates the
entries in place (SDK models / SimpleNamespace / dicts) and returns
the same list. Blank/missing ids are left for the deterministic
fallback in ``build_assistant_message``.
"""
seen: set = set()
for tc in tool_calls or []:
# Same coalescing rule as ``coalesce_tool_call_id`` but tolerant of
# non-string ids (degraded models can emit ints/None here).
if isinstance(tc, dict):
raw = tc.get("call_id") or tc.get("id") or ""
else:
raw = getattr(tc, "call_id", None) or getattr(tc, "id", None) or ""
raw = raw.strip() if isinstance(raw, str) else ""
if not raw:
continue
# Composite Responses ids ("call_x|fc_y") collide on the call
# half — that's the pairing key providers enforce per turn.
cid = raw.split("|", 1)[0]
if not cid:
continue
if cid not in seen:
seen.add(cid)
continue
n = 2
new_id = f"{cid}_d{n}"
while new_id in seen:
n += 1
new_id = f"{cid}_d{n}"
seen.add(new_id)
def _renamed(value):
# Preserve a composite id's response-item half so the
# provider's real fc_/item id survives the rename.
if isinstance(value, str) and "|" in value:
return f"{new_id}|{value.split('|', 1)[1]}"
return new_id
try:
if isinstance(tc, dict):
if tc.get("id"):
tc["id"] = _renamed(tc["id"])
else:
tc["id"] = new_id
if tc.get("call_id"):
tc["call_id"] = new_id
else:
tc.id = _renamed(getattr(tc, "id", None))
if getattr(tc, "call_id", None):
tc.call_id = new_id
except Exception:
logger.warning(
"Could not uniquify duplicate tool call id %s", cid
)
continue
_fn = tc.get("function") if isinstance(tc, dict) else getattr(tc, "function", None)
_fn_name = (_fn.get("name") if isinstance(_fn, dict) else getattr(_fn, "name", None)) or "?"
logger.warning(
"Model reused tool call id %s within one turn; renamed the "
"duplicate to %s (tool=%s) to keep call/result pairing "
"lossless.", cid, new_id, _fn_name,
)
return tool_calls
# ---------------------------------------------------------------------------
# reasoning_content policy — single owner (audit F4)
# ---------------------------------------------------------------------------
#
# The strip-vs-repad decision was previously forked across the wire files in
# separate incident commits (2b3a4f0af8 strip for strict providers,
# b5495db701 re-pad for require-side, 94b3131be7/9a9f8a6d99 kimi pad). The
# POLICY — which provider direction gets which treatment — lives here as one
# rule table + apply functions; adapters keep only SYNTAX mapping (e.g.
# anthropic_adapter turning reasoning_content into a thinking block).
#
# Direction table:
# require-side (echo-back enforced; replays 400 without the field):
# kimi — provider kimi-coding/kimi-coding-cn, or host api.kimi.com /
# moonshot.ai / moonshot.cn. Host-driven on purpose:
# aggregators re-exporting kimi models reject the echo.
# deepseek — provider "deepseek", model contains "deepseek", or host
# api.deepseek.com (#15250; V4 rejects empty-string pads,
# hence the " " single-space pad, #17341).
# mimo — provider "xiaomi", model contains "mimo", or host
# *.xiaomimimo.com.
# strict side (field rejected with 400/422 "Extra inputs are not
# permitted"): everyone else — Mistral, Cerebras, Groq, SambaNova, …
# (#45655). Strip the key entirely, even a single-space pad.
_REASONING_ECHO_RULES: tuple = (
# (family, exact providers (raw), exact providers (lowered),
# model substrings (lowered), base_url hosts)
("kimi", frozenset({"kimi-coding", "kimi-coding-cn"}), frozenset(), (),
("api.kimi.com", "moonshot.ai", "moonshot.cn")),
("deepseek", frozenset(), frozenset({"deepseek"}), ("deepseek",),
("api.deepseek.com",)),
("mimo", frozenset(), frozenset({"xiaomi"}), ("mimo",),
("api.xiaomimimo.com", "xiaomimimo.com")),
)
def _family_rule(family: str) -> tuple:
for rule in _REASONING_ECHO_RULES:
if rule[0] == family:
return rule
raise KeyError(family)
def matches_reasoning_echo_family(
family: str, provider: Any, model: Any, base_url: Any
) -> bool:
"""True when (provider, model, base_url) matches one echo-back family.
Families can overlap (e.g. a deepseek-named model pointed at a kimi
host); this membership test is independent per family so per-family
predicates keep their original semantics.
"""
from utils import base_url_host_matches
_, raw_providers, lowered_providers, model_subs, hosts = _family_rule(family)
provider_lower = (provider or "").lower()
model_lower = (model or "").lower()
if provider in raw_providers or provider_lower in lowered_providers:
return True
if any(sub in model_lower for sub in model_subs):
return True
return any(base_url_host_matches(base_url, host) for host in hosts)
def reasoning_echo_family(provider: Any, model: Any, base_url: Any) -> "str | None":
"""Classify the provider direction for the reasoning_content echo policy.
Returns ``"kimi"``, ``"deepseek"``, or ``"mimo"`` (first match in table
order) when the target endpoint enforces reasoning_content echo-back on
assistant turns, else ``None`` (strict/indifferent side — the field must
be stripped).
"""
for rule in _REASONING_ECHO_RULES:
if matches_reasoning_echo_family(rule[0], provider, model, base_url):
return rule[0]
return None
def needs_reasoning_echo(provider: Any, model: Any, base_url: Any) -> bool:
"""True when the endpoint requires reasoning_content echo-back."""
return reasoning_echo_family(provider, model, base_url) is not None
def apply_reasoning_content_policy(
source_msg: dict, api_msg: dict, needs_thinking_pad: bool
) -> None:
"""Copy provider-facing reasoning fields onto an API replay message.
``needs_thinking_pad`` is the require-side flag (see
``needs_reasoning_echo`` / the agent's cached
``_needs_thinking_reasoning_pad``). Mutates ``api_msg`` in place.
"""
if source_msg.get("role") != "assistant":
return
# 1. Explicit reasoning_content already set.
#
# When the active provider enforces the thinking-mode echo-back
# (DeepSeek / Kimi / MiMo), preserve it verbatim — that includes their
# own space-placeholder written at creation time and any valid reasoning
# from the same provider. Sessions persisted BEFORE #17341 have
# empty-string placeholders pinned at creation time; DeepSeek V4 Pro
# rejects those with HTTP 400, so upgrade "" → " " on replay.
#
# When the active provider does NOT enforce echo-back, strip the field
# entirely. Strict OpenAI-compatible providers (Mistral, Cerebras, Groq,
# SambaNova, …) reject ANY reasoning_content key in input messages with
# HTTP 400/422 ("Extra inputs are not permitted"), even an empty string
# or a single-space pad. This is the cross-provider fallback case: a
# reasoning primary (DeepSeek/Kimi/MiMo) pads history with " ", then a
# fallback to a strict provider replays that pad and 422s. Stripping
# here covers the rebuild path; ``reapply_reasoning_echo`` covers the
# already-built api_messages path. Refs #45655.
existing = source_msg.get("reasoning_content")
if isinstance(existing, str):
if not needs_thinking_pad:
api_msg.pop("reasoning_content", None)
elif existing == "":
api_msg["reasoning_content"] = " "
else:
api_msg["reasoning_content"] = existing
return
# 2. Cross-provider poisoned history (#15748): on DeepSeek/Kimi,
# if the source turn has tool_calls AND a 'reasoning' field but no
# 'reasoning_content' key, the 'reasoning' text was written by a
# prior provider (e.g. MiniMax) — DeepSeek's own _build_assistant_message
# pins reasoning_content at creation time for tool-call turns, so the
# shape (reasoning set, reasoning_content absent, tool_calls present)
# is unreachable from same-provider DeepSeek history after this fix.
# Inject a single space to satisfy the API without leaking another
# provider's chain of thought to DeepSeek/Kimi. Space (not "")
# because DeepSeek V4 Pro rejects empty-string reasoning_content
# in thinking mode (refs #17341).
normalized_reasoning = source_msg.get("reasoning")
if (
needs_thinking_pad
and source_msg.get("tool_calls")
and isinstance(normalized_reasoning, str)
and normalized_reasoning
):
api_msg["reasoning_content"] = " "
return
# 3. Healthy session: promote 'reasoning' field to 'reasoning_content'
# for providers that use the internal 'reasoning' key.
# This must happen before the unconditional empty-string fallback so
# genuine reasoning content is not overwritten (#15812 regression in
# PR #15478). Only promote for providers that enforce echo-back —
# strict providers reject the field (refs #45655).
if isinstance(normalized_reasoning, str) and normalized_reasoning:
if needs_thinking_pad:
api_msg["reasoning_content"] = normalized_reasoning
else:
api_msg.pop("reasoning_content", None)
return
# 4. DeepSeek / Kimi thinking mode: all assistant messages need
# reasoning_content. Inject a single space to satisfy the provider's
# requirement when no explicit reasoning content is present. Covers
# both tool-call turns (already-poisoned history with no reasoning
# at all) and plain text turns. Space (not "") because DeepSeek V4
# Pro tightened validation and rejects empty string with HTTP 400
# ("The reasoning content in the thinking mode must be passed back
# to the API"). Refs #17341.
if needs_thinking_pad:
api_msg["reasoning_content"] = " "
return
# 5. reasoning_content was present but not a string (e.g. None after
# context compaction). Don't pass null to the API.
api_msg.pop("reasoning_content", None)
def reapply_reasoning_echo(api_messages: list, needs_thinking_pad: bool) -> int:
"""Re-pad (or strip) assistant turns' reasoning_content for the active provider.
``api_messages`` is built once, before the retry loop, while the *primary*
provider is active. A mid-conversation fallback can then switch providers,
so the reasoning fields baked into ``api_messages`` are shaped for the
*prior* provider and must be reconciled against the *current* one:
* Switching TO a require-side provider (DeepSeek / Kimi / MiMo thinking
mode): assistant turns built when the prior provider did NOT need the
echo-back go out without ``reasoning_content`` and the new provider
rejects them with HTTP 400 ("The reasoning_content in the thinking mode
must be passed back"). Re-apply the pad.
* Switching TO a strict provider that rejects the field (Mistral,
Cerebras, Groq, SambaNova, …): assistant turns built under a reasoning
primary carry a ``reasoning_content`` pad (often a single space ``" "``),
and the strict provider rejects it with HTTP 400/422 ("Extra inputs are
not permitted"). Strip the field. This is the exact cross-provider
fallback bug from #45655 — a DeepSeek primary pads history with ``" "``,
the request falls back to Mistral, and Mistral 422s on the stale pad.
Calling this immediately before building the request kwargs reconciles the
fields against the *current* provider. It is idempotent and safe to call
every iteration; it covers every fallback path.
Returns the number of assistant turns whose reasoning_content was added or
removed.
"""
changed = 0
for api_msg in api_messages:
if api_msg.get("role") != "assistant":
continue
if needs_thinking_pad:
if api_msg.get("reasoning_content"):
continue
apply_reasoning_content_policy(api_msg, api_msg, needs_thinking_pad)
if api_msg.get("reasoning_content"):
changed += 1
else:
# Strict provider — strip any stale reasoning_content pad left
# over from a reasoning primary so the fallback request doesn't
# 400/422 on it.
if "reasoning_content" in api_msg:
api_msg.pop("reasoning_content", None)
changed += 1
return changed
# ---------------------------------------------------------------------------
# Image / multimodal parts — evaluated, NOT consolidated (verdict: syntax)
# ---------------------------------------------------------------------------
#
# The per-adapter image handling is format-specific SYNTAX, not shared policy:
# * anthropic_adapter (~1817): data-URL → Anthropic `source: {type: base64}`
# block mapping — Anthropic wire shape only.
# * codex_responses_adapter (~113/165/812): chat `image_url` parts →
# Responses `input_image` items and image counting for log summaries —
# Responses wire shape only.
# * transports/chat_completions: pass-through (native format).
# The one genuinely shared image POLICY — removing images when a server
# rejects them while preserving tool_call_id pairing — already has a single
# owner here: ``_strip_images_from_messages`` above.
+2 -2
View File
@@ -72,7 +72,7 @@ def _resolve_requests_verify() -> bool | str:
_PROVIDER_PREFIXES: frozenset[str] = frozenset({
"openrouter", "nous", "openai-codex", "copilot", "copilot-acp",
"gemini", "ollama-cloud", "zai", "kimi-coding", "kimi-coding-cn", "stepfun", "minimax", "minimax-oauth", "minimax-cn", "anthropic", "deepseek", "deepinfra",
"opencode-zen", "opencode-go", "kilocode", "alibaba", "novita",
"opencode-zen", "opencode-go", "ai-gateway", "kilocode", "alibaba", "novita",
"qwen-oauth",
"xiaomi",
"arcee",
@@ -84,7 +84,7 @@ _PROVIDER_PREFIXES: frozenset[str] = frozenset({
"glm", "z-ai", "z.ai", "zhipu", "github", "github-copilot",
"github-models", "kimi", "moonshot", "kimi-cn", "moonshot-cn", "claude", "deep-seek", "deep-infra",
"ollama",
"stepfun", "opencode", "zen", "go", "kilo", "dashscope", "aliyun", "qwen",
"stepfun", "opencode", "zen", "go", "vercel", "kilo", "dashscope", "aliyun", "qwen",
"mimo", "xiaomi-mimo",
"tencent", "tokenhub", "tencent-cloud", "tencentmaas",
"arcee-ai", "arceeai",
+1
View File
@@ -168,6 +168,7 @@ PROVIDER_TO_MODELS_DEV: Dict[str, str] = {
"alibaba": "alibaba",
"qwen-oauth": "alibaba",
"copilot": "github-copilot",
"ai-gateway": "vercel",
"opencode-zen": "opencode",
"opencode-go": "opencode-go",
"kilocode": "kilo",
+2 -2
View File
@@ -210,8 +210,8 @@ def _resolve_trust_policy(plugin_id: str) -> _TrustPolicy:
return _TrustPolicy(plugin_id="")
try:
from hermes_cli.config import load_config
config = load_config() or {}
from hermes_cli.config import load_config_readonly
config = load_config_readonly() or {}
except Exception: # pragma: no cover — config IO failure
return _TrustPolicy(plugin_id=plugin_id)
+122 -25
View File
@@ -19,13 +19,18 @@ from typing import Optional
from agent.runtime_cwd import resolve_agent_cwd
from agent.skill_utils import (
EXCLUDED_SKILL_DIRS,
ORG_ACTIVE_MARKER,
ORG_MIRROR_DIR_NAME,
ORG_PROVENANCE_FILE,
SKILL_SUPPORT_DIRS,
extract_skill_conditions,
extract_skill_description,
get_all_skills_dirs,
get_disabled_skill_names,
iter_skill_index_files,
org_id_of_path,
parse_frontmatter,
read_active_org_id,
skill_matches_environment,
skill_matches_platform,
skill_matches_platform_list,
@@ -567,16 +572,18 @@ def computer_use_guidance(platform_name: Optional[str] = None) -> str:
"Background delivery is the DEFAULT and the co-work path, but it is "
"the first rung, not the only one. Read each action's structured "
"result and climb only when the driver tells you to:\n"
"- `effect: 'confirmed'` + `verified: true` — the driver read the "
"result back. Done.\n"
"- `effect: 'confirmed'` (or `verified: true`) — done, even if an "
"advisory escalation is also present. Never repeat successful input.\n"
"- `effect: 'unverifiable'` — the input was delivered but the driver "
"can't confirm it. Re-capture and check the screenshot/tree yourself "
"before deciding it worked.\n"
"- `effect: 'suspected_noop'`, `code: 'background_unavailable'`, or an "
"`escalation.recommended` field — the action did NOT land. Follow "
"`escalation.recommended`:\n"
"can't confirm it. Get fresh state and check it before any retry; an "
"escalation recommendation does not override this rule.\n"
"- `effect: 'suspected_noop'` or a structured refusal such as "
"`code: 'background_unavailable'` — escalation is allowed. Follow "
"the recommended rung when present:\n"
" - `'px'` → re-issue addressing the target by `coordinate=[x,y]` "
"read off the screenshot instead of `element`.\n"
" - `'page'` → use the exact-bound typed browser page rung below "
"before native foreground escalation. Do not start a legacy page workflow.\n"
" - `'foreground'` (or a pixel click still didn't land) → re-issue "
"the SAME action with `delivery_mode='foreground'`. This briefly "
"raises the window; it needs its own approval and is only appropriate "
@@ -586,6 +593,21 @@ def computer_use_guidance(platform_name: Optional[str] = None) -> str:
"as a prediction from the app being Electron/Chromium/GTK. Do not "
"silently retry the same rung expecting a different result, and do "
"not conclude 'cua-driver can't drive this app' — climb the ladder.\n\n"
"## Typed browser page rung\n"
"For `recommended='page'` or supported browser PAGE content, use the namespaced "
"`cua_browser_*` actions: bind with `cua_browser_state` using the exact "
"native `(pid, window_id)`, require `binding_quality='exact'` and "
"`mutation_allowed=true`, select its opaque `tab_id`, then take a "
"fresh semantic snapshot before using a current `ref`. After every "
"typed mutation, call `cua_browser_state` again before another action. "
"Input defaults to trusted; `input_route='dom_event'` is an explicit "
"downgrade, never an automatic retry. Use native capture/input for "
"browser chrome, OS permission prompts, native dialogs, and unsupported "
"targets. Browser setup is a separately approved action; attaching an "
"existing profile is enforced by cua-driver's immutable permission "
"mode: standard requires a certified protected host and fails closed "
"when Hermes has none; explicit Hermes YOLO uses a private unrestricted "
"daemon after the user's launch/session risk acceptance.\n\n"
"## Background mode rules\n"
"- Do NOT use `raise_window=true` on `focus_app` unless the user "
"explicitly asked you to bring a window to front. Input routing to "
@@ -959,7 +981,7 @@ WSL_ENVIRONMENT_HINT = (
# misleading — the agent should only see the machine it can actually touch.
_REMOTE_TERMINAL_BACKENDS = frozenset({
"docker", "singularity", "modal", "daytona", "ssh",
"managed_modal",
"vercel_sandbox", "managed_modal",
})
@@ -973,6 +995,7 @@ _BACKEND_FALLBACK_DESCRIPTIONS: dict[str, str] = {
"modal": "a Modal sandbox (Linux)",
"managed_modal": "a managed Modal sandbox (Linux)",
"daytona": "a Daytona workspace (Linux)",
"vercel_sandbox": "a Vercel sandbox (Linux)",
"ssh": "a remote host reached over SSH (likely Linux)",
}
@@ -1047,7 +1070,7 @@ def _probe_remote_backend(env_type: str) -> str | None:
}
container_config = None
if env_type in {"docker", "singularity", "modal", "daytona"}:
if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox"}:
container_config = {
"container_cpu": config.get("container_cpu", 1),
"container_memory": config.get("container_memory", 5120),
@@ -1137,7 +1160,7 @@ def build_environment_hints() -> str:
and a Windows-only note that `terminal` shells out to bash, not
PowerShell).
- For **remote / sandbox** terminal backends (docker, singularity,
modal, daytona, ssh): host info is **suppressed**
modal, daytona, ssh, vercel_sandbox): host info is **suppressed**
because the agent's tools can't touch the host — only the backend
matters. A live probe inside the backend reports its OS, user, $HOME,
and cwd. Falls back to a static summary if the probe fails.
@@ -1224,10 +1247,10 @@ def build_environment_hints() -> str:
extra = (os.getenv("HERMES_ENVIRONMENT_HINT") or "").strip()
if not extra:
try:
from hermes_cli.config import load_config
from hermes_cli.config import load_config_readonly
extra = str(
(load_config().get("agent", {}) or {}).get("environment_hint", "")
(load_config_readonly().get("agent", {}) or {}).get("environment_hint", "")
).strip()
except Exception as e:
logger.debug("Could not read agent.environment_hint from config: %s", e)
@@ -1278,9 +1301,9 @@ def _get_context_file_max_chars(context_length: Optional[int] = None) -> int:
3. ``CONTEXT_FILE_MAX_CHARS`` (20K) as the upstream-compatible fallback.
"""
try:
from hermes_cli.config import load_config
from hermes_cli.config import load_config_readonly
val = load_config().get("context_file_max_chars")
val = load_config_readonly().get("context_file_max_chars")
if isinstance(val, (int, float)) and val > 0:
return int(val)
except Exception as e:
@@ -1323,7 +1346,9 @@ def drain_truncation_warnings() -> list:
_SKILLS_PROMPT_CACHE_MAX = 8
_SKILLS_PROMPT_CACHE: OrderedDict[tuple, str] = OrderedDict()
_SKILLS_PROMPT_CACHE_LOCK = threading.Lock()
_SKILLS_SNAPSHOT_VERSION = 1
# v2: entries gained org provenance fields (org_id/org_author/rel_dir) for M2
# org-shared skills; older snapshots are discarded and rebuilt.
_SKILLS_SNAPSHOT_VERSION = 2
def _skills_prompt_snapshot_path() -> Path:
@@ -1342,13 +1367,32 @@ def clear_skills_system_prompt_cache(*, clear_snapshot: bool = False) -> None:
def _build_skills_manifest(skills_dir: Path) -> dict[str, list[int]]:
"""Build an mtime/size manifest of all SKILL.md and DESCRIPTION.md files."""
"""Build an mtime/size manifest of all SKILL.md and DESCRIPTION.md files.
Org mirrors (M2): only the ACTIVE org's mirror participates, and the
``.active_org`` marker itself is included — so switching/leaving an org
invalidates the snapshot even when no SKILL.md changed.
"""
manifest: dict[str, list[int]] = {}
skills_dir_str = str(skills_dir)
base = os.path.join(skills_dir_str, "")
prefix_len = len(base)
active_org = read_active_org_id(skills_dir)
org_root = os.path.join(skills_dir_str, ORG_MIRROR_DIR_NAME)
marker_path = os.path.join(org_root, ORG_ACTIVE_MARKER)
try:
st = os.stat(marker_path)
manifest[ORG_MIRROR_DIR_NAME + "/" + ORG_ACTIVE_MARKER] = [
int(st.st_mtime), int(st.st_size),
]
except OSError:
pass
for root, dirs, files in os.walk(skills_dir_str, followlinks=True):
has_skill_md = "SKILL.md" in files
if root == skills_dir_str and ORG_MIRROR_DIR_NAME in dirs and active_org is None:
dirs.remove(ORG_MIRROR_DIR_NAME)
elif root == org_root:
dirs[:] = [d for d in dirs if d == active_org]
dirs[:] = [
d
for d in dirs
@@ -1413,6 +1457,15 @@ def _build_snapshot_entry(
"""Build a serialisable metadata dict for one skill."""
rel_path = skill_file.relative_to(skills_dir)
parts = rel_path.parts
# M2 org mirror: strip the `_org/<org_id>/` prefix so category/name derive
# from the path WITHIN the mirror (same shape the org tree was built
# from), and record provenance for labeling + fail-loud collisions.
org_id: str | None = None
if len(parts) >= 3 and parts[0] == ORG_MIRROR_DIR_NAME:
org_id = parts[1]
parts = parts[2:]
if len(parts) >= 2:
skill_name = parts[-2]
category = "/".join(parts[:-2]) if len(parts) > 2 else parts[0]
@@ -1424,7 +1477,7 @@ def _build_snapshot_entry(
if isinstance(platforms, str):
platforms = [platforms]
return {
entry = {
"skill_name": skill_name,
"category": category,
"frontmatter_name": str(frontmatter.get("name", skill_name)),
@@ -1432,6 +1485,22 @@ def _build_snapshot_entry(
"platforms": [str(p).strip() for p in platforms if str(p).strip()],
"conditions": extract_skill_conditions(frontmatter),
}
if org_id:
entry["org_id"] = org_id
# Author from the pull-time provenance sidecar (token-verified at
# push by the plane's author_mismatch guard). Best-effort.
try:
import json as _json
prov_path = (
skills_dir / ORG_MIRROR_DIR_NAME / org_id / ORG_PROVENANCE_FILE
)
prov = _json.loads(prov_path.read_text(encoding="utf-8"))
device = str(prov.get("author_device") or "")
entry["org_author"] = device or str(prov.get("author_user_id") or "")
except Exception:
entry["org_author"] = ""
return entry
# =========================================================================
@@ -1567,6 +1636,10 @@ def build_skills_system_prompt(
skills_by_category: dict[str, list[tuple[str, str]]] = {}
category_descriptions: dict[str, str] = {}
# Unified visible-entry list (both paths) so the org labeling +
# fail-loud collision pass below runs identically for snapshot and scan.
visible_entries: list[dict] = []
skill_entries: list[dict] = []
if snapshot is not None:
# Fast path: use pre-parsed metadata from disk
@@ -1574,7 +1647,6 @@ def build_skills_system_prompt(
if not isinstance(entry, dict):
continue
skill_name = entry.get("skill_name") or ""
category = entry.get("category") or "general"
frontmatter_name = entry.get("frontmatter_name") or skill_name
platforms = entry.get("platforms") or []
if not skill_matches_platform_list(platforms):
@@ -1587,16 +1659,13 @@ def build_skills_system_prompt(
available_toolsets,
):
continue
skills_by_category.setdefault(category, []).append(
(frontmatter_name, entry.get("description", ""))
)
visible_entries.append(entry)
category_descriptions = {
str(k): str(v)
for k, v in (snapshot.get("category_descriptions") or {}).items()
}
else:
# Cold path: full filesystem scan + write snapshot for next time
skill_entries: list[dict] = []
for skill_file in iter_skill_index_files(skills_dir, "SKILL.md"):
is_compatible, frontmatter, desc = _parse_skill_file(skill_file)
entry = _build_snapshot_entry(skill_file, skills_dir, frontmatter, desc)
@@ -1612,10 +1681,38 @@ def build_skills_system_prompt(
available_toolsets,
):
continue
skills_by_category.setdefault(entry["category"], []).append(
(entry["frontmatter_name"], entry["description"])
)
visible_entries.append(entry)
# ── M2 org labeling + FAIL-LOUD collisions ─────────────────────────
# An org skill lists with an explicit provenance tag. When a personal and
# an org skill share a name, NEITHER silently wins: both list qualified
# (personal keeps the bare name is the wrong default — silent divergence
# from the org set; org winning silently shadows the user's own work) —
# so both entries carry a [name collision] flag and skill_view refuses
# the ambiguous bare name (its existing multi-candidate guard).
name_owners: dict[str, set[str]] = {}
for entry in visible_entries:
fm = entry.get("frontmatter_name") or entry.get("skill_name") or ""
kind = "org" if entry.get("org_id") else "personal"
name_owners.setdefault(fm, set()).add(kind)
for entry in visible_entries:
fm = entry.get("frontmatter_name") or entry.get("skill_name") or ""
desc = entry.get("description", "")
org_id = entry.get("org_id")
collided = len(name_owners.get(fm, set())) > 1
if org_id:
author = entry.get("org_author") or ""
tag = f"[org-shared{': by ' + author if author else ''}]"
desc = f"{tag} {desc}".strip()
category = f"org:{org_id}"
else:
category = entry.get("category") or "general"
if collided:
desc = f"[name collision — also exists {'personally' if org_id else 'in your org'}; load via category path] {desc}".strip()
skills_by_category.setdefault(category, []).append((fm, desc))
if snapshot is None:
# (continuation of the cold path below: category descriptions + write)
# Read category-level DESCRIPTION.md files
for desc_file in iter_skill_index_files(skills_dir, "DESCRIPTION.md"):
try:
+24 -2
View File
@@ -140,6 +140,11 @@ _ENV_ASSIGN_RE = re.compile(
# The colon-form URL guard (skip when ``://`` present) lives at the call site.
_SECRET_CFG_NAMES = r"(?:api[ _.\-]?key|token|secret|passwd|password|credential|auth)"
_CFG_VALUE = r"(['\"]?)([^\s&]+?)\2(?=[\s&]|$)"
# Linear pre-gate for the _CFG_*_RE subs below: a text with no secret keyword
# can never match either pattern, so the (potentially backtrack-heavy) subs
# are skipped entirely for such text. See the call site in
# redact_sensitive_text().
_CFG_SECRET_WORD_RE = re.compile(_SECRET_CFG_NAMES, re.IGNORECASE)
# Programmatic env lookups (``os.getenv(...)``, ``os.environ[...]``,
# ``os.environ.get(...)``, ``process.env.X``, ``$ENV{X}``) reference variable
@@ -377,8 +382,17 @@ _STRICT_URL_PARAM_RE = re.compile(
# Match userinfo in both absolute (``scheme://user:pass@host``) and
# network-path (``//user:pass@host``) references. The authority boundary stops
# at path/query/fragment delimiters so an ``@`` elsewhere in a URL is ignored.
#
# Anchored on the mandatory ``//`` rather than an optional scheme prefix: the
# scheme sits outside the match either way (replacement callbacks re-emit
# group(1), so ``https:`` stays untouched in the surrounding text), and the
# old optional-scheme prefix ``(?:[A-Za-z][A-Za-z0-9+.-]*:)?`` backtracked
# catastrophically (O(n²)) on long unbroken alphanumeric runs — a 320KB
# synthetic compaction payload spent ~55s inside this pattern per sub() call.
# Output-equivalence to the old pattern was fuzz-verified (20k random strings
# plus targeted URL forms).
_STRICT_URL_USERINFO_RE = re.compile(
r"((?:[A-Za-z][A-Za-z0-9+.-]*:)?//)([^/\s?#@]+)@"
r"(//)([^/\s?#@]+)@"
)
# HTTP access logs often use a relative request target rather than a full URL:
@@ -706,7 +720,15 @@ def redact_sensitive_text(
# web-URL query params are intentionally passed through (see note
# near the bottom of this function); _DB_CONNSTR_RE still guards
# connection-string passwords.
if "://" not in text:
#
# Extra gate: every _CFG_*_RE match requires a secret keyword in
# the key, so a text without any secret keyword cannot match —
# skipping is exact. This matters because _CFG_DOTTED_RE
# backtracks quadratically on long unbroken [A-Za-z0-9_.\-] runs
# (e.g. base64/hex blobs in compaction payloads); the linear
# keyword scan prevents that pathological path on secret-free
# text.
if "://" not in text and _CFG_SECRET_WORD_RE.search(text):
text = _CFG_DOTTED_RE.sub(_redact_env, text)
text = _CFG_ANCHORED_RE.sub(_redact_env, text)
+2 -2
View File
@@ -25,9 +25,9 @@ _INLINE_SHELL_MAX_OUTPUT = 4000
def load_skills_config() -> dict:
"""Load the ``skills`` section of config.yaml (best-effort)."""
try:
from hermes_cli.config import load_config
from hermes_cli.config import load_config_readonly
cfg = load_config() or {}
cfg = load_config_readonly() or {}
skills_cfg = cfg.get("skills")
if isinstance(skills_cfg, dict):
return skills_cfg
+62
View File
@@ -49,6 +49,55 @@ EXCLUDED_SKILL_DIRS = frozenset(
# archive workflow preserves a complete old skill package under references/.
SKILL_SUPPORT_DIRS = frozenset(("references", "templates", "assets", "scripts"))
# ── Org-shared skills (sync contract) ───────────────────────────
# Org mirrors live under ~/.hermes/skills/_org/<org_id>/. Resolution is
# TOKEN-GATED via a marker file the sync client writes after verifying the
# token (skills_sync_client.pull_org_skills): only the marked org's mirror is
# scanned. No marker ⇒ no org skills load. The marker is plain data (org_id
# string) so this module stays import-light; the VERIFICATION lives in the
# sync client, which is the only writer. Offline grace: the marker persists,
# so already-pulled org skills keep working without connectivity; a VERIFIED
# org change (or personal-org token) rewrites/removes it.
ORG_MIRROR_DIR_NAME = "_org"
ORG_ACTIVE_MARKER = ".active_org"
ORG_PROVENANCE_FILE = ".org-provenance.json"
# Records the fingerprint of each skill exactly as upstream sent it, so a
# later local edit is detectable and an org pull can refuse to clobber it.
ORG_BASELINE_FILE = ".org-baseline.json"
def read_active_org_id(skills_dir: Path) -> Optional[str]:
"""The org id whose mirror may resolve, or None (no org skills load)."""
try:
marker = skills_dir / ORG_MIRROR_DIR_NAME / ORG_ACTIVE_MARKER
if not marker.exists():
return None
val = marker.read_text(encoding="utf-8").strip()
return val or None
except OSError:
return None
def is_org_mirror_path(path, skills_dir: Path) -> bool:
"""True when *path* is inside the org mirror (``_org/``)."""
try:
rel = Path(path).resolve().relative_to(Path(skills_dir).resolve())
except (OSError, ValueError):
return False
return bool(rel.parts) and rel.parts[0] == ORG_MIRROR_DIR_NAME
def org_id_of_path(path, skills_dir: Path) -> Optional[str]:
"""The ``<org_id>`` segment for a path under ``_org/<org_id>/...``."""
try:
rel = Path(path).resolve().relative_to(Path(skills_dir).resolve())
except (OSError, ValueError):
return None
if len(rel.parts) >= 2 and rel.parts[0] == ORG_MIRROR_DIR_NAME:
return rel.parts[1]
return None
def is_excluded_skill_path(path, *, root: Optional[Path] = None) -> bool:
"""True if *path* should be skipped by active skill scanners.
@@ -817,11 +866,24 @@ def iter_skill_index_files(skills_dir: Path, filename: str):
scripts) can contain arbitrary markdown and even archived package
``SKILL.md`` files, but they are progressive-disclosure data loaded through
``skill_view(..., file_path=...)`` rather than active skill roots.
M2 org mirrors (``_org/``): TOKEN-GATED resolution. Only the active org's
subdir (per the sync-client-written ``.active_org`` marker) is walked;
every other ``_org/<id>/`` (stale mirror from a previous org, or no
marker at all) is pruned — leave an org and its skills stop resolving,
without any manual cleanup.
"""
skills_dir_str = str(skills_dir)
active_org = read_active_org_id(skills_dir)
org_root = os.path.join(skills_dir_str, ORG_MIRROR_DIR_NAME)
matches: list[str] = []
for root, dirs, files in os.walk(skills_dir_str, followlinks=True):
has_skill_md = "SKILL.md" in files
if root == skills_dir_str and ORG_MIRROR_DIR_NAME in dirs and active_org is None:
dirs.remove(ORG_MIRROR_DIR_NAME)
elif root == org_root:
# Inside _org/: descend ONLY into the active org's mirror.
dirs[:] = [d for d in dirs if d == active_org]
dirs[:] = [
d
for d in dirs
+2 -2
View File
@@ -43,10 +43,10 @@ _TITLE_PROMPT_PINNED_LANGUAGE = (
def _title_language() -> str:
"""Return configured title language, or empty string to match the user."""
try:
from hermes_cli.config import load_config
from hermes_cli.config import load_config_readonly
return str(
((load_config() or {}).get("auxiliary") or {})
((load_config_readonly() or {}).get("auxiliary") or {})
.get("title_generation", {})
.get("language", "")
).strip()
-3
View File
@@ -1979,9 +1979,6 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
return
break
if agent.tool_delay > 0 and i < len(assistant_message.tool_calls):
time.sleep(agent.tool_delay)
# ── Per-turn aggregate budget enforcement ─────────────────────────
num_tools_seq = len(assistant_message.tool_calls)
if finalize and num_tools_seq > 0:
+2 -2
View File
@@ -1244,8 +1244,8 @@ def normalize_usage(
output_tokens = _to_int(getattr(response_usage, "completion_tokens", 0))
details = getattr(response_usage, "prompt_tokens_details", None)
# Primary: OpenAI-style prompt_tokens_details. Fallback: Anthropic-style
# top-level fields that some OpenAI-compatible proxies (OpenRouter, Cline)
# expose when routing Claude models — without this
# top-level fields that some OpenAI-compatible proxies (OpenRouter, Vercel
# AI Gateway, Cline) expose when routing Claude models — without this
# fallback, cache writes are undercounted as 0 and cache reads can be
# missed when the proxy only surfaces them at the top level.
# Port of cline/cline#10266.
+14 -54
View File
@@ -72,64 +72,24 @@ def _filter_verifiable_paths(paths: Iterable[str]) -> list[str]:
return [p for p in paths if p and not _is_non_code_path(p)]
# Session identities (platform or source) that are NOT human conversational
# messaging surfaces: interactive coding surfaces (CLI, TUI, desktop, codex,
# local, gateway) and programmatic callers (API server, webhooks, tools).
# Verify-on-stop stays ON by default for these. Any other resolved gateway
# platform is a conversational messaging surface (Telegram, Discord, WhatsApp,
# Signal, Slack, etc.) where the verification narrative would reach a human as
# chat noise, so it defaults OFF. Mirrors LOCAL_SESSION_SOURCE_IDS in
# apps/desktop/src/lib/session-source.ts; keep roughly in sync when adding a
# local or programmatic surface. Default-deny by design: an unrecognized
# identity is treated as messaging (OFF) so a new chat platform never leaks the
# verification receipt before this set is updated.
_NON_MESSAGING_SESSION_SURFACES = frozenset(
{
"",
"cli",
"codex",
"desktop",
"gateway",
"local",
"tui",
"tool",
"api_server",
"webhook",
"msgraph_webhook",
}
)
def _session_is_messaging_surface() -> bool:
"""Return whether this turn is delivered over a human messaging channel.
"""Whether this turn is delivered over a human messaging channel.
The gateway binds the platform value (e.g. ``telegram``) to
``HERMES_SESSION_PLATFORM``; the CLI and TUI set ``HERMES_SESSION_SOURCE``
(e.g. ``cli``, ``tui``) instead. Both are consulted via the session-context
helper (with an ``os.environ`` fallback), alongside the ``HERMES_PLATFORM``
override, matching the sibling platform resolution in
``agent/skill_commands.py`` and ``agent/prompt_builder.py``. A turn is a
messaging surface when a resolved identity is present and is not a known
non-messaging surface.
Verify-on-stop defaults ON for the interactive coding surfaces and
programmatic callers, and OFF on a conversational platform (Telegram,
Discord, Slack, ...) where the verification narrative reaches a human as
chat noise. The surface classification itself is shared with the other
consumers of this distinction — see
``gateway.session_context.session_is_messaging_surface``.
"""
try:
from gateway.session_context import get_session_env
from gateway.session_context import session_is_messaging_surface
platform = (
os.getenv("HERMES_PLATFORM")
or get_session_env("HERMES_SESSION_PLATFORM", "")
)
source = get_session_env("HERMES_SESSION_SOURCE", "")
return session_is_messaging_surface()
except Exception:
platform = os.getenv("HERMES_PLATFORM", "") or os.environ.get(
"HERMES_SESSION_PLATFORM", ""
)
source = os.environ.get("HERMES_SESSION_SOURCE", "")
for identity in (platform, source):
identity = str(identity or "").strip().lower()
if identity and identity not in _NON_MESSAGING_SESSION_SURFACES:
return True
return False
# The gateway package is unreachable, so there is no messaging channel
# to be on. Reporting a local surface keeps verify-on-stop enabled.
return False
def verify_on_stop_enabled(config: dict[str, Any] | None = None) -> bool:
@@ -149,9 +109,9 @@ def verify_on_stop_enabled(config: dict[str, Any] | None = None) -> bool:
return env.strip().lower() not in {"0", "false", "no", "off"}
if config is None:
try:
from hermes_cli.config import load_config
from hermes_cli.config import load_config_readonly
config = load_config()
config = load_config_readonly()
except Exception:
config = {}
agent_cfg = (config or {}).get("agent") if isinstance(config, dict) else None
+2 -2
View File
@@ -84,9 +84,9 @@ def get_active_provider() -> Optional[VideoGenProvider]:
"""
configured: Optional[str] = None
try:
from hermes_cli.config import load_config
from hermes_cli.config import load_config_readonly
cfg = load_config()
cfg = load_config_readonly()
section = cfg.get("video_gen") if isinstance(cfg, dict) else None
if isinstance(section, dict):
raw = section.get("provider")
+2 -2
View File
@@ -98,9 +98,9 @@ def get_provider(name: str) -> Optional[WebSearchProvider]:
def _read_config_key(*path: str) -> Optional[str]:
"""Resolve a dotted config key from ``config.yaml``. Returns None on miss."""
try:
from hermes_cli.config import load_config
from hermes_cli.config import load_config_readonly
cfg = load_config()
cfg = load_config_readonly()
cur = cfg
for segment in path:
if not isinstance(cur, dict):
@@ -66,6 +66,10 @@ windows-sys = { version = "0.59", features = [
"Win32_UI_WindowsAndMessaging",
] }
# Signal-0 liveness probe for the update-lock marker owner (update.rs).
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[profile.release]
# A 5-10MB signed installer is the goal. LTO + size-opt + single codegen unit.
panic = "abort"
+362 -14
View File
@@ -107,16 +107,110 @@ pub async fn start_update(app: AppHandle) -> Result<(), String> {
/// future desktop launches. The marker payload is `{pid}\n{started_at_unix}`
/// so the desktop's launch gate can detect a stale marker (dead PID / past a
/// hard ceiling) and self-heal rather than wait forever.
///
/// The marker is also the cross-process update lock: `hermes update` claims
/// the same file (see `hermes_cli/update_lock.py`) so a dashboard-spawned
/// update and this updater can't mutate one checkout at the same time.
/// `acquire` therefore REFUSES when a live foreign owner holds it rather than
/// overwriting — the pre-fix clobber is what let a dashboard `hermes update`
/// keep running while install-mode bootstrap rewrote the tree underneath it.
struct UpdateMarkerGuard {
path: PathBuf,
/// False when a live foreign updater already owns the marker: we hold no
/// claim, so `Drop` must not delete their marker.
owned: bool,
}
/// Never treat a marker older than this as a live update. Mirrors
/// UPDATE_MARKER_MAX_AGE_MS in apps/desktop/electron/update-marker.ts and
/// UPDATE_MARKER_MAX_AGE_SECONDS in hermes_cli/update_lock.py — all three read
/// this one file, so a shorter ceiling in any of them would steal a lock the
/// others still consider live.
const UPDATE_MARKER_MAX_AGE_SECS: u64 = 20 * 60;
/// The pid + age of a confirmed-live update holding the marker.
struct MarkerOwner {
pid: u32,
age_secs: u64,
}
/// Read the marker and report a live *foreign* owner, if any. `None` for every
/// "no live update" case — absent, unreadable, malformed, dead pid, past the
/// ceiling, or a marker whose pid is **this** process — matching
/// `readLiveUpdateMarker` in the Electron gate. Never panics.
///
/// Self-PID is treated as non-ownership on purpose (#74761): since #50238 the
/// desktop pre-writes this marker with the spawned updater's pid before the
/// updater reaches `acquire`. Without the exclusion, `acquire` sees a live
/// owner that is itself and aborts ("Another Hermes update is already
/// running"), then the desktop relaunches and retries forever. A foreign live
/// pid (e.g. a dashboard-spawned `hermes update`) still blocks.
fn live_marker_owner(path: &Path) -> Option<MarkerOwner> {
let raw = std::fs::read_to_string(path).ok()?;
let mut lines = raw.lines();
let pid: u32 = lines.next()?.trim().parse().ok()?;
let started_at: u64 = lines.next().unwrap_or("").trim().parse().unwrap_or(0);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let age_secs = now.saturating_sub(started_at);
if age_secs > UPDATE_MARKER_MAX_AGE_SECS || !pid_is_alive(pid) {
return None;
}
// Desktop `writeUpdateMarker(hermesHome, child.pid)` races ahead of us;
// adopt that pre-claim rather than refusing our own marker.
if pid == std::process::id() {
return None;
}
Some(MarkerOwner { pid, age_secs })
}
/// True when a process with `pid` currently exists.
#[cfg(windows)]
fn pid_is_alive(pid: u32) -> bool {
use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE};
use windows_sys::Win32::System::Threading::{
GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
};
unsafe {
let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
if handle.is_null() {
// Either the pid is gone or we lack rights to open it. A pid we
// can't inspect is treated as dead so an unopenable straggler
// can't wedge every future update.
return false;
}
let mut code: u32 = 0;
let ok = GetExitCodeProcess(handle, &mut code);
CloseHandle(handle);
ok != 0 && code == STILL_ACTIVE as u32
}
}
#[cfg(not(windows))]
fn pid_is_alive(pid: u32) -> bool {
// signal 0 delivers nothing; it only probes existence/permission.
// ESRCH => dead. EPERM => alive but owned by another user.
let rc = unsafe { libc::kill(pid as libc::pid_t, 0) };
if rc == 0 {
return true;
}
std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
impl UpdateMarkerGuard {
/// Write the marker. Best-effort: a write failure must NOT abort the
/// update (the gate degrades to "no marker => proceed", i.e. exactly the
/// pre-fix behavior), so we log and carry on with a guard that still
/// attempts cleanup of whatever may exist at the path.
fn acquire(path: PathBuf) -> Self {
/// Claim the marker, or report the live updater that already owns it.
///
/// Writing is best-effort: a write failure must NOT abort the update (the
/// gate degrades to "no marker => proceed", i.e. exactly the pre-marker
/// behavior), so we log and carry on with a guard that still attempts
/// cleanup of whatever may exist at the path.
fn acquire(path: PathBuf) -> Result<Self, MarkerOwner> {
if let Some(owner) = live_marker_owner(&path) {
return Err(owner);
}
let pid = std::process::id();
let started_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -128,17 +222,32 @@ impl UpdateMarkerGuard {
if let Err(err) = std::fs::write(&path, format!("{pid}\n{started_at}")) {
tracing::warn!(?path, %err, "could not write update-in-progress marker");
}
Self { path }
Ok(Self { path, owned: true })
}
/// Release the marker as soon as every mutating stage has completed.
///
/// The updater still owns a Tauri/Cocoa event loop while it relaunches the
/// desktop, and that loop can outlive `app.exit(0)`. Relying on `Drop`
/// alone therefore leaves a *successful* update looking active — a live
/// pid holding a fresh marker — which blocks desktop startup and every
/// other updater for the full age ceiling. Idempotent: `Drop` still runs
/// and tolerates an already-removed marker.
fn complete(&self) {
if !self.owned {
return;
}
if let Err(err) = std::fs::remove_file(&self.path) {
if err.kind() != std::io::ErrorKind::NotFound {
tracing::warn!(path = ?self.path, %err, "could not remove completed update marker");
}
}
}
}
impl Drop for UpdateMarkerGuard {
fn drop(&mut self) {
if let Err(err) = std::fs::remove_file(&self.path) {
if err.kind() != std::io::ErrorKind::NotFound {
tracing::warn!(path = ?self.path, %err, "could not remove update-in-progress marker");
}
}
self.complete();
}
}
@@ -152,7 +261,39 @@ async fn run_update(app: AppHandle) -> Result<()> {
// it, that backend re-locks the venv shim, our `force_kill_other_hermes`
// straggler-cleanup kills it, and the relaunch/kill cycle loops. The guard
// removes the marker on every exit path (incl. early returns / panics).
let _update_marker = UpdateMarkerGuard::acquire(crate::paths::update_in_progress_marker());
//
// The same marker is the cross-process update lock (hermes_cli/
// update_lock.py claims it too), so a live foreign owner means another
// updater — most often a dashboard-spawned `hermes update` — is already
// mutating this checkout. Refuse instead of running a second one over it.
let _update_marker = match UpdateMarkerGuard::acquire(
crate::paths::update_in_progress_marker(),
) {
Ok(guard) => guard,
Err(owner) => {
let mins = owner.age_secs / 60;
let secs = owner.age_secs % 60;
let elapsed = if mins > 0 {
format!("{mins}m {secs}s")
} else {
format!("{secs}s")
};
let msg = format!(
"Another Hermes update is already running (PID {}, started {} ago). \
Wait for it to finish, or close the window or dashboard tab that \
started it, then try again.",
owner.pid, elapsed
);
emit(
&app,
BootstrapEvent::Failed {
stage: None,
error: msg.clone(),
},
);
return Err(anyhow!(msg));
}
};
let update_branch = update_branch_from_args(std::env::args().skip(1))
.or_else(|| option_env_string("BUILD_PIN_BRANCH"))
@@ -453,6 +594,12 @@ async fn run_update(app: AppHandle) -> Result<()> {
marker: None,
},
);
// Every install-tree mutation is finished. Release the lock BEFORE the
// relaunch: this process can stay wedged in its native event loop even
// after a successful app.exit(), and a live pid on a fresh marker would
// make a completed update look active — blocking desktop startup and
// every other updater until the age ceiling expires.
_update_marker.complete();
if let Some(target_app) = launch_target {
if let Err(err) = launch_macos_app_and_exit(&app, &target_app).await {
@@ -477,9 +624,26 @@ async fn run_update(app: AppHandle) -> Result<()> {
);
}
// The launch helpers normally request exit themselves, but their failure
// paths must still close a successful updater. A native event loop can
// ignore that graceful request, so arm a process-exit fallback now that
// all update state and the marker have been settled.
exit_after_success(&app);
Ok(())
}
/// Ask the app to exit, with a hard `process::exit` fallback for a native
/// event loop that ignores the graceful request. Without it a finished updater
/// can linger as a live pid forever.
fn exit_after_success(app: &AppHandle) {
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_secs(3));
tracing::warn!("graceful updater exit timed out; forcing process exit");
std::process::exit(0);
});
app.exit(0);
}
/// Poll until the venv shim AND packaged desktop app bundle are no longer locked
/// (Windows) or a bounded timeout elapses. On non-Windows this is a short fixed
/// grace since file locking isn't the failure mode there.
@@ -744,6 +908,17 @@ fn update_child_env(install_root: &Path) -> Vec<(String, OsString)> {
// a frozen stage, and users cancel a healthy update. Force line-by-line
// output instead.
envs.push(("PYTHONUNBUFFERED".to_string(), OsString::from("1")));
// We hold the update-in-progress marker for this whole run, and the
// `hermes update` child claims that SAME lock (hermes_cli/update_lock.py).
// Name our pid so the child recognizes the live holder as its own
// orchestrator and runs under our claim — without this every GUI update
// refuses its parent's marker with exit 2 ("Hermes is still running")
// and no number of retries can ever succeed. Keep the variable name in
// sync with HANDOFF_PID_ENV in hermes_cli/update_lock.py.
envs.push((
"HERMES_UPDATE_HANDOFF_PID".to_string(),
OsString::from(std::process::id().to_string()),
));
if let Some(path) = path_with_prepended_entries(&[
hermes_home.join("node").join("bin"),
venv_bin_dir(install_root),
@@ -1067,6 +1242,17 @@ mod tests {
);
}
#[test]
fn update_child_env_names_our_pid_for_the_lock_handoff() {
let envs = update_child_env(Path::new("/x/hermes-agent"));
assert!(
envs.iter().any(|(k, v)| k == "HERMES_UPDATE_HANDOFF_PID"
&& v.to_str() == Some(std::process::id().to_string().as_str())),
"the hermes update child claims the same marker we hold; without our pid \
it refuses its own parent's lock and every GUI update dead-ends on exit 2"
);
}
#[test]
fn lock_probe_paths_include_desktop_app_payload() {
let root = Path::new("/x/hermes-agent");
@@ -1102,7 +1288,8 @@ mod tests {
let marker = dir.join(".hermes-update-in-progress");
{
let _g = UpdateMarkerGuard::acquire(marker.clone());
let _g = UpdateMarkerGuard::acquire(marker.clone())
.unwrap_or_else(|_| panic!("no live owner => acquire must succeed"));
assert!(marker.exists(), "marker must exist while the guard is held");
let body = std::fs::read_to_string(&marker).unwrap();
let pid_line = body.lines().next().unwrap();
@@ -1127,7 +1314,8 @@ mod tests {
std::fs::create_dir_all(&dir).unwrap();
let marker = dir.join(".hermes-update-in-progress");
let guard = UpdateMarkerGuard::acquire(marker.clone());
let guard = UpdateMarkerGuard::acquire(marker.clone())
.unwrap_or_else(|_| panic!("no live owner => acquire must succeed"));
// Simulate an external cleanup (e.g. the desktop pruned a marker it
// judged stale) before our guard drops — Drop must not panic.
std::fs::remove_file(&marker).unwrap();
@@ -1137,6 +1325,166 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// Spawn a short-lived sibling process whose pid stands in for a foreign
/// updater. Same-process double-acquire no longer models contention: since
/// #74761 `live_marker_owner` treats our own pid as adoptable (desktop
/// pre-writes it), so a second acquire in *this* process would succeed.
fn spawn_foreign_holder() -> std::process::Child {
#[cfg(windows)]
{
std::process::Command::new("timeout")
.args(["/t", "30", "/nobreak"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("spawn foreign marker holder")
}
#[cfg(not(windows))]
{
std::process::Command::new("sleep")
.arg("30")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("spawn foreign marker holder")
}
}
#[test]
fn acquire_refuses_while_a_live_updater_owns_the_marker() {
let dir = unique_tmp_dir("marker-contended");
std::fs::create_dir_all(&dir).unwrap();
let marker = dir.join(".hermes-update-in-progress");
// A live *foreign* updater holds it. We must NOT clobber the marker and
// run concurrently over the same checkout — that race is what let a
// dashboard `hermes update` and install-mode bootstrap mutate one tree
// at once. Own-pid markers are adoptable (#74761), so the foreign pid
// must be a real sibling process.
let mut foreign = spawn_foreign_holder();
let foreign_pid = foreign.id();
let started_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
std::fs::write(&marker, format!("{foreign_pid}\n{started_at}")).unwrap();
let owner = UpdateMarkerGuard::acquire(marker.clone())
.err()
.expect("acquire must be refused while a foreign updater is live");
assert_eq!(owner.pid, foreign_pid);
// The refused guard must not delete the live owner's marker.
assert!(marker.exists(), "refused acquire must leave the marker intact");
let _ = foreign.kill();
let _ = foreign.wait();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn acquire_adopts_a_marker_prewritten_with_our_own_pid() {
// #74761: desktop writeUpdateMarker(hermesHome, child.pid) races ahead
// of UpdateMarkerGuard::acquire. The marker names US; refusing it made
// every in-app desktop update loop forever. Adopt and rewrite.
let dir = unique_tmp_dir("marker-own-pid");
std::fs::create_dir_all(&dir).unwrap();
let marker = dir.join(".hermes-update-in-progress");
let started_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
.saturating_sub(2);
std::fs::write(&marker, format!("{}\n{started_at}", std::process::id())).unwrap();
let guard = UpdateMarkerGuard::acquire(marker.clone()).unwrap_or_else(|owner| {
panic!(
"own-pid pre-write must be adoptable, got foreign owner pid={}",
owner.pid
)
});
assert!(marker.exists(), "adopted guard must own the marker");
let body = std::fs::read_to_string(&marker).unwrap();
assert_eq!(
body.lines().next().unwrap().trim().parse::<u32>().unwrap(),
std::process::id(),
"acquire rewrites the marker with our pid + fresh started_at"
);
drop(guard);
assert!(
!marker.exists(),
"Drop must still clear the marker we adopted"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn acquire_reclaims_a_marker_owned_by_a_dead_pid() {
let dir = unique_tmp_dir("marker-dead-pid");
std::fs::create_dir_all(&dir).unwrap();
let marker = dir.join(".hermes-update-in-progress");
// pid 1 exists everywhere, so fabricate a dead one: a very large pid
// that no live process owns. A crashed updater must never wedge every
// future update.
let started_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
std::fs::write(&marker, format!("4294967294\n{started_at}")).unwrap();
let guard = UpdateMarkerGuard::acquire(marker.clone())
.unwrap_or_else(|_| panic!("a dead owner must not block acquisition"));
let body = std::fs::read_to_string(&marker).unwrap();
assert_eq!(
body.lines().next().unwrap().trim().parse::<u32>().unwrap(),
std::process::id(),
"reclaiming rewrites the marker with our pid"
);
drop(guard);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn acquire_reclaims_a_marker_past_the_age_ceiling() {
let dir = unique_tmp_dir("marker-stale-age");
std::fs::create_dir_all(&dir).unwrap();
let marker = dir.join(".hermes-update-in-progress");
// Our own (live) pid, but started well past the ceiling: a wedged
// updater must not hold the lock forever.
let long_ago = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
.saturating_sub(UPDATE_MARKER_MAX_AGE_SECS + 60);
std::fs::write(&marker, format!("{}\n{long_ago}", std::process::id())).unwrap();
let guard = UpdateMarkerGuard::acquire(marker.clone())
.unwrap_or_else(|_| panic!("a marker past the ceiling must be reclaimable"));
drop(guard);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn completed_update_releases_marker_before_guard_drop() {
let dir = unique_tmp_dir("marker-complete");
std::fs::create_dir_all(&dir).unwrap();
let marker = dir.join(".hermes-update-in-progress");
let guard = UpdateMarkerGuard::acquire(marker.clone())
.unwrap_or_else(|_| panic!("no live owner => acquire must succeed"));
guard.complete();
assert!(
!marker.exists(),
"a successful update must unblock desktop startup before relaunch/exit"
);
drop(guard);
assert!(!marker.exists(), "Drop stays idempotent after completion");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn parses_update_branch_from_space_or_equals_args() {
assert_eq!(
+10 -1
View File
@@ -91,10 +91,11 @@ for call-site shadow or border inventions.
| Token | Use |
| --- | --- |
| `--ui-stroke-primary…quaternary` | hairlines, in descending strength |
| `--ui-stroke-tertiary` | the default in-panel divider / list hairline |
| `--ui-stroke-tertiary` | the default in-panel divider / list hairline — and every bordered surface in the transcript |
| `--stroke-nous` | the overlay hairline (pairs with `shadow-nous`) |
| `--ui-text-primary / -secondary / -tertiary` | text hierarchy |
| `--ui-bg-quaternary` | soft control fill (secondary button) |
| `--ui-widget-surface-background` | fill for inline chat widgets (`WIDGET_SHELL_CLASS`) |
| `--chrome-action-hover` | hover fill for quiet controls |
| `--theme-primary`, `--ui-accent` | brand/accent |
@@ -196,6 +197,14 @@ Notes:
existing components under `src/components/assistant-ui` and
`src/app/chat/composer`; do not fork a second markdown, message, tool-call, or
approval renderer for one feature.
- **Inline widgets** — a tool result that renders as a panel the user reads or
acts on (clarify, artifact card) wears `WIDGET_SHELL_CLASS`
(`src/components/chat/widget-shell.ts`): shared radius, the
`--ui-widget-surface-background` fill, no border. Its actions sit *outside*
the panel, below it. Don't give one widget its own radius or fill.
- Bordered surfaces in the transcript (tables, fences, callouts, attachments)
use `--ui-stroke-tertiary`. Not `border-border` — that's the app-wide
default and reads too hot against the thread.
- A tool result may expose an inline action that opens a preview. It must not
open the rail automatically.
- Install, onboarding, connecting, boot failure, and reauthentication are
+23 -1
View File
@@ -5155,6 +5155,20 @@ function sendClosePreviewRequested() {
webContents.send('hermes:close-preview-requested')
}
function sendOpenFolderRequested() {
if (!mainWindow || mainWindow.isDestroyed()) {
return
}
const webContents = mainWindow.webContents
if (!webContents || webContents.isDestroyed()) {
return
}
webContents.send('hermes:open-folder-requested')
}
// Tell the renderer the machine just woke. Sleep silently drops the
// renderer's WebSocket to the local backend; the renderer reconnects on this
// signal so the chat composer doesn't stay stuck on "Starting Hermes...".
@@ -5272,6 +5286,10 @@ function buildApplicationMenu() {
// a menu accelerator would fight the rebind panel and (on macOS) be
// swallowed before the renderer sees it. Here purely for discoverability.
{ click: () => createInstanceWindow(), label: 'New Window' },
// Same no-accelerator rationale: ⌘O is the rebindable renderer keybind
// (workspace.openFolder). Clicking runs the same open-folder-as-project
// flow through the renderer.
{ click: () => sendOpenFolderRequested(), label: 'Open Folder…' },
{ type: 'separator' },
IS_MAC
? {
@@ -5624,8 +5642,12 @@ function installContextMenu(window) {
}
}
// Bare right-click on non-editable, non-selected, non-media content (a pane
// body, the sidebar, chrome): the renderer's own context menus own those
// surfaces, and anywhere without one shows nothing — not a lone, useless
// "Select All" from the native fallback.
if (!template.length) {
template.push({ role: 'selectAll' })
return
}
Menu.buildFromTemplate(template).popup({ window })
+6
View File
@@ -212,6 +212,12 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
return () => ipcRenderer.removeListener('hermes:close-preview-requested', listener)
},
onOpenFolderRequested: callback => {
const listener = () => callback()
ipcRenderer.on('hermes:open-folder-requested', listener)
return () => ipcRenderer.removeListener('hermes:open-folder-requested', listener)
},
onOpenUpdatesRequested: callback => {
const listener = () => callback()
ipcRenderer.on('hermes:open-updates', listener)
+2
View File
@@ -101,7 +101,9 @@
"d3-force": "^3.0.0",
"dnd-core": "^14.0.1",
"dompurify": "^3.4.11",
"emojibase-data": "^16.0.3",
"fflate": "^0.8.3",
"frimousse": "^0.3.0",
"hast-util-from-html-isomorphic": "^2.0.0",
"hast-util-to-text": "^4.0.2",
"ignore": "^7.0.5",
@@ -0,0 +1,146 @@
// ⌘K open latency, measured in-page (no CDP round-trip in the number).
//
// node scripts/probe-command-palette.mjs [--port 9222] [--rounds 8]
//
// Reports, per round, the time from the keydown the app actually receives to:
// frame_ms — the dialog frame + input in the DOM and painted (what "instant"
// means: the overlay owes you a frame immediately)
// rows_ms — the row list painted (may lag frame_ms; rows are deferred)
// plus any long tasks in the window, so a slow open is attributable.
import { CDP, sleep } from './perf/lib/cdp.mjs'
const args = process.argv.slice(2)
const flag = name => {
const i = args.indexOf(`--${name}`)
return i >= 0 ? args[i + 1] : undefined
}
const port = Number(flag('port') ?? 9222)
const rounds = Number(flag('rounds') ?? 8)
const cdp = await CDP.connect({ port })
await cdp.send('Runtime.enable')
const INSTALL = `
(() => {
if (window.__CMDK__) window.__CMDK__.stop()
const state = { t0: null, frame: null, rows: 0, rowsAt: null, tasks: [], armed: false }
// Time from the keydown the APP receives — excludes CDP transport, so the
// number is what a user's finger actually experiences.
const onKey = e => {
if (state.armed && (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
state.t0 = performance.now()
state.armed = false
}
}
window.addEventListener('keydown', onKey, true)
const obs = new MutationObserver(() => {
if (state.t0 === null) return
if (state.frame === null && document.querySelector('[cmdk-input]')) {
state.frame = performance.now() - state.t0
}
const n = document.querySelectorAll('[cmdk-item]').length
if (n > state.rows) { state.rows = n; state.rowsAt = performance.now() - state.t0 }
})
obs.observe(document.body, { childList: true, subtree: true })
const po = new PerformanceObserver(list => {
for (const e of list.getEntries()) state.tasks.push({ start: e.startTime, dur: Math.round(e.duration) })
})
try { po.observe({ entryTypes: ['longtask'] }) } catch {}
window.__CMDK__ = {
arm: () => { state.t0 = null; state.frame = null; state.rows = 0; state.rowsAt = null; state.tasks = []; state.armed = true },
read: () => ({
frame_ms: state.frame === null ? -1 : Math.round(state.frame),
rows_ms: state.rowsAt === null ? -1 : Math.round(state.rowsAt),
rows: state.rows,
longtask_ms: state.t0 === null ? 0 : state.tasks.filter(t => t.start >= state.t0).reduce((s, t) => s + t.dur, 0)
}),
stop: () => { window.removeEventListener('keydown', onKey, true); obs.disconnect(); po.disconnect() }
}
return true
})()
`
// Settle: frame painted AND rows stopped growing for two frames.
const WAIT = `
new Promise(resolve => {
let stable = 0
let last = -1
const started = performance.now()
const tick = () => {
const r = window.__CMDK__.read()
if (r.frame_ms >= 0 && r.rows === last && r.rows > 0) {
if (++stable >= 2) { resolve(r); return }
} else { stable = 0 }
last = r.rows
if (performance.now() - started > 8000) { resolve(window.__CMDK__.read()); return }
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
})
`
const key = async type =>
cdp.send('Input.dispatchKeyEvent', {
type,
key: 'k',
code: 'KeyK',
windowsVirtualKeyCode: 75,
nativeVirtualKeyCode: 75,
modifiers: 4
})
const esc = async () => {
for (const type of ['keyDown', 'keyUp']) {
await cdp.send('Input.dispatchKeyEvent', { type, key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 })
}
await sleep(400)
}
await cdp.eval(INSTALL)
await esc()
const samples = []
for (let i = 0; i < rounds; i++) {
await sleep(250)
await cdp.eval('window.__CMDK__.arm()')
await key('rawKeyDown')
await key('keyUp')
const r = await cdp.eval(WAIT)
samples.push(r)
console.log(`round ${i}:`, r)
await esc()
}
await cdp.eval('window.__CMDK__.stop()')
const stat = k => {
const v = samples.map(s => s[k]).filter(n => n >= 0).sort((a, b) => a - b)
if (!v.length) return null
return {
min: v[0],
median: v[Math.floor(v.length / 2)],
max: v[v.length - 1]
}
}
console.log('\nkeydown → dialog frame painted (ms):', stat('frame_ms'))
console.log('keydown → rows painted (ms):', stat('rows_ms'))
console.log('long-task time in window (ms):', stat('longtask_ms'))
cdp.close()
+99 -13
View File
@@ -1,9 +1,30 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const closeFocusedSessionTab = vi.fn(() => false)
const nextSessionTileForWorkspace = vi.fn<() => null | string>(() => null)
const closeSessionTile = vi.fn()
const requestFreshSession = vi.fn()
vi.mock('@/components/pane-shell/tree/store', () => ({
closeFocusedSessionTab: () => closeFocusedSessionTab()
}))
vi.mock('@/store/session-states', () => ({
closeSessionTile: (...args: unknown[]) => closeSessionTile(...args),
nextSessionTileForWorkspace: () => nextSessionTileForWorkspace()
}))
vi.mock('@/store/profile', () => ({
requestFreshSession: () => requestFreshSession()
}))
import { $rightRailActiveTabId } from '@/store/layout'
import { $previewTabs, closeRightRail, openPreview, type PreviewTarget } from '@/store/preview'
import { $activeSessionId, $selectedStoredSessionId } from '@/store/session'
import { closeActiveTab } from './close-tab'
import { $workspaceIsPage } from '../routes'
import { closeActiveTab, closeWorkspaceTab } from './close-tab'
function fileTarget(path: string): PreviewTarget {
return {
@@ -16,19 +37,31 @@ function fileTarget(path: string): PreviewTarget {
}
}
/** Main is holding a loaded chat and nothing else is stacked with it. */
function loadedMainOnly() {
$selectedStoredSessionId.set('stored-a')
$activeSessionId.set('runtime-a')
}
beforeEach(() => {
vi.stubGlobal('document', { activeElement: null })
closeRightRail()
window.localStorage.clear()
$selectedStoredSessionId.set(null)
$activeSessionId.set(null)
$workspaceIsPage.set(false)
closeFocusedSessionTab.mockReturnValue(false)
nextSessionTileForWorkspace.mockReturnValue(null)
vi.clearAllMocks()
})
afterEach(() => {
vi.unstubAllGlobals()
closeRightRail()
window.localStorage.clear()
})
describe('closeActiveTab', () => {
beforeEach(() => {
vi.stubGlobal('document', { activeElement: null })
closeRightRail()
window.localStorage.clear()
})
afterEach(() => {
vi.unstubAllGlobals()
closeRightRail()
window.localStorage.clear()
})
it('closes the active file preview tab (⌘W happy path)', () => {
openPreview(fileTarget('/work/notes.md'), 'manual')
@@ -50,3 +83,56 @@ describe('closeActiveTab', () => {
expect($previewTabs.get()).toHaveLength(0)
})
})
/**
* The main tab's own close. The workspace pane can never leave the tree, so
* every answer here is about what FILLS it — a stacked session, or an empty
* draft. The gesture used to dead-end whenever main was the only tab.
*/
describe('closeWorkspaceTab', () => {
it('shifts the next stacked session into main', () => {
loadedMainOnly()
nextSessionTileForWorkspace.mockReturnValue('stored-b')
const load = vi.fn()
expect(closeWorkspaceTab(load)).toBe(true)
expect(closeSessionTile).toHaveBeenCalledWith('stored-b')
expect(load).toHaveBeenCalledWith('stored-b')
// Promotion refills main — it must not ALSO blank it.
expect(requestFreshSession).not.toHaveBeenCalled()
})
it('drops a lone loaded main to a fresh draft', () => {
loadedMainOnly()
expect(closeWorkspaceTab(vi.fn())).toBe(true)
expect(requestFreshSession).toHaveBeenCalledTimes(1)
})
it('empties main even with no session loader wired', () => {
loadedMainOnly()
expect(closeWorkspaceTab()).toBe(true)
expect(requestFreshSession).toHaveBeenCalledTimes(1)
})
it('is a no-op on a blank draft — that IS the post-close state', () => {
expect(closeWorkspaceTab(vi.fn())).toBe(false)
expect(requestFreshSession).not.toHaveBeenCalled()
})
it('is a no-op over a full-page view, which owns no chat tab', () => {
loadedMainOnly()
$workspaceIsPage.set(true)
expect(closeWorkspaceTab(vi.fn())).toBe(false)
expect(requestFreshSession).not.toHaveBeenCalled()
})
it('⌘W reaches it once the terminal, rail and zone tabs pass', () => {
loadedMainOnly()
expect(closeActiveTab(vi.fn())).toBe(true)
expect(requestFreshSession).toHaveBeenCalledTimes(1)
})
})
+51 -26
View File
@@ -1,27 +1,68 @@
import { mainChatOccupied } from '@/app/open-session'
import { closeActiveTerminal } from '@/app/right-sidebar/terminal/terminals'
import { $workspaceIsPage } from '@/app/routes'
import { closeFocusedSessionTab } from '@/components/pane-shell/tree/store'
import { isFocusWithin } from '@/lib/keybinds/combo'
import { $previewTabs, closeActiveRightRailTab } from '@/store/preview'
import { requestFreshSession } from '@/store/profile'
import { $activeSessionId, $selectedStoredSessionId } from '@/store/session'
import { closeSessionTile, nextSessionTileForWorkspace } from '@/store/session-states'
/**
* Close the MAIN tab. The workspace pane itself can't leave the tree, so
* "closing" it means emptying it, and what fills the hole depends on what's
* stacked beside it:
*
* - session tabs stacked with it → the next one shifts INTO main (drop its
* tile, load it as the primary — the session stays alive, no busy prompt),
* - nothing stacked → main drops to a fresh "New session" draft.
*
* The second half is what makes the gesture honest when main is the ONLY tab:
* ⌘W / ⌘-click / middle-click used to be a dead key there, since the only
* available answer was "remove the pane", which this app never does.
*
* Returns false when there is nothing to close — a blank draft (already the
* post-close state) or a full-page view (skills / artifacts, which isn't a
* chat and owns no tab). ⌘W then stays a no-op; it never closes the window.
*
* `loadSessionIntoWorkspace` carries the app's route-based "load this session
* into main"; omitting it disables the promotion half.
*/
export function closeWorkspaceTab(loadSessionIntoWorkspace?: (storedSessionId: string) => void): boolean {
// Order matters — close the tile FIRST so the selection homes to the
// workspace instead of re-fronting the tile.
if (loadSessionIntoWorkspace) {
const next = nextSessionTileForWorkspace()
if (next) {
closeSessionTile(next)
loadSessionIntoWorkspace(next)
return true
}
}
if ($workspaceIsPage.get() || !mainChatOccupied($activeSessionId.get(), $selectedStoredSessionId.get())) {
return false
}
requestFreshSession()
return true
}
/**
* ⌘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 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).
* 4. the workspace tab itself — see `closeWorkspaceTab`.
* 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.
* window. 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).
*/
export function closeActiveTab(loadSessionIntoWorkspace?: (storedSessionId: string) => void): boolean {
if (isFocusWithin('[data-terminal]')) {
@@ -43,21 +84,5 @@ export function closeActiveTab(loadSessionIntoWorkspace?: (storedSessionId: stri
return true
}
// The main (workspace) tab is active and can't be closed — but if session
// tabs are stacked with it, ⌘W shifts the next one into the main tab: drop
// its tile (the session stays alive, no busy-close prompt) and load it into
// main. Order matters — close the tile FIRST so the selection homes to the
// workspace instead of re-fronting the tile.
if (loadSessionIntoWorkspace) {
const next = nextSessionTileForWorkspace()
if (next) {
closeSessionTile(next)
loadSessionIntoWorkspace(next)
return true
}
}
return false
return closeWorkspaceTab(loadSessionIntoWorkspace)
}
@@ -0,0 +1,131 @@
import type { Unstable_TriggerItem } from '@assistant-ui/core'
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
import { classify } from './hooks/use-at-completions'
import { useComposerTrigger } from './hooks/use-composer-trigger'
import { composerPlainText, RICH_INPUT_SLOT } from './rich-editor'
/** A row exactly as tui_gateway's complete.path emits it, run through the
* real classify() the popover uses. */
function backendRow(text: string, display: string, meta: string): Unstable_TriggerItem {
const c = classify({ text, display, meta })
return {
id: `${text}|0`,
type: c.type,
label: c.display,
metadata: { icon: c.type, display: c.display, meta: c.meta, rawText: text, insertId: c.insertId }
}
}
function typed(text: string) {
const editor = document.createElement('div')
editor.contentEditable = 'true'
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
editor.append(document.createTextNode(text))
const range = document.createRange()
range.selectNodeContents(editor)
range.collapse(false)
const sel = window.getSelection()
sel?.removeAllRanges()
sel?.addRange(range)
const editorRef = { current: editor as HTMLDivElement | null }
const { result } = renderHook(() =>
useComposerTrigger({
at: { adapter: null, loading: false },
draftRef: { current: text },
editorRef,
requestMainFocus: vi.fn(),
setComposerText: vi.fn(),
slash: { adapter: null, loading: false }
})
)
act(() => result.current.refreshTrigger())
return { editor, result }
}
/** The label the sent message renders for a committed draft. */
function sentLabel(draft: string) {
return hermesDirectiveFormatter
.parse(draft)
.filter((s): s is Extract<typeof s, { kind: 'mention' }> => s.kind === 'mention')
.map(s => s.label)
.join(',')
}
describe('one label per reference, on every surface', () => {
it('the popover row, the committed chip, and the sent chip all read the same', () => {
const cases = [
{ text: '@folder:apps/desktop/', display: 'desktop/', meta: 'dir' },
{ text: '@file:apps/desktop/src/main.tsx', display: 'main.tsx', meta: 'apps/desktop/src' },
{ text: '@folder:apps/desktop/src/', display: 'src/', meta: 'dir' }
]
for (const entry of cases) {
const item = backendRow(entry.text, entry.display, entry.meta)
const { editor, result } = typed('@desk')
act(() => result.current.replaceTriggerWithChip(item))
const row = String((item.metadata as { display: string }).display)
const chip = editor.querySelector('[data-ref-text]')?.textContent ?? ''
expect(chip).toBe(row)
expect(sentLabel(composerPlainText(editor))).toBe(row)
}
})
it('a folder pick reads as its path, not a bare basename', () => {
// `src` and `desktop` repeat all over a repo — the row you picked said
// where it was, and the chip has to keep saying it.
const item = backendRow('@folder:apps/desktop/', 'desktop/', 'dir')
expect(item.label).toBe('apps/desktop/')
const { editor, result } = typed('@desk')
act(() => result.current.replaceTriggerWithChip(item))
expect(editor.querySelector('[data-ref-text]')?.textContent).toBe('apps/desktop/')
})
it('Tab-descend leaves the live query, and the scope when there is one', () => {
const { editor, result } = typed('@folder:desk')
act(() =>
result.current.replaceTriggerWithChip(backendRow('@folder:apps/desktop/', 'desktop/', 'dir'), {
descend: true
})
)
// Mid-browse the editor holds the live query, scope included — that's the
// path being typed, not a label, and it's what the next completion reads.
expect(composerPlainText(editor)).toBe('@folder:apps/desktop/')
})
it('a url still reads host + path on every surface', () => {
const item = backendRow('@url:https://github.com/NousResearch/hermes-agent/pull/74533', '', '')
const { editor, result } = typed('@gith')
act(() => result.current.replaceTriggerWithChip(item))
const expected = 'github.com/NousResearch/hermes-agent/pull/74533'
expect(item.label).toBe(expected)
expect(editor.querySelector('[data-ref-text]')?.textContent).toBe(expected)
expect(sentLabel(composerPlainText(editor))).toBe(expected)
})
})
@@ -0,0 +1,164 @@
import type { Unstable_TriggerItem } from '@assistant-ui/core'
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { useComposerTrigger } from './hooks/use-composer-trigger'
import { pathifyRefs } from './path-refs'
import { composerPlainText, insertComposerContentsAtCaret, RICH_INPUT_SLOT } from './rich-editor'
import { detectTrigger, openDirectiveScope, textBeforeCaret } from './text-utils'
import { linkifyUrls } from './url-refs'
function folderItem(rel: string): Unstable_TriggerItem {
const rawText = `@folder:${rel}/`
return {
id: `${rawText}|0`,
type: 'folder',
label: rel.split('/').filter(Boolean).pop() ?? rel,
metadata: { icon: 'folder', display: `${rel}/`, meta: 'dir', rawText, insertId: `${rel}/` }
}
}
/** Literally-typed text, caret `fromEnd` characters before the end. */
function typed(text: string, fromEnd = 0) {
const editor = document.createElement('div')
editor.contentEditable = 'true'
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
const node = document.createTextNode(text)
editor.append(node)
const range = document.createRange()
range.setStart(node, text.length - fromEnd)
range.collapse(true)
const sel = window.getSelection()
sel?.removeAllRanges()
sel?.addRange(range)
return editor
}
function withTrigger(editor: HTMLDivElement, draft: string) {
const editorRef = { current: editor as HTMLDivElement | null }
const { result } = renderHook(() =>
useComposerTrigger({
at: { adapter: null, loading: false },
draftRef: { current: draft },
editorRef,
requestMainFocus: vi.fn(),
setComposerText: vi.fn(),
slash: { adapter: null, loading: false }
})
)
act(() => result.current.refreshTrigger())
return result
}
/** The composer's paste handler, minus the clipboard plumbing. */
function paste(editor: HTMLDivElement, text: string) {
insertComposerContentsAtCaret(editor, pathifyRefs(linkifyUrls(text)), openDirectiveScope(editor))
}
describe('directive scope is a browse mode, not text to maintain', () => {
it('Tab-descend carries the scope down instead of dropping to a bare path', () => {
const editor = typed('@folder:apps/deskt')
const result = withTrigger(editor, '@folder:apps/deskt')
expect(result.current.trigger).toMatchObject({ kind: '@', scope: 'folder', value: 'apps/deskt' })
act(() => result.current.replaceTriggerWithChip(folderItem('apps/desktop'), { descend: true }))
expect(composerPlainText(editor)).toBe('@folder:apps/desktop/')
})
it('Backspace climbs the path, then drops the whole scope', () => {
const editor = typed('@folder:apps/desktop/')
const result = withTrigger(editor, '@folder:apps/desktop/')
act(() => result.current.ascendTriggerPath())
expect(composerPlainText(editor)).toBe('@folder:apps/')
act(() => result.current.refreshTrigger())
act(() => result.current.ascendTriggerPath())
expect(composerPlainText(editor)).toBe('@folder:')
// The scope is one unit: Backspace drops it whole rather than nibbling
// back through `:`, `r`, `e`, `d`, `l`, `o`, `f`.
act(() => result.current.refreshTrigger())
act(() => result.current.ascendTriggerPath())
expect(composerPlainText(editor)).toBe('@')
})
it('leaves Backspace alone when there is no scope and no path', () => {
const editor = typed('@apps')
const result = withTrigger(editor, '@apps')
let handled = true
act(() => {
handled = result.current.ascendTriggerPath()
})
expect(handled).toBe(false)
})
it('a pick mid-message keeps the trailing prose and consumes the whole token', () => {
const editor = typed('@folder:apps/deskt and some trailing words', 24)
const result = withTrigger(editor, '@folder:apps/deskt and some trailing words')
act(() => result.current.replaceTriggerWithChip(folderItem('apps/desktop')))
expect(composerPlainText(editor)).toBe('@folder:`apps/desktop/` and some trailing words')
expect(editor.querySelector('[data-ref-kind="folder"]')).not.toBeNull()
})
it('pasting into an open @url: scope consumes it instead of stacking', () => {
const editor = typed('refer to @url:')
paste(editor, 'https://github.com/NousResearch/hermes-agent/pull/74533')
expect(composerPlainText(editor)).toBe('refer to @url:`https://github.com/NousResearch/hermes-agent/pull/74533`')
expect(editor.textContent).not.toContain('@url:@url:')
})
it('a normal paste with no open scope is untouched', () => {
const editor = typed('look at ')
paste(editor, 'https://example.com/x')
expect(composerPlainText(editor)).toBe('look at @url:`https://example.com/x`')
})
it('scope parsing leaves an unscoped @ query alone', () => {
expect(detectTrigger('@apps/desk')).toMatchObject({ kind: '@', value: 'apps/desk' })
expect(detectTrigger('@apps/desk')?.scope).toBeUndefined()
})
it('openDirectiveScope only fires on an EMPTY scope', () => {
// The count is what a paste consumes: `@url:` is 5 characters of syntax
// the user never typed and shouldn't be left holding.
expect(openDirectiveScope(typed('@url:'))).toBe(5)
expect(openDirectiveScope(typed('@url:https://x.com'))).toBe(0)
expect(openDirectiveScope(typed('plain text'))).toBe(0)
})
it('chips stay atomic to scope detection', () => {
const editor = typed('@folder:apps/desktop/')
const result = withTrigger(editor, '@folder:apps/desktop/')
act(() => result.current.replaceTriggerWithChip(folderItem('apps/desktop')))
// A committed chip is one object-replacement char, so a fresh `@` typed
// after it opens an unscoped browse rather than inheriting the old scope.
expect(detectTrigger(`${textBeforeCaret(editor)}@`)?.scope).toBeUndefined()
})
})
@@ -0,0 +1,139 @@
import { describe, expect, it } from 'vitest'
import {
composerPlainText,
normalizeComposerEditorDom,
renderComposerContents,
RICH_INPUT_SLOT
} from './rich-editor'
function editor(): HTMLDivElement {
const el = document.createElement('div')
el.dataset.slot = RICH_INPUT_SLOT
el.contentEditable = 'true'
document.body.append(el)
return el
}
/** Whatever emptied it — Delete, cut, Chromium's own selection-delete — the
* normalizer lands on the same DOM. */
function emptied(): HTMLDivElement {
const el = editor()
el.append(document.createTextNode('hello'))
el.replaceChildren()
normalizeComposerEditorDom(el)
return el
}
describe('an emptied composer reads as empty', () => {
it('keeps the placeholder <br> so the contenteditable holds its height', () => {
// The scaffolding is deliberate: a childless contenteditable collapses to a
// sliver in Chromium. It just must not read as content.
expect(emptied().innerHTML).toBe('<br>')
})
it('reads that editor as empty, not as a newline', () => {
expect(composerPlainText(emptied())).toBe('')
})
it('reads a truly childless editor as empty', () => {
expect(composerPlainText(editor())).toBe('')
})
it('still reads a real Shift+Enter line break as a newline', () => {
const el = editor()
el.append(document.createTextNode('one'), document.createElement('br'), document.createTextNode('two'))
expect(composerPlainText(el)).toBe('one\ntwo')
})
it('still reads a trailing break after text as a newline', () => {
const el = editor()
el.append(document.createTextNode('one'), document.createElement('br'))
expect(composerPlainText(el)).toBe('one\n')
})
it('only treats the EDITOR\u2019s lone <br> as scaffolding, not a nested one', () => {
// A lone <br> inside some other element is a real line break; the exemption
// is scoped to the editor root by its slot marker. (The block wrapper adds
// its own trailing newline — unchanged behavior, asserted so the exemption
// can't quietly widen to nested nodes.)
const el = editor()
const inner = document.createElement('div')
inner.append(document.createElement('br'))
el.append(document.createTextNode('one'), inner)
expect(composerPlainText(el)).toBe('one\n\n')
})
})
/** The rule the stylesheet paints the placeholder with. `:empty` alone goes
* false the instant the scaffolding <br> lands. */
const PLACEHOLDER_SHOWS = ':is(:empty, [data-empty])'
describe('an emptied composer shows its placeholder again', () => {
it('advertises emptiness once the scaffolding break is in place', () => {
expect(emptied().matches(PLACEHOLDER_SHOWS)).toBe(true)
})
it('advertises emptiness for a truly childless editor', () => {
expect(editor().matches(PLACEHOLDER_SHOWS)).toBe(true)
})
it('stops advertising it once something is typed', () => {
const el = emptied()
el.replaceChildren(document.createTextNode('hi'))
normalizeComposerEditorDom(el)
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(false)
})
// A text node is invisible to selectors, so `one<br>` and `<br>` are the same
// shape to any pure-CSS rule (`:has(> br:only-child)` matches both and paints
// the placeholder straight over the user's text). The DOM writer has to say.
it('does not advertise emptiness for a trailing break after text', () => {
const el = editor()
el.append(document.createTextNode('one'), document.createElement('br'))
normalizeComposerEditorDom(el)
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(false)
})
it('does not advertise emptiness for a Shift+Enter break between text', () => {
const el = editor()
el.append(document.createTextNode('one'), document.createElement('br'), document.createTextNode('two'))
normalizeComposerEditorDom(el)
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(false)
})
// Repainting from text (restored draft, undo, completion rebuild) is the
// other writer that reshapes the editor root — it must not strand the marker.
it('drops the marker when a draft is painted back in', () => {
const el = emptied()
renderComposerContents(el, 'restored draft')
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(false)
})
it('re-advertises emptiness when a draft is painted back out', () => {
const el = editor()
renderComposerContents(el, 'temporary')
renderComposerContents(el, '')
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(true)
})
})
@@ -1,12 +1,16 @@
import { afterEach, describe, expect, it } from 'vitest'
import { $hoveredTreeGroup } from '@/components/pane-shell/tree/store'
import {
blurComposerInput,
getActiveComposer,
markActiveComposer,
onComposerFocusRequest,
onComposerModelMenuRequest,
releaseActiveComposer,
requestComposerFocus
requestComposerFocus,
requestModelMenuToggle
} from './focus'
import { RICH_INPUT_SLOT } from './rich-editor'
@@ -45,6 +49,7 @@ afterEach(() => {
// `activeTarget` is module-level — a case that leaves a stale claim behind
// would otherwise decide the next one.
markActiveComposer('main')
$hoveredTreeGroup.set(null)
})
describe('blurComposerInput', () => {
@@ -216,3 +221,65 @@ describe('resolveActive / keep-alive tab heal', () => {
expect(getActiveComposer()).toBe('edit')
})
})
/** A chat surface inside a layout zone, mirroring ChatView-in-tree-group. */
function mountZonedSurface(target: string, zone: string, hidden = false) {
const group = document.createElement('div')
group.dataset.treeGroup = zone
const layer = document.createElement('div')
layer.toggleAttribute('data-pane-hidden', hidden)
const surface = document.createElement('div')
surface.dataset.composerTarget = target
layer.append(surface)
group.append(layer)
document.body.append(group)
return surface
}
const collectModelMenuTargets = async (): Promise<string[]> => {
const saw: string[] = []
const off = onComposerModelMenuRequest(target => saw.push(target))
await new Promise(resolve => window.setTimeout(resolve, 0))
off()
return saw
}
describe('requestModelMenuToggle', () => {
it('targets the pane under the pointer over the focused one (#74447 convention)', async () => {
mountZonedSurface('main', 'zone-a')
mountZonedSurface('tile:hovered', 'zone-b')
markActiveComposer('main')
$hoveredTreeGroup.set('zone-b')
expect(requestModelMenuToggle()).toBe(true)
expect(await collectModelMenuTargets()).toEqual(['tile:hovered'])
})
it('falls back to the active composer when the pointer is off every zone', async () => {
mountZonedSurface('main', 'zone-a')
mountZonedSurface('tile:other', 'zone-b')
markActiveComposer('tile:other')
expect(requestModelMenuToggle()).toBe(true)
expect(await collectModelMenuTargets()).toEqual(['tile:other'])
})
it('skips a hidden keep-alive tab in the hovered zone (targets its visible sibling)', async () => {
mountZonedSurface('main', 'zone-a', true)
mountZonedSurface('tile:front', 'zone-a')
markActiveComposer('main')
$hoveredTreeGroup.set('zone-a')
expect(requestModelMenuToggle()).toBe(true)
expect(await collectModelMenuTargets()).toEqual(['tile:front'])
})
it('returns false with no chat surface on screen so the caller can open the dialog', async () => {
// Settings/profiles routes: no [data-composer-target] anywhere.
expect(requestModelMenuToggle()).toBe(false)
expect(await collectModelMenuTargets()).toEqual([])
})
})
+41 -1
View File
@@ -10,7 +10,8 @@
* steal focus from the composer effect.
*/
import { queryVisible } from '@/components/pane-shell/pane-visibility'
import { queryAllVisible, queryVisible } from '@/components/pane-shell/pane-visibility'
import { $hoveredTreeGroup } from '@/components/pane-shell/tree/store'
import type { InlineRefInput } from './inline-refs'
import { RICH_INPUT_SLOT } from './rich-editor'
@@ -42,6 +43,7 @@ const INSERT_EVENT = 'hermes:composer-insert'
const INSERT_REFS_EVENT = 'hermes:composer-insert-refs'
const SUBMIT_EVENT = 'hermes:composer-submit'
const VOICE_TOGGLE_EVENT = 'hermes:composer-voice-toggle'
const MODEL_MENU_EVENT = 'hermes:composer-model-menu'
/** Inline edit composer root — mounted only while a user bubble is being edited. */
const EDIT_COMPOSER_ROOT = '[data-slot="aui_edit-composer-root"]'
@@ -258,6 +260,44 @@ export const requestVoiceToggle = (target: ComposerTarget | 'active' = 'active')
export const onComposerVoiceToggleRequest = (handler: (target: ComposerTarget) => void) =>
subscribe<{ target: ComposerTarget }>(VOICE_TOGGLE_EVENT, ({ target }) => handler(target))
/** The chat surface inside the zone the pointer is over, if any. Mirrors the
* tab verbs' hover-first targeting (`tabTargetGroupId`, #74447): the model
* hotkey lands in the pane you're pointing at without clicking into it first.
* Hidden keep-alive tabs are skipped like every document-wide lookup. */
const composerTargetInHoveredZone = (): ComposerTarget | null => {
const zone = $hoveredTreeGroup.get()
if (!zone || typeof document === 'undefined') {
return null
}
const surface = queryAllVisible<HTMLElement>('[data-composer-target]').find(
el => el.closest<HTMLElement>('[data-tree-group]')?.dataset.treeGroup === zone
)
return (surface?.dataset.composerTarget as ComposerTarget | undefined) ?? null
}
/** Toggle ONE composer's model menu — the `composer.modelPicker` hotkey.
* Targets the pane under the pointer first (the tab-verb convention), then
* the active composer. Returns false when no chat surface is on screen at
* all (settings, profiles…), so the caller can fall back to the full
* model-picker dialog instead of dispatching into the void. */
export const requestModelMenuToggle = (): boolean => {
if (typeof document !== 'undefined' && !queryVisible('[data-composer-target]')) {
return false
}
dispatch<{ target: ComposerTarget }>(MODEL_MENU_EVENT, {
target: composerTargetInHoveredZone() ?? resolveActive()
})
return true
}
export const onComposerModelMenuRequest = (handler: (target: ComposerTarget) => void) =>
subscribe<{ target: ComposerTarget }>(MODEL_MENU_EVENT, ({ target }) => handler(target))
/**
* Focus a composer input across React commit + browser focus restore.
*
@@ -0,0 +1,107 @@
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { queryClient } from '@/lib/query-client'
import { useAtCompletions } from './use-at-completions'
function gatewayStub(latencyMs = 40) {
const calls: string[] = []
const gateway = {
request: vi.fn(async (_method: string, params: { word: string }) => {
calls.push(params.word)
await new Promise(r => setTimeout(r, latencyMs))
return { items: [{ text: `@folder:${params.word.slice(1)}x/`, display: 'x/', meta: 'dir' }] }
})
}
return { calls, gateway }
}
function setup(latencyMs = 40) {
const { calls, gateway } = gatewayStub(latencyMs)
const { result } = renderHook(() => useAtCompletions({ gateway: gateway as never, sessionId: 's1', cwd: '/repo' }))
return { calls, result }
}
/** Type a burst of keystrokes `gapMs` apart, like a person. */
async function type(
result: { current: { adapter: { search?: (q: string) => unknown } } },
queries: string[],
gapMs: number
) {
for (const q of queries) {
act(() => {
result.current.adapter.search?.(q)
})
await act(async () => {
await vi.advanceTimersByTimeAsync(gapMs)
})
}
}
describe('PERF: @ path completions are cached and skip the debounce', () => {
it('serves a repeated query with no round trip and no spinner', async () => {
vi.useFakeTimers()
queryClient.clear()
const { calls, result } = setup()
// First visit to `apps/` pays the round trip.
await type(result, ['apps/'], 0)
await act(async () => {
await vi.advanceTimersByTimeAsync(200)
})
const afterFirst = calls.length
expect(afterFirst).toBe(1)
// Walk away and come back — Tab in, Backspace out, retype. Every one of
// these used to be a fresh git ls-files + rank on the backend.
await type(result, ['apps/desktop/', 'apps/', 'apps/desktop/', 'apps/'], 0)
await act(async () => {
await vi.advanceTimersByTimeAsync(200)
})
// Two distinct paths, so exactly two round trips total — the repeats are free.
expect(calls.length).toBe(2)
expect(result.current.loading).toBe(false)
vi.useRealTimers()
})
it('a cached query paints without waiting out the debounce', async () => {
vi.useFakeTimers()
queryClient.clear()
const { calls, result } = setup()
await type(result, ['apps/'], 0)
await act(async () => {
await vi.advanceTimersByTimeAsync(200)
})
expect(calls.length).toBe(1)
// Re-ask for the cached query and advance by far less than the 60ms
// debounce. A cached answer resolves in a microtask, so it must paint
// without the timer and without ever flipping the spinner on.
act(() => {
result.current.adapter.search?.('apps/')
})
await act(async () => {
await vi.advanceTimersByTimeAsync(1)
})
expect(result.current.loading).toBe(false)
expect(calls.length).toBe(1)
vi.useRealTimers()
})
})
@@ -1,7 +1,9 @@
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
import { useCallback } from 'react'
import { refChipLabel } from '@/components/assistant-ui/directive-text'
import type { HermesGateway } from '@/hermes'
import { cachedPathCompletion, hasCachedPathCompletion } from '@/lib/slash-completion-cache'
import { normalize } from '@/lib/text'
import type { CompletionEntry, CompletionPayload } from './use-live-completion-adapter'
@@ -60,7 +62,14 @@ function classify(entry: CompletionEntry): {
return {
type: kind,
insertId: rest,
display: textValue(entry.display, rest || `@${kind}:`),
// The row shows exactly what picking it produces. Upstream keeps one
// label per item and hands it to the chip verbatim (DirectiveNode's
// `__label = item.label`); our wire format is `@kind:value`, which can't
// carry a label the way their `:type[label]{name=id}` does, so the same
// invariant is held by deriving both ends from refChipLabel. Without
// this the list said `desktop/`, the editor said `apps/desktop/`, and
// the chip said `desktop` — three names for one folder.
display: rest ? refChipLabel(kind, rest) : textValue(entry.display, `@${kind}:`),
meta: textValue(entry.meta)
}
}
@@ -82,6 +91,11 @@ export function useAtCompletions(options: {
const { gateway, sessionId, cwd } = options
const enabled = Boolean(gateway)
// Cache key: the completion depends on the query AND the directory it's
// resolved against, so a cwd or session change can't serve another tree's
// listing.
const cacheKey = useCallback((query: string) => `${cwd ?? ''}|${sessionId ?? ''}|${query}`, [cwd, sessionId])
const fetcher = useCallback(
async (query: string): Promise<CompletionPayload> => {
const starters = starterEntries(query)
@@ -102,7 +116,15 @@ export function useAtCompletions(options: {
}
try {
const result = await gateway.request<{ items?: CompletionEntry[] }>('complete.path', params)
// De-duplicated the same way `/` completions are. Walking a path is
// inherently repetitive — Tab into a folder, Backspace out, retype a
// segment — and every one of those steps used to be a fresh
// `git ls-files` + rank on the backend (~40ms of the ~50ms round trip
// measured on this repo's 8k files).
const result = await cachedPathCompletion(cacheKey(query), () =>
gateway.request<{ items?: CompletionEntry[] }>('complete.path', params)
)
const items = result.items ?? []
return { items: items.length > 0 ? items : starters, query }
@@ -110,7 +132,7 @@ export function useAtCompletions(options: {
return { items: starters, query }
}
},
[gateway, sessionId, cwd]
[cacheKey, gateway, sessionId, cwd]
)
const toItem = useCallback((entry: CompletionEntry, index: number): Unstable_TriggerItem => {
@@ -135,7 +157,13 @@ export function useAtCompletions(options: {
}
}, [])
return useLiveCompletionAdapter({ enabled, fetcher, toItem })
// A query already in cache skips both the debounce and the loading state.
// This is what makes walking a tree feel instant rather than merely fast:
// the 60ms debounce exists to avoid a request per keystroke, and it buys
// nothing when the answer is already in hand.
const isCached = useCallback((query: string) => hasCachedPathCompletion(cacheKey(query)), [cacheKey])
return useLiveCompletionAdapter({ enabled, fetcher, isCached, toItem })
}
/** Re-export `classify` for use by the formatter (insertion side). */
@@ -2,6 +2,7 @@ import { useAui, useAuiState, useComposerRuntime } from '@assistant-ui/react'
import { type RefObject, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
import { type ComposerAttachment, stashSessionDraft, takeSessionDraft } from '@/store/composer'
import { isBrowsingHistory } from '@/store/composer-input-history'
@@ -21,7 +22,13 @@ import {
releaseActiveComposer
} from '../focus'
import { type InlineRefInput, insertInlineRefsIntoEditor } from '../inline-refs'
import { composerPlainText, placeCaretEnd, REF_RE, renderComposerContents } from '../rich-editor'
import {
composerPlainText,
normalizeComposerEditorDom,
placeCaretEnd,
REF_RE,
renderComposerContents
} from '../rich-editor'
import { useComposerScope } from '../scope'
import type { ChatBarProps } from '../types'
@@ -121,7 +128,7 @@ export function useComposerDraft({
const editor = editorRef.current
if (editor) {
renderComposerContents(editor, next)
renderComposerContents(editor, next, { trailingCommitted: true })
placeCaretEnd(editor)
}
@@ -237,7 +244,13 @@ export function useComposerDraft({
return draftRef.current
}
const text = composerPlainText(editor)
// Same normalize-then-sanitize the rAF flush does. An emptied editor still
// holds the placeholder <br> that keeps the contenteditable from collapsing
// to a sliver, and that serializes as "\n" — so an editor the user just
// cleared would otherwise stash a one-newline draft and come back non-empty.
normalizeComposerEditorDom(editor)
const text = sanitizeComposerInput(composerPlainText(editor))
if (text !== draftRef.current) {
draftRef.current = text
@@ -265,7 +278,7 @@ export function useComposerDraft({
const editor = editorRef.current
if (editor && document.activeElement !== editor && composerPlainText(editor) !== text) {
renderComposerContents(editor, text)
renderComposerContents(editor, text, { trailingCommitted: true })
}
if (isBrowsingHistory(sessionIdRef.current) || queueEditRef.current) {
@@ -14,6 +14,7 @@ import { useResizeObserver } from '@/hooks/use-resize-observer'
import { COMPOSER_COMPACT_PILL_PX, COMPOSER_SINGLE_LINE_MAX_PX, COMPOSER_STACK_BREAKPOINT_PX } from '../composer-utils'
interface UseComposerMetricsArgs {
composerDockRef: RefObject<HTMLDivElement | null>
composerRef: RefObject<HTMLFormElement | null>
composerSurfaceRef: RefObject<HTMLDivElement | null>
editorRef: RefObject<HTMLDivElement | null>
@@ -28,7 +29,13 @@ interface UseComposerMetricsArgs {
* tree's computed style, and `tight` only flips when it crosses the breakpoint.
* Returns `stacked` (the only value the render needs).
*/
export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut }: UseComposerMetricsArgs): {
export function useComposerMetrics({
composerDockRef,
composerRef,
composerSurfaceRef,
editorRef,
poppedOut
}: UseComposerMetricsArgs): {
compactPill: boolean
stacked: boolean
} {
@@ -89,8 +96,11 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef,
const syncComposerMetrics = useCallback(() => {
const composer = composerRef.current
// The dock is the full docked footprint — strips, status stack, composer —
// so it, not the composer alone, is what the thread has to clear.
const dock = composerDockRef.current
if (!composer) {
if (!composer || !dock) {
return
}
@@ -108,7 +118,8 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef,
return
}
const { height, width } = composer.getBoundingClientRect()
const { height } = dock.getBoundingClientRect()
const { width } = composer.getBoundingClientRect()
const surfaceHeight = composerSurfaceRef.current?.getBoundingClientRect().height
if (width > 0) {
@@ -156,9 +167,9 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef,
setSurfaceVar(composer, COMPOSER_SURFACE_HEIGHT_VAR, `${bucket}px`)
}
}
}, [composerRef, composerSurfaceRef, editorRef])
}, [composerDockRef, composerRef, composerSurfaceRef, editorRef])
useResizeObserver(syncComposerMetrics, composerRef, composerSurfaceRef, editorRef)
useResizeObserver(syncComposerMetrics, composerDockRef, composerRef, composerSurfaceRef, editorRef)
// Toggling pop-out changes whether the composer reserves thread clearance.
// The ResizeObserver may not fire (the box can keep the same box size), so
@@ -170,10 +181,8 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef,
useEffect(() => {
// Resolve the owning surface while the composer is still attached; the
// unmount cleanup runs after React detached the node, where closest()
// can no longer find [data-chat-surface] and would clear the document
// root instead of this surface (same class of bug as the status stack's
// stale-clearance leak).
// unmount cleanup runs after React detached the node, where closest() can
// no longer find [data-chat-surface].
const root = chatSurfaceRoot(composerRef.current)
return () => {
@@ -223,3 +223,72 @@ describe('useComposerTrigger — free-text slash arguments', () => {
expect(editor.querySelector('[data-slash-kind]')?.getAttribute('data-ref-text')).toBe('/personality creative')
})
})
describe('useComposerTrigger — chip survival (the plaintext-demotion bug class)', () => {
it('keeps a leading command pill through a Backspace path-ascend', () => {
// The reported repro: `/work @folder…` then Backspace — both chips went
// plaintext because ascend re-rendered the whole editor from text.
const editor = mountEditor('/work @Desktop/')
const { hook } = mountTrigger(editor, [])
expect(editor.querySelector('[data-slash-kind]')).not.toBeNull()
act(() => hook.result.current.refreshTrigger())
expect(hook.result.current.trigger).toMatchObject({ kind: '@', query: 'Desktop/' })
let ran = false
act(() => {
ran = hook.result.current.ascendTriggerPath()
})
expect(ran).toBe(true)
expect(composerPlainText(editor)).toBe('/work @')
expect(editor.querySelector('[data-slash-kind]')).not.toBeNull()
})
it('keeps a leading command pill when a folder pick commits its ref chip', () => {
const editor = mountEditor('/work @Desk')
const folder: Unstable_TriggerItem = {
id: 'folder:Desktop',
type: 'folder',
label: 'Desktop',
metadata: { rawText: '@folder:Desktop', insertId: 'Desktop' }
}
const { hook } = mountTrigger(editor, [folder])
act(() => hook.result.current.refreshTrigger())
act(() => hook.result.current.replaceTriggerWithChip(folder))
expect(composerPlainText(editor)).toBe('/work @folder:`Desktop` ')
expect(editor.querySelector('[data-slash-kind]')).not.toBeNull()
expect(editor.querySelector('[data-ref-kind="folder"]')).not.toBeNull()
})
it('commits in place when Chromium has split the token across text nodes', () => {
// Chromium fragments text nodes around contenteditable=false chips; the
// commit path must span the fragments instead of bailing to a full
// re-render.
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.contentEditable = 'true'
document.body.append(editor)
editor.append(document.createTextNode('please run /c'), document.createTextNode('le'))
const caret = document.createRange()
caret.setStart(editor.lastChild!, 2)
caret.collapse(true)
const selection = window.getSelection()!
selection.removeAllRanges()
selection.addRange(caret)
const { hook } = mountTrigger(editor, [item('/clean')])
act(() => hook.result.current.refreshTrigger())
act(() => hook.result.current.replaceTriggerWithChip(item('/clean')))
expect(composerPlainText(editor)).toBe('please run /clean ')
expect(editor.querySelector('[data-slash-kind]')).not.toBeNull()
})
})
@@ -12,14 +12,59 @@ import {
slashCommandToken
} from '../composer-utils'
import {
appendComposerContents,
caretOffsetInEditor,
composerPlainText,
placeCaretEnd,
placeCaretAtOffset,
refChipElement,
renderComposerContents,
replaceBeforeCaret,
RICH_INPUT_SLOT,
slashChipElement
} from '../rich-editor'
import { detectTrigger, textBeforeCaret, type TriggerState } from '../text-utils'
/**
* Rebuild-from-text fallback for carets the range walk can't anchor (a
* non-collapsed selection, a caret not preceded by contiguous text). It
* re-renders the whole editor from serialized text, so it only runs when the
* in-place path reports failure — never as the default.
*
* The split is around the CARET, not the end of the draft. Slicing
* `length - tokenLength` off the end assumed the trigger token was the last
* thing in the editor: a completion picked mid-message chopped the trailing
* prose off and stranded a partial `folder:` in front of the chip, because the
* window it removed wasn't the token the user was typing.
*/
export function rebuildAroundCaret(editor: HTMLDivElement, tokenLength: number, insert: DocumentFragment | string) {
const current = composerPlainText(editor)
const caret = caretOffsetInEditor(editor)
const prefix = current.slice(0, Math.max(0, caret - tokenLength))
const suffix = current.slice(caret)
if (typeof insert === 'string') {
renderComposerContents(editor, `${prefix}${insert}${suffix}`)
placeCaretAtOffset(editor, prefix.length + insert.length)
return
}
// Measure before appending — moving a fragment empties it. Appending the
// element rather than re-serializing keeps mid-message slash pills alive:
// they have no text hydration, unlike `@` refs and the leading command.
const scratch = document.createElement('div')
scratch.dataset.slot = RICH_INPUT_SLOT
scratch.append(insert.cloneNode(true))
const inserted = composerPlainText(scratch)
renderComposerContents(editor, prefix)
editor.append(insert)
appendComposerContents(editor, suffix)
placeCaretAtOffset(editor, prefix.length + inserted.length)
}
interface CompletionSource {
adapter: Unstable_TriggerAdapter | null
loading: boolean
@@ -29,6 +74,10 @@ interface UseComposerTriggerOptions {
at: CompletionSource
draftRef: MutableRefObject<string>
editorRef: RefObject<HTMLDivElement | null>
/** `:joy` emoji completions — inserts the emoji character, never a chip. */
emoji?: CompletionSource
/** Bank the pre-commit state so a popover pick is a single undo step. */
recordUndoPoint?: () => void
requestMainFocus: () => void
setComposerText: (text: string) => void
slash: CompletionSource
@@ -47,6 +96,8 @@ export function useComposerTrigger({
at,
draftRef,
editorRef,
emoji,
recordUndoPoint,
requestMainFocus,
setComposerText,
slash
@@ -87,7 +138,7 @@ export function useComposerTrigger({
// is present do we pay the cost of the full walk + DOM range work.
const rawText = editor.textContent ?? ''
if (!rawText.includes('@') && !rawText.includes('/')) {
if (!rawText.includes('@') && !rawText.includes('/') && !rawText.includes(':')) {
if (trigger) {
setTrigger(null)
resetTriggerActive()
@@ -124,7 +175,13 @@ export function useComposerTrigger({
}, [editorRef, resetTriggerActive, trigger])
const triggerAdapter: Unstable_TriggerAdapter | null =
trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null
trigger?.kind === '@'
? at.adapter
: trigger?.kind === '/'
? slash.adapter
: trigger?.kind === ':'
? (emoji?.adapter ?? null)
: null
useEffect(() => {
if (!trigger || !triggerAdapter?.search) {
@@ -142,7 +199,14 @@ export function useComposerTrigger({
setTriggerItems(trigger.inline ? items.filter(isSkillItem) : items)
}, [trigger, triggerAdapter])
const triggerLoading = trigger?.kind === '@' ? at.loading : trigger?.kind === '/' ? slash.loading : false
const triggerLoading =
trigger?.kind === '@'
? at.loading
: trigger?.kind === '/'
? slash.loading
: trigger?.kind === ':'
? (emoji?.loading ?? false)
: false
// Suppress the "No matches" empty state once a slash command is past its name:
// a no-arg command has nothing to offer, and a fully-typed arg commits on
@@ -214,17 +278,22 @@ export function useComposerTrigger({
return
}
// Bank the pre-commit state first — every path below mutates the editor,
// and a pick must be exactly one undo step.
recordUndoPoint?.()
const rebuildAround = (insert: DocumentFragment | string) => rebuildAroundCaret(editor, trigger.tokenLength, insert)
// Action items (e.g. "Browse all sessions…") run a side effect instead of
// inserting a chip: strip the typed trigger token, then fire the action.
const completionAction = (item.metadata as { action?: unknown } | undefined)?.action
const runAction = typeof completionAction === 'string' ? COMPLETION_ACTIONS[completionAction] : undefined
if (runAction) {
const current = composerPlainText(editor)
const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength))
if (!replaceBeforeCaret(editor, trigger.tokenLength, document.createDocumentFragment())) {
rebuildAround('')
}
renderComposerContents(editor, prefix)
placeCaretEnd(editor)
draftRef.current = composerPlainText(editor)
setComposerText(draftRef.current)
closeTrigger()
@@ -247,19 +316,29 @@ export function useComposerTrigger({
? String((item.metadata as { insertId?: unknown } | undefined)?.insertId ?? '')
: ''
if (descendInto) {
const path = descendInto.endsWith('/') ? descendInto : `${descendInto}/`
const current = composerPlainText(editor)
const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength))
renderComposerContents(editor, `${prefix}@${path}`)
placeCaretEnd(editor)
const finish = (keepOpen: boolean) => {
draftRef.current = composerPlainText(editor)
setComposerText(draftRef.current)
requestMainFocus()
window.setTimeout(refreshTrigger, 0)
keepOpen ? window.setTimeout(refreshTrigger, 0) : closeTrigger()
}
return
if (descendInto) {
const path = descendInto.endsWith('/') ? descendInto : `${descendInto}/`
// Carry the browse scope down with the path. Dropping it turned an
// explicit `@folder:` browse into a bare `@apps/desktop/` token halfway
// through, so the next completion silently widened back to files and the
// committed chip had to re-guess the kind from a trailing slash.
const scope = trigger.scope ? `${trigger.scope}:` : ''
const fragment = document.createDocumentFragment()
fragment.append(document.createTextNode(`@${scope}${path}`))
if (!replaceBeforeCaret(editor, trigger.tokenLength, fragment)) {
rebuildAround(`@${scope}${path}`)
}
return finish(true)
}
// Picking a bare arg-taking command (e.g. `/personality`) shouldn't commit
@@ -280,90 +359,78 @@ export function useComposerTrigger({
const slashKind = !expandsToArgs && trigger.kind === '/' ? slashChipKindForItem(item) : null
const keepTriggerOpen = starter || (expandsToArgs && argumentMode !== 'text')
const finish = () => {
draftRef.current = composerPlainText(editor)
setComposerText(draftRef.current)
requestMainFocus()
keepTriggerOpen ? window.setTimeout(refreshTrigger, 0) : closeTrigger()
}
const sel = window.getSelection()
const range = sel?.rangeCount ? sel.getRangeAt(0) : null
const node = range?.startContainer
const offset = range?.startOffset ?? 0
if (!sel || !range || node?.nodeType !== Node.TEXT_NODE || offset < trigger.tokenLength) {
const current = composerPlainText(editor)
const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength))
if (slashKind) {
// Two-step arg picks (e.g. `/handoff` pill already inserted, now picking
// the platform) land here because the caret sits past a contenteditable
// chip. Rebuild the prefix and re-emit a single pill for the full command.
renderComposerContents(editor, prefix)
editor.append(slashChipElement(serialized, slashKind), document.createTextNode(' '))
placeCaretEnd(editor)
return finish()
}
renderComposerContents(editor, `${prefix}${text}`)
placeCaretEnd(editor)
return finish()
}
const replaceRange = document.createRange()
replaceRange.setStart(node, offset - trigger.tokenLength)
replaceRange.setEnd(node, offset)
replaceRange.deleteContents()
const chip = slashKind
? slashChipElement(serialized, slashKind)
: directive
? refChipElement(directive[1], directive[2])
? // Carry the picked row's own label into the chip rather than letting
// it re-derive one from the value. Upstream's DirectiveNode does the
// same (`__label = item.label`), and it's what makes the list and the
// chip agree: you get the string you just read, not a second guess at
// it. Falls back to the shared deriver for callers with no label.
refChipElement(directive[1], directive[2], (item.metadata as { display?: string })?.display || item.label)
: null
if (chip) {
const space = document.createTextNode(' ')
const fragment = document.createDocumentFragment()
fragment.append(chip, space)
replaceRange.insertNode(fragment)
// The trailing space is a convenience for "keep typing after the chip", so
// it's wrong when the caret already has whitespace in front of it — a pick
// made mid-sentence would leave a double space in the prose.
const followedBySpace = /^\s/.test(composerPlainText(editor).slice(caretOffsetInEditor(editor)))
const fragment = document.createDocumentFragment()
const caret = document.createRange()
caret.setStart(space, 1)
caret.collapse(true)
sel.removeAllRanges()
sel.addRange(caret)
chip
? fragment.append(chip, ...(followedBySpace ? [] : [document.createTextNode(' ')]))
: fragment.append(document.createTextNode(followedBySpace ? text.trimEnd() : text))
return finish()
if (!replaceBeforeCaret(editor, trigger.tokenLength, fragment)) {
// The failed in-place attempt never consumed the fragment, so the chip +
// trailing space are re-inserted around the caret here. Moving the
// element (rather than re-serializing) keeps mid-message slash pills
// alive — they have no text hydration, unlike `@` refs and the leading
// command.
rebuildAround(chip ? fragment : text)
}
document.execCommand('insertText', false, text)
finish()
finish(keepTriggerOpen)
}
/** Backspace inside an `@` path drops the last segment (`a/b/` → `a/`)
* instead of one character. Descending is one Tab per level, so climbing
* back out should cost one key too rather than a held delete. Returns
* instead of one character, and once the path is empty it drops the browse
* scope (`@folder:` → `@`) rather than nibbling `:`, `r`, `e`, `d`… back
* through the directive syntax the user never typed. Descending is one Tab
* per level, so climbing back out costs one key per level too. Returns
* false when the caret isn't in a path, so keydown falls through. */
const ascendTriggerPath = () => {
const editor = editorRef.current
if (!editor || trigger?.kind !== '@' || !trigger.query.includes('/')) {
if (!editor || trigger?.kind !== '@') {
return false
}
const scope = trigger.scope ? `${trigger.scope}:` : ''
if (!trigger.value.includes('/') && !scope) {
return false
}
// Trailing slash means we're listing a folder's children: drop that
// folder. Otherwise a partial segment is typed — drop just that.
const trimmed = trigger.query.replace(/\/$/, '')
// folder. Otherwise a partial segment is typed — drop just that. With the
// value already empty, the only thing left to drop is the scope itself.
const trimmed = trigger.value.replace(/\/$/, '')
const parent = trimmed.slice(0, trimmed.lastIndexOf('/') + 1)
const next = trigger.value ? `${scope}${parent}` : ''
const current = composerPlainText(editor)
const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength))
recordUndoPoint?.()
const fragment = document.createDocumentFragment()
fragment.append(document.createTextNode(`@${next}`))
// In place first: the destructive re-render fallback rebuilds the editor
// from text, which is exactly what used to demote a leading command pill
// to plaintext on every Backspace inside a path.
if (!replaceBeforeCaret(editor, trigger.tokenLength, fragment)) {
rebuildAroundCaret(editor, trigger.tokenLength, `@${next}`)
}
renderComposerContents(editor, `${prefix}@${parent}`)
placeCaretEnd(editor)
draftRef.current = composerPlainText(editor)
setComposerText(draftRef.current)
window.setTimeout(refreshTrigger, 0)
@@ -27,6 +27,9 @@ interface UseComposerVoiceArgs {
focusInput: () => void
insertText: (text: string) => void
maxRecordingSeconds: number
/** Interrupt the in-flight agent turn (Stop-button seam) — fired when the
* user speaks over the model while it is still generating. */
onInterrupt?: () => Promise<void> | void
onSubmit: ChatBarProps['onSubmit']
onTranscribeAudio: ChatBarProps['onTranscribeAudio']
sessionId: string | null | undefined
@@ -48,6 +51,7 @@ export function useComposerVoice({
focusInput,
insertText,
maxRecordingSeconds,
onInterrupt,
onSubmit,
onTranscribeAudio,
sessionId,
@@ -129,6 +133,10 @@ export function useComposerVoice({
consumePendingResponse,
enabled: voiceConversationActive,
onFatalError: () => setVoiceConversationActive(false),
// Speaking over the model mid-generation interrupts the in-flight turn —
// the same seam as the Stop button — so the interjection becomes the next
// turn instead of waiting behind a reply the user already rejected.
onInterrupt,
// A spoken stop command ("stop", "never mind", "goodbye", …) ends the
// hands-free conversation. Flipping the flag is the authoritative off
// switch — the enabled=false prop + effect below drive conversation.end()
@@ -0,0 +1,122 @@
import { useCallback } from 'react'
import { type CompletionEntry, type CompletionPayload, useLiveCompletionAdapter } from './use-live-completion-adapter'
/**
* `:shortcode:` completions for the composers, Slack-style (`:joy` → 😂).
*
* Draws from the same bundled emojibase-data the reaction picker uses (served
* at ./emojibase by the `hermes:emojibase-assets` vite plugin — offline, no
* CDN). The index lazy-loads on the first `:` trigger, then every query is
* answered from memory, so `isCached` skips the debounce and loading state
* after that first load.
*
* A pick inserts the emoji CHARACTER as plain text — not a chip. Directive
* chips exist to carry machine-readable references the backend resolves
* (@file:, /skill); a picked emoji is just text, so it rides the formatter's
* `rawText` path and lands inline.
*/
interface EmojiEntry {
emoji: string
/** Primary shortcode, e.g. "joy". */
code: string
/** Every shortcode, tag, and label that should match a search. */
haystack: string[]
}
let indexPromise: Promise<EmojiEntry[]> | null = null
let indexLoaded = false
async function loadIndex(): Promise<EmojiEntry[]> {
const [dataRes, codesRes] = await Promise.all([
fetch('./emojibase/en/data.json'),
fetch('./emojibase/en/shortcodes/emojibase.json')
])
const data: { emoji: string; hexcode: string; label: string; tags?: string[] }[] = await dataRes.json()
const codes: Record<string, string | string[]> = await codesRes.json()
const entries: EmojiEntry[] = []
for (const item of data) {
const raw = codes[item.hexcode]
if (!raw) {
continue
}
const shortcodes = Array.isArray(raw) ? raw : [raw]
entries.push({
emoji: item.emoji,
code: shortcodes[0],
haystack: [...shortcodes, ...(item.tags ?? []), item.label.toLowerCase()]
})
}
indexLoaded = true
return entries
}
/** Prefix matches on shortcodes rank first, then tag/label substring hits. */
async function searchEmoji(query: string, limit = 8): Promise<EmojiEntry[]> {
const index = await (indexPromise ??= loadIndex())
const q = query.toLowerCase()
const prefix: EmojiEntry[] = []
const loose: EmojiEntry[] = []
for (const entry of index) {
if (entry.code.startsWith(q) || entry.haystack.some(h => h.startsWith(q))) {
prefix.push(entry)
} else if (entry.haystack.some(h => h.includes(q))) {
loose.push(entry)
}
if (prefix.length >= limit) {
break
}
}
return [...prefix, ...loose].slice(0, limit)
}
export function useEmojiCompletions() {
const fetcher = useCallback(async (query: string): Promise<CompletionPayload> => {
const entries = await searchEmoji(query)
return {
query,
items: entries.map(entry => ({
text: entry.emoji,
display: `${entry.emoji} :${entry.code}:`,
meta: ''
}))
}
}, [])
const toItem = useCallback(
(entry: CompletionEntry, index: number) => ({
id: `${entry.text}|${index}`,
type: 'emoji',
label: typeof entry.display === 'string' ? entry.display : entry.text,
metadata: {
display: typeof entry.display === 'string' ? entry.display : entry.text,
// The formatter's serialize() returns rawText verbatim → the emoji
// character lands as plain inline text, no chip.
rawText: entry.text,
meta: '',
group: '',
action: ''
}
}),
[]
)
return useLiveCompletionAdapter({
enabled: true,
fetcher,
isCached: () => indexLoaded,
toItem
})
}
@@ -49,15 +49,7 @@ function gestureTargetOk(target: EventTarget | null) {
return false
}
// `composer-no-drag`: chrome that lives inside the composer root but isn't
// part of the draggable frame — the floating pill strips. The pills are
// `button`s and already excluded, but the strip's own box (the gaps between
// pills) isn't, so without this a press landing between two badges still
// drags. The strips are `w-fit`, so this costs the grab band only the width
// of the badges themselves.
return !target.closest(
'button, a, input, textarea, select, [role="menuitem"], [data-radix-popper-content-wrapper], [data-slot="composer-no-drag"]'
)
return !target.closest('button, a, input, textarea, select, [role="menuitem"], [data-radix-popper-content-wrapper]')
}
/** Floating composer's 5px outer frame — grab here to drag without long-press. */
@@ -0,0 +1,266 @@
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { BargeMonitorCallbacks } from '@/lib/voice-barge-in'
import type { MicRecording } from './use-mic-recorder'
import { useVoiceConversation } from './use-voice-conversation'
// The full-duplex contract: the barge monitor is live across the WHOLE agent
// turn — generation (thinking) and playback (speaking) — so speaking over the
// model interrupts it mid-generation instead of the mic being deaf until TTS
// starts (the Windows report: interruption "never works" because the deaf
// window covered generation, and playback bleed made the old monitor's
// trigger unreachable).
const monitorCalls: BargeMonitorCallbacks[] = []
const stopMonitor = vi.fn()
vi.mock('@/lib/voice-barge-in', () => ({
monitorSpeechDuringPlayback: (callbacks: BargeMonitorCallbacks) => {
monitorCalls.push(callbacks)
return stopMonitor
}
}))
const markVoicePlaybackInterrupted = vi.fn()
const stopVoicePlayback = vi.fn()
vi.mock('@/lib/voice-playback', () => ({
markVoicePlaybackInterrupted: () => markVoicePlaybackInterrupted(),
playSpeechText: vi.fn(async () => true),
startSpeechStream: vi.fn(async () => null),
stopVoicePlayback: () => stopVoicePlayback()
}))
vi.mock('@/lib/thinking-sound', () => ({
startThinkingSound: vi.fn(),
stopThinkingSound: vi.fn()
}))
const micHandle = {
cancel: vi.fn(),
start: vi.fn(async () => undefined),
stop: vi.fn<() => Promise<MicRecording | null>>(async () => null)
}
vi.mock('./use-mic-recorder', () => ({
useMicRecorder: () => ({ handle: micHandle, level: 0, recording: false })
}))
vi.mock('@/i18n', () => ({
useI18n: () => ({
t: {
notifications: {
voice: {
configureSpeechToText: 'configure STT',
couldNotStartSession: 'could not start',
microphoneFailed: 'mic failed',
playbackFailed: 'playback failed',
transcriptionFailed: 'transcription failed',
unavailable: 'unavailable'
}
}
}
})
}))
vi.mock('@/store/notifications', () => ({
notify: vi.fn(),
notifyError: vi.fn()
}))
interface HookProps {
busy: boolean
}
function renderConversation(overrides: { onInterrupt?: () => void; transcript?: string } = {}) {
const onInterrupt = overrides.onInterrupt ?? vi.fn()
// Mirrors the real app: submitting a turn makes the agent busy.
const onBusyChange: { current: (busy: boolean) => void } = { current: () => undefined }
const onSubmit = vi.fn(async () => {
onBusyChange.current(true)
})
const onStopWord = vi.fn()
// First transcription is the turn that starts the conversation; subsequent
// ones are barge captures (the overridable transcript).
let transcriptions = 0
const onTranscribeAudio = vi.fn(async () =>
transcriptions++ === 0 ? 'kick off the task' : (overrides.transcript ?? 'and another thing')
)
const hook = renderHook(
({ busy }: HookProps) =>
useVoiceConversation({
busy,
consumePendingResponse: vi.fn(),
enabled: true,
onInterrupt,
onStopWord,
onSubmit,
onTranscribeAudio,
pendingResponse: () => null
}),
{ initialProps: { busy: false } }
)
onBusyChange.current = busy => hook.rerender({ busy })
return { hook, onInterrupt, onStopWord, onSubmit, onTranscribeAudio }
}
/** Drive the hook into the generation phase (turn submitted, model working). */
async function enterThinking(hook: ReturnType<typeof renderConversation>['hook']) {
await act(async () => {
await hook.result.current.start()
})
await waitFor(() => expect(hook.result.current.status).toBe('listening'))
micHandle.stop.mockResolvedValueOnce({
audio: new Blob(['q'], { type: 'audio/webm' }),
durationMs: 900,
heardSpeech: true
})
await act(async () => {
hook.result.current.stopTurn()
})
await waitFor(() => expect(hook.result.current.status).toBe('thinking'))
}
describe('useVoiceConversation full-duplex barge-in', () => {
beforeEach(() => {
monitorCalls.length = 0
vi.clearAllMocks()
micHandle.start.mockResolvedValue(undefined)
micHandle.stop.mockResolvedValue(null)
})
afterEach(cleanup)
it('arms the barge monitor during generation (before any reply audio exists)', async () => {
const { hook } = renderConversation()
await act(async () => {
await hook.result.current.start()
})
await enterThinking(hook)
await waitFor(() => expect(hook.result.current.status).toBe('thinking'))
// busy=true + thinking → the full-duplex monitor must be live.
await waitFor(() => expect(monitorCalls.length).toBeGreaterThan(0))
})
it('interrupts the in-flight turn when speech trips mid-generation', async () => {
const { hook, onInterrupt } = renderConversation()
await act(async () => {
await hook.result.current.start()
})
await enterThinking(hook)
await waitFor(() => expect(monitorCalls.length).toBeGreaterThan(0))
act(() => {
monitorCalls.at(-1)?.onSpeech()
})
expect(onInterrupt).toHaveBeenCalledTimes(1)
expect(markVoicePlaybackInterrupted).toHaveBeenCalled()
expect(stopVoicePlayback).toHaveBeenCalled()
})
it('submits the captured interruption once the interrupt settles (busy clears)', async () => {
const { hook, onSubmit } = renderConversation({ transcript: 'no, do it differently' })
await act(async () => {
await hook.result.current.start()
})
await enterThinking(hook)
await waitFor(() => expect(monitorCalls.length).toBeGreaterThan(0))
const monitor = monitorCalls.at(-1)
act(() => {
monitor?.onSpeech()
})
// Interrupt lands → the turn ends → busy flips false.
hook.rerender({ busy: false })
await act(async () => {
monitor?.onUtterance?.(new Blob(['x'], { type: 'audio/webm' }))
})
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('no, do it differently'))
})
it('does not interrupt when speech trips during playback (turn already done)', async () => {
const { hook, onInterrupt } = renderConversation()
await act(async () => {
await hook.result.current.start()
})
await enterThinking(hook)
await waitFor(() => expect(monitorCalls.length).toBeGreaterThan(0))
// Turn finished; playback phase.
hook.rerender({ busy: false })
act(() => {
monitorCalls.at(-1)?.onSpeech()
})
expect(onInterrupt).not.toHaveBeenCalled()
expect(stopVoicePlayback).toHaveBeenCalled()
})
it('a spoken stop command in the barge capture ends the conversation instead of submitting', async () => {
const { hook, onStopWord, onSubmit } = renderConversation({ transcript: 'stop' })
await act(async () => {
await hook.result.current.start()
})
await enterThinking(hook)
await waitFor(() => expect(monitorCalls.length).toBeGreaterThan(0))
const monitor = monitorCalls.at(-1)
act(() => {
monitor?.onSpeech()
})
hook.rerender({ busy: false })
await act(async () => {
monitor?.onUtterance?.(new Blob(['s'], { type: 'audio/webm' }))
})
await waitFor(() => expect(onStopWord).toHaveBeenCalledTimes(1))
// Only the kickoff turn was submitted — the "stop" capture never was.
expect(onSubmit).toHaveBeenCalledTimes(1)
expect(onSubmit).not.toHaveBeenCalledWith('stop')
})
it('re-arms a single monitor per turn (idempotent ensure)', async () => {
const { hook } = renderConversation()
await act(async () => {
await hook.result.current.start()
})
await enterThinking(hook)
await waitFor(() => expect(monitorCalls.length).toBeGreaterThan(0))
const armed = monitorCalls.length
// Effect re-runs (busy toggles, status changes) must not open more mics.
hook.rerender({ busy: true })
hook.rerender({ busy: true })
expect(monitorCalls.length).toBe(armed)
})
})
@@ -28,6 +28,9 @@ interface VoiceConversationOptions {
busy: boolean
enabled: boolean
onFatalError?: () => void
/** Interrupt the in-flight agent turn (the same seam as the Stop button).
* Fired when the user speaks while the model is still generating. */
onInterrupt?: () => Promise<void> | void
onStopWord?: () => void
onSubmit: (text: string) => Promise<void> | void
onTranscribeAudio?: (audio: Blob) => Promise<string>
@@ -38,10 +41,15 @@ interface VoiceConversationOptions {
beforeMicOpen?: () => Promise<void> | void
}
/** How long a barge-triggered interrupt may take to settle before we submit
* the captured utterance anyway. */
const INTERRUPT_SETTLE_TIMEOUT_MS = 5_000
export function useVoiceConversation({
busy,
enabled,
onFatalError,
onInterrupt,
onStopWord,
onSubmit,
onTranscribeAudio,
@@ -63,6 +71,7 @@ export function useVoiceConversation({
const speechSessionRef = useRef<null | SpeechStreamSession>(null)
const stopBargeMonitorRef = useRef<(() => void) | null>(null)
const bargeCapturePendingRef = useRef(false)
const bargedRef = useRef(false)
const speechStartSequenceRef = useRef(0)
const enabledRef = useRef(enabled)
const mutedRef = useRef(muted)
@@ -70,6 +79,12 @@ export function useVoiceConversation({
const statusRef = useRef<ConversationStatus>('idle')
const wasEnabledRef = useRef(enabled)
const onStopWordRef = useRef(onStopWord)
const onInterruptRef = useRef(onInterrupt)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
onInterruptRef.current = onInterrupt
}, [onInterrupt])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
@@ -114,6 +129,7 @@ export function useVoiceConversation({
stopBargeMonitorRef.current?.()
stopBargeMonitorRef.current = null
bargeCapturePendingRef.current = false
bargedRef.current = false
speechSessionRef.current = null
responseIdRef.current = null
spokenSourceLengthRef.current = 0
@@ -315,6 +331,25 @@ export function useVoiceConversation({
return
}
// A spoken stop command while barging means "stop everything" — the
// turn/playback was already cut at trip time; now end the conversation
// instead of submitting "stop" as a new prompt.
if (isVoiceStopCommand(transcript)) {
dropSpeechSession()
setStatus('idle')
onStopWordRef.current?.()
return
}
// A generation-phase barge interrupted the in-flight turn; the submit
// path refuses while `busy`, so wait for the interrupt to settle.
const deadline = Date.now() + INTERRUPT_SETTLE_TIMEOUT_MS
while (busyRef.current && Date.now() < deadline) {
await new Promise(resolve => window.setTimeout(resolve, 100))
}
awaitingSpokenResponseRef.current = true
dropSpeechSession()
consumePendingResponse()
@@ -328,24 +363,46 @@ export function useVoiceConversation({
[consumePendingResponse, onSubmit, onTranscribeAudio, voiceCopy.transcriptionFailed]
)
/** Barge-in monitor wiring shared by the live and fallback speech paths. */
const openBargeMonitor = useCallback(
(onBarge: () => void) =>
monitorSpeechDuringPlayback({
onSpeech: () => {
bargeCapturePendingRef.current = true
onBarge()
markVoicePlaybackInterrupted()
stopVoicePlayback()
},
onUtterance: audio => {
bargeCapturePendingRef.current = false
stopBargeMonitorRef.current = null
void submitCapturedUtterance(audio)
/**
* Full-duplex barge-in monitor for the WHOLE agent turn: armed at submit,
* live through generation (thinking) AND playback (speaking).
*
* - generation phase (`busy`): speech interrupts the in-flight turn via
* `onInterrupt` — the same seam as the Stop button — and cuts any TTS that
* managed to start, so the stale reply never speaks.
* - playback phase: speech cuts playback and the captured interruption is
* transcribed and submitted as the next turn.
*
* Idempotent — one monitor owns the mic per turn; re-arming while one is
* live is a no-op (the live/fallback speech paths and the turn-drive effect
* all call this).
*/
const ensureBargeMonitor = useCallback(() => {
if (stopBargeMonitorRef.current) {
return
}
stopBargeMonitorRef.current = monitorSpeechDuringPlayback({
isPlaying: () => $voicePlayback.get().status === 'speaking',
onSpeech: () => {
bargeCapturePendingRef.current = true
bargedRef.current = true
markVoicePlaybackInterrupted()
stopVoicePlayback()
if (busyRef.current) {
// Mid-generation: stop the in-flight turn so the captured utterance
// becomes the next one instead of queueing behind a stale reply.
void onInterruptRef.current?.()
}
}),
[submitCapturedUtterance]
)
},
onUtterance: audio => {
bargeCapturePendingRef.current = false
stopBargeMonitorRef.current = null
void submitCapturedUtterance(audio)
}
})
}, [submitCapturedUtterance])
/** Push any new reply text into the live session; finish when complete. */
const feedSpeechSession = useCallback(
@@ -397,12 +454,9 @@ export function useVoiceConversation({
return
}
let barged = false
stopBargeMonitorRef.current?.()
stopBargeMonitorRef.current = openBargeMonitor(() => {
barged = true
})
// The full-duplex monitor is normally already live (armed at submit);
// this is a safety net for read-aloud-style entries into the loop.
ensureBargeMonitor()
speechStartSequenceRef.current = $voicePlayback.get().sequence
@@ -411,14 +465,14 @@ export function useVoiceConversation({
.finally(() => {
if (responseIdRef.current === responseId) {
awaitingSpokenResponseRef.current = false
settleAfterSpeech(barged)
settleAfterSpeech(bargedRef.current)
}
})
}
poll()
},
[openBargeMonitor, pendingResponse, settleAfterSpeech, voiceCopy.playbackFailed]
[ensureBargeMonitor, pendingResponse, settleAfterSpeech, voiceCopy.playbackFailed]
)
/**
@@ -433,15 +487,11 @@ export function useVoiceConversation({
speechStartSequenceRef.current = $voicePlayback.get().sequence
setStatus('speaking')
let barged = false
// VAD barge-in: the user talking over the reply cuts playback, drops
// the not-yet-spoken remainder, AND keeps capturing — the interruption
// is transcribed from its first syllable instead of losing the opening
// words to a mic re-open.
stopBargeMonitorRef.current = openBargeMonitor(() => {
barged = true
})
// words to a mic re-open. Usually already live (armed at submit).
ensureBargeMonitor()
void (async () => {
const session = await startSpeechStream({ source: 'voice-conversation' })
@@ -484,10 +534,10 @@ export function useVoiceConversation({
}
awaitingSpokenResponseRef.current = false
settleAfterSpeech(barged)
settleAfterSpeech(bargedRef.current)
})()
},
[awaitFallbackSpeech, feedSpeechSession, openBargeMonitor, settleAfterSpeech]
[awaitFallbackSpeech, ensureBargeMonitor, feedSpeechSession, settleAfterSpeech]
)
const start = useCallback(async () => {
@@ -601,6 +651,13 @@ export function useVoiceConversation({
}
if (awaitingSpokenResponseRef.current && status !== 'speaking') {
// Generation phase: the turn is in flight but no reply audio exists
// yet. Keep the mic live so speech can interrupt the model mid-
// generation (full-duplex) instead of going deaf until playback.
if (status === 'thinking' && (busy || bargeCapturePendingRef.current)) {
ensureBargeMonitor()
}
const response = pendingResponse()
if (response) {
@@ -609,8 +666,9 @@ export function useVoiceConversation({
return
}
if (!busy && status === 'thinking') {
// Turn finished without any speakable reply (tool-only, error).
if (!busy && status === 'thinking' && !bargeCapturePendingRef.current) {
// Turn finished without any speakable reply (tool-only, error). A
// live barge capture owns the loop instead — it submits or resumes.
awaitingSpokenResponseRef.current = false
dropSpeechSession()
pendingStartRef.current = true
@@ -627,7 +685,7 @@ export function useVoiceConversation({
if (pendingStartRef.current) {
void startListening()
}
}, [busy, enabled, muted, openLiveSpeech, pendingResponse, startListening, status])
}, [busy, enabled, muted, ensureBargeMonitor, openLiveSpeech, pendingResponse, startListening, status])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
+202 -153
View File
@@ -49,12 +49,15 @@ import { useComposerTrigger } from './hooks/use-composer-trigger'
import { useComposerUndo } from './hooks/use-composer-undo'
import { useComposerUrlDialog } from './hooks/use-composer-url-dialog'
import { useComposerVoice } from './hooks/use-composer-voice'
import { useEmojiCompletions } from './hooks/use-emoji-completions'
import { useComposerMicroActions } from './hooks/use-micro-actions'
import { useSlashCompletions } from './hooks/use-slash-completions'
import { useSessionStatusPresence } from './hooks/use-status-presence'
import { ActionBadges } from './micro-actions'
import { chipTypedPathOnSpace, pathifyRefs } from './path-refs'
import { QueuePanel } from './queue-panel'
import {
COMPOSER_PLACEHOLDER_CLASS,
composerPlainText,
deleteChipBeforeCaret,
deleteSelectionInEditor,
@@ -65,7 +68,7 @@ import {
import { useComposerScope } from './scope'
import { ComposerStatusStack } from './status-stack'
import { CodingStatusRow } from './status-stack/coding-row'
import { extractClipboardImageBlobs } from './text-utils'
import { extractClipboardImageBlobs, openDirectiveScope } from './text-utils'
import { ComposerTriggerPopover } from './trigger-popover'
import type { ChatBarProps } from './types'
import { isRedoShortcut, isUndoShortcut } from './undo-history'
@@ -161,6 +164,9 @@ export function ChatBar({
useComposerMicroActions(statusSessionId, busy)
const composerRef = useRef<HTMLFormElement | null>(null)
// The dock wraps the strips + status stack + composer; the thread's bottom
// clearance measures this, while the pop-out drag still tracks the composer.
const composerDockRef = useRef<HTMLDivElement | null>(null)
const composerSurfaceRef = useRef<HTMLDivElement | null>(null)
// Pop-out engine: docked↔floating state, dock/float/toggle, drag gestures, and
@@ -184,6 +190,7 @@ export function ChatBar({
const { availableThemes, themeName } = useTheme()
const at = useAtCompletions({ gateway: gateway ?? null, sessionId: sessionId ?? null, cwd: cwd ?? null })
const slash = useSlashCompletions({ activeSkin: themeName, gateway: gateway ?? null, skinThemes: availableThemes })
const emoji = useEmojiCompletions()
const { t } = useI18n()
const gatewayState = useStore($gatewayState)
@@ -277,7 +284,14 @@ export function ChatBar({
return onCancel()
}, [activeQueueSessionKeyRef, onCancel])
const { compactPill, stacked } = useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut })
const { compactPill, stacked } = useComposerMetrics({
composerDockRef,
composerRef,
composerSurfaceRef,
editorRef,
poppedOut
})
const hasComposerPayload = hasText || attachments.length > 0
const canSubmit = busy || hasComposerPayload
@@ -346,7 +360,7 @@ export function ChatBar({
triggerItems,
triggerKeyConsumedRef,
triggerLoading
} = useComposerTrigger({ at, draftRef, editorRef, requestMainFocus, setComposerText, slash })
} = useComposerTrigger({ at, draftRef, editorRef, emoji, recordUndoPoint, requestMainFocus, setComposerText, slash })
// Pull the live contentEditable text into draftRef + the AUI composer state
// (which drives `hasComposerPayload` → the send button). Shared by the input
@@ -478,8 +492,13 @@ 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. Bare `@path` tokens promote the same way.
// A paste into an open `@url:`/`@file:` scope CONSUMES that scope instead of
// stacking on it — the scope is the browse mode the user is pasting into,
// not text they typed and want to keep (`@url:@url:\`https://…\``).
const scope = openDirectiveScope(event.currentTarget)
recordUndoPoint()
insertComposerContentsAtCaret(event.currentTarget, pathifyRefs(linkifyUrls(pastedText)))
insertComposerContentsAtCaret(event.currentTarget, pathifyRefs(linkifyUrls(pastedText)), scope)
scheduleFlushEditorToDraft(event.currentTarget)
}
@@ -569,6 +588,17 @@ export function ChatBar({
return
}
// The popover is open but its items are still in flight (debounce + RPC).
// Tab must not fall through to the browser — it would move focus out of
// the composer mid-completion, which reads as the popover "eating" the
// keypress. Swallow it; the refresh lands with the items.
if (trigger && triggerLoading && triggerItems.length === 0 && event.key === 'Tab') {
event.preventDefault()
triggerKeyConsumedRef.current = true
return
}
if (trigger && triggerItems.length > 0) {
if (event.key === 'ArrowDown') {
event.preventDefault()
@@ -856,6 +886,8 @@ export function ChatBar({
focusInput,
insertText,
maxRecordingSeconds,
// Voice barge-in mid-generation halts the run like the Stop button.
onInterrupt: haltRun,
onSubmit,
onTranscribeAudio,
sessionId,
@@ -915,7 +947,7 @@ export function ChatBar({
autoCorrect="off"
className={cn(
'min-h-[1.625rem] min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) cursor-text overflow-y-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere] bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed',
'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60',
COMPOSER_PLACEHOLDER_CLASS,
'**:data-ref-text:cursor-default',
stacked && 'pl-3',
stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1'
@@ -1005,36 +1037,27 @@ export function ChatBar({
/>
)}
<ComposerPrimitive.Unstable_TriggerPopoverRoot>
<ComposerPrimitive.Root
{/* Dock column: owns the composer's POSITION and stacks, bottom-up,
[micro actions] · [status stack] · [composer] · [underside].
Anchored at the bottom, so in-flow children grow upward and still
overlay the thread — no absolute lane needed.
The strips are siblings of the composer, not children: the pop-out
drag region is `absolute inset-0` INSIDE the composer, so anything
rendered in there is inside the grab area by construction. Keeping
them out here is what makes that impossible rather than excluded. */}
<div
className={cn(
'group/composer z-30 overflow-visible rounded-2xl',
poppedOut
? // Floating: the composer (with its own border) floats with an even
// 5px transparent grab margin around it — drag that to move it.
'fixed w-[var(--composer-popout-width)] max-w-[calc(100vw-1.5rem)] bg-transparent p-[5px]'
: 'absolute bottom-0 left-1/2 w-[min(var(--composer-width),calc(100%-2rem))] max-w-full -translate-x-1/2 pt-2 pb-[var(--composer-shell-pad-block-end)]',
dragging && 'cursor-grabbing select-none touch-none'
'z-30 flex flex-col',
poppedOut ? 'fixed max-w-[calc(100vw-1.5rem)]' : 'absolute bottom-0 left-1/2 max-w-full -translate-x-1/2'
)}
data-drag-active={dragActive ? '' : undefined}
data-popped-out={poppedOut ? '' : undefined}
data-slot="composer-root"
data-status-stack={statusStackVisible ? '' : undefined}
data-slot="composer-dock"
data-thread-scrolled-up={scrolledUp ? '' : undefined}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onPointerDown={popoutAllowed ? onComposerGesturePointerDown : undefined}
onSubmit={e => {
e.preventDefault()
if (composingRef.current) {
return
}
submitDraft()
}}
ref={composerRef}
// Measured for the thread's bottom clearance: the dock is the box
// that contains the strips, the status stack, AND the composer, so
// one measurement covers everything the thread must clear.
ref={composerDockRef}
style={
poppedOut
? {
@@ -1046,22 +1069,16 @@ export function ChatBar({
: undefined
}
>
{isHelpHint && <HelpHint />}
{trigger && !argStageEmpty && (
<ComposerTriggerPopover
activeIndex={triggerActive}
items={triggerItems}
kind={trigger.kind}
loading={triggerLoading}
onHover={setTriggerActive}
onPick={replaceTriggerWithChip}
/>
)}
{/* Aligned to the composer SURFACE, which sits inside the composer's
5px transparent grab margin — so both strips carry the same inset
and share one left edge with it. */}
<div className={cn(composerFloatingStrip, 'px-[5px] pb-1.5 empty:hidden')}>
<ActionBadges sessionId={statusSessionId} />
</div>
{/* Session-scoped status stack (todos, subagents, background tasks,
queue). Out of flow so it never inflates the composer's measured
height; it overlays the chat instead of pushing it, and publishes
its own --status-stack-measured-height so the thread's clearance
accounts for it. Collapses to nothing when every status is empty. */}
queue). An in-flow dock child: the dock is bottom-anchored, so it
grows upward over the thread and the dock's own measurement covers
it. Collapses to nothing when every status is empty. */}
<ComposerStatusStack
queue={
activeQueueSessionKey && queuedPrompts.length > 0 ? (
@@ -1091,134 +1108,166 @@ export function ChatBar({
}
sessionId={statusSessionId}
/>
{!poppedOut && (
<div
className="pointer-events-none absolute inset-0 rounded-[inherit]"
style={{ background: COMPOSER_FADE_BACKGROUND }}
/>
)}
{/* Drag region: covers the transparent grab margin around the surface.
<ComposerPrimitive.Root
className={cn(
'group/composer relative w-full overflow-visible rounded-2xl',
poppedOut && 'bg-transparent',
dragging && 'cursor-grabbing select-none touch-none'
)}
data-drag-active={dragActive ? '' : undefined}
data-popped-out={poppedOut ? '' : undefined}
data-slot="composer-root"
data-status-stack={statusStackVisible ? '' : undefined}
data-thread-scrolled-up={scrolledUp ? '' : undefined}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onPointerDown={popoutAllowed ? onComposerGesturePointerDown : undefined}
onSubmit={e => {
e.preventDefault()
if (composingRef.current) {
return
}
submitDraft()
}}
ref={composerRef}
>
{isHelpHint && <HelpHint />}
{trigger && !argStageEmpty && (
<ComposerTriggerPopover
activeIndex={triggerActive}
items={triggerItems}
kind={trigger.kind}
loading={triggerLoading}
onHover={setTriggerActive}
onPick={replaceTriggerWithChip}
scope={trigger.scope}
/>
)}
{!poppedOut && (
<div
className="pointer-events-none absolute inset-0 rounded-[inherit]"
style={{ background: COMPOSER_FADE_BACKGROUND }}
/>
)}
{/* Drag region: covers the transparent grab margin around the surface.
The surface sits on top (z-4) so only the exposed ring receives this
element's hover/cursor — grab cursor + a diagonal hatch (/////)
appear when you hover the draggable margin, never over the input.
The hatch pattern + opacity ladder live in styles.css. */}
{popoutAllowed && (
<div
aria-hidden
className={cn('pointer-events-auto absolute inset-0', dragging ? 'cursor-grabbing' : 'cursor-grab')}
data-dragging={dragging ? '' : undefined}
data-slot="composer-drag-region"
onDoubleClick={event => {
// The pill strips paint above this region; a double-click that
// lands on one must not float the composer. onPointerDown goes
// through gestureTargetOk, but this handler doesn't.
if (!(event.target as Element).closest('[data-slot="composer-no-drag"]')) {
handleComposerToggle()
}
}}
/>
)}
<div className="relative w-full rounded-[inherit]">
<div
className={cn(
'group/composer-surface relative z-4 isolate grid grid-rows-[auto_1fr] overflow-hidden rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))]',
COMPOSER_DROP_FADE_CLASS,
dragActive && COMPOSER_DROP_ACTIVE_CLASS
)}
data-slot="composer-surface"
ref={composerSurfaceRef}
>
{popoutAllowed && (
<div
aria-hidden
className={cn(
'pointer-events-none absolute inset-0 -z-10 rounded-[inherit]',
composerFill,
composerSurfaceGlass
)}
/>
<CodingStatusRow
onBranchOff={handleBranchOff}
onConvertBranch={handleConvertBranch}
onListBranches={handleListBranches}
onOpen={toggleReview}
onOpenWorktree={openInWorktree}
onSwitchBranch={handleSwitchBranch}
repoPath={cwd}
className={cn('pointer-events-auto absolute inset-0', dragging ? 'cursor-grabbing' : 'cursor-grab')}
data-dragging={dragging ? '' : undefined}
data-slot="composer-drag-region"
onDoubleClick={handleComposerToggle}
/>
)}
<div className="relative w-full rounded-[inherit]">
<div
className={cn(
'relative z-1 flex min-h-0 w-full flex-col gap-(--composer-row-gap) overflow-hidden rounded-[inherit] px-(--composer-surface-pad-x) py-(--composer-surface-pad-y) transition-opacity duration-200 ease-out',
scrolledUp
? 'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer-surface:opacity-100'
: 'opacity-100'
'group/composer-surface relative z-4 isolate grid grid-rows-[auto_1fr] overflow-hidden rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))]',
COMPOSER_DROP_FADE_CLASS,
dragActive && COMPOSER_DROP_ACTIVE_CLASS
)}
data-slot="composer-fade"
data-slot="composer-surface"
ref={composerSurfaceRef}
>
{/* Contribution seams: banners above, a row below, inline
additions beside the "+" menu and before the controls.
All four render nothing until something contributes. */}
<ContribSlot area={COMPOSER_AREAS.top} />
<VoiceActivity state={voiceActivityState} />
<VoicePlaybackActivity />
{queueEdit && editingQueuedPrompt && (
<div className="flex items-center justify-between gap-2 rounded-lg border border-[color-mix(in_srgb,var(--dt-composer-ring)_32%,transparent)] bg-accent/18 px-2 py-1">
<div className="min-w-0 text-[0.7rem] text-muted-foreground/88">
{t.composer.editingQueuedInComposer}
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
className="h-6 rounded-md px-2 text-[0.68rem]"
onClick={() => exitQueuedEdit('cancel')}
type="button"
variant="ghost"
>
{t.common.cancel}
</Button>
<Button
className="h-6 rounded-md px-2 text-[0.68rem]"
onClick={() => exitQueuedEdit('save')}
type="button"
>
{t.common.save}
</Button>
</div>
</div>
)}
{attachments.length > 0 && <AttachmentList attachments={attachments} onRemove={onRemoveAttachment} />}
<div
aria-hidden
className={cn(
'pointer-events-none absolute inset-0 -z-10 rounded-[inherit]',
composerFill,
composerSurfaceGlass
)}
/>
<CodingStatusRow
onBranchOff={handleBranchOff}
onConvertBranch={handleConvertBranch}
onListBranches={handleListBranches}
// A tile's rail reviews ITS worktree: pin the pane's scope to
// this surface's cwd. Main keeps the classic follow-the-
// active-session scope (null).
onOpen={() => toggleReview(scope.target === 'main' ? null : (cwd ?? null))}
onOpenWorktree={openInWorktree}
onSwitchBranch={handleSwitchBranch}
repoPath={cwd}
/>
<div
className={cn(
'grid w-full',
stacked
? 'grid-cols-[auto_1fr] gap-(--composer-row-gap) [grid-template-areas:"input_input"_"menu_controls"]'
: 'grid-cols-[auto_1fr_auto] items-center gap-(--composer-control-gap) [grid-template-areas:"menu_input_controls"]'
'relative z-1 flex min-h-0 w-full flex-col gap-(--composer-row-gap) overflow-hidden rounded-[inherit] px-(--composer-surface-pad-x) py-(--composer-surface-pad-y) transition-opacity duration-200 ease-out',
scrolledUp
? 'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer-surface:opacity-100'
: 'opacity-100'
)}
data-slot="composer-fade"
>
<div className="flex translate-y-[3px] items-start gap-(--composer-control-gap) self-start [grid-area:menu]">
{contextMenu}
<ContribSlot area={COMPOSER_AREAS.leading} />
</div>
<div className="min-w-0 [grid-area:input]">{input}</div>
<div className="flex items-center justify-end gap-(--composer-control-gap) [grid-area:controls]">
<ContribSlot area={COMPOSER_AREAS.actions} />
{controls}
{/* Contribution seams: banners above, a row below, inline
additions beside the "+" menu and before the controls.
All four render nothing until something contributes. */}
<ContribSlot area={COMPOSER_AREAS.top} />
<VoiceActivity state={voiceActivityState} />
<VoicePlaybackActivity />
{queueEdit && editingQueuedPrompt && (
<div className="flex items-center justify-between gap-2 rounded-lg border border-[color-mix(in_srgb,var(--dt-composer-ring)_32%,transparent)] bg-accent/18 px-2 py-1">
<div className="min-w-0 text-[0.7rem] text-muted-foreground/88">
{t.composer.editingQueuedInComposer}
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
className="h-6 rounded-md px-2 text-[0.68rem]"
onClick={() => exitQueuedEdit('cancel')}
type="button"
variant="ghost"
>
{t.common.cancel}
</Button>
<Button
className="h-6 rounded-md px-2 text-[0.68rem]"
onClick={() => exitQueuedEdit('save')}
type="button"
>
{t.common.save}
</Button>
</div>
</div>
)}
{attachments.length > 0 && <AttachmentList attachments={attachments} onRemove={onRemoveAttachment} />}
<div
className={cn(
'grid w-full',
stacked
? 'grid-cols-[auto_1fr] gap-(--composer-row-gap) [grid-template-areas:"input_input"_"menu_controls"]'
: 'grid-cols-[auto_1fr_auto] items-center gap-(--composer-control-gap) [grid-template-areas:"menu_input_controls"]'
)}
>
<div className="flex translate-y-[3px] items-start gap-(--composer-control-gap) self-start [grid-area:menu]">
{contextMenu}
<ContribSlot area={COMPOSER_AREAS.leading} />
</div>
<div className="min-w-0 [grid-area:input]">{input}</div>
<div className="flex items-center justify-end gap-(--composer-control-gap) [grid-area:controls]">
<ContribSlot area={COMPOSER_AREAS.actions} />
{controls}
</div>
</div>
<ContribSlot area={COMPOSER_AREAS.bottom} />
</div>
<ContribSlot area={COMPOSER_AREAS.bottom} />
</div>
</div>
</div>
{/* Underside: a floating strip BELOW the whole composer surface.
Chrome-free by design — contributions bring their own pill/skin,
like the micro-action strip above. In flow (the root is
bottom-anchored, so this grows the composer upward and stays on
screen) but OUTSIDE the surface, so it escapes the surface's
clipping, border, and scroll fade. Shares the micro-action
strip's grid so the two bracket the composer on one vertical
line. Renders nothing until something contributes. */}
<div className={cn(composerFloatingStrip, 'pt-1.5 empty:hidden')} data-slot="composer-no-drag">
</ComposerPrimitive.Root>
{/* Underside: chrome-free strip BELOW the composer. Outside the root
for the same reason as the micro actions — it must not fall inside
the pop-out drag region. Same px as the strip above, so the two
bracket the composer on one vertical line. */}
<div className={cn(composerFloatingStrip, 'px-[5px] pt-1.5 empty:hidden')}>
<ContribSlot area={COMPOSER_AREAS.underside} />
</div>
</ComposerPrimitive.Root>
</div>
</ComposerPrimitive.Unstable_TriggerPopoverRoot>
<UrlDialog
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import { refAttrs, refAttrsHtml } from '@/components/assistant-ui/directive-text'
import { REFERENCE_STYLES, referenceKind, referenceStyle } from '@/components/assistant-ui/reference-kinds'
/**
* There is ONE inline-reference system: `class="ref"` + `data-ref="<kind>"`.
* A pasted link, an `@file:` chip, a `/skill`, a `@session:` the agent wrote —
* all the same markup, styled by the `.ref` rules in styles.css.
*/
describe('the inline reference contract', () => {
it('marks any element as a reference of a given kind', () => {
expect(refAttrs('file')).toEqual({ className: 'ref', 'data-ref': 'file' })
expect(refAttrsHtml('skill')).toBe('class="ref" data-ref="skill"')
})
it('an unkinded reference is a plain link, not a broken one', () => {
// A bare external link has no kind — it keeps the default link colour
// rather than being tagged with a wrong one.
expect(refAttrs()).toEqual({ className: 'ref' })
expect(refAttrsHtml()).toBe('class="ref"')
})
it('normalises an unknown kind instead of emitting it raw', () => {
// A kind CSS has no rule for would silently render unstyled; coercing to
// `other` keeps it inside the system.
expect(refAttrs('wat')['data-ref']).toBe('other')
expect(referenceKind('wat')).toBe('other')
})
it('ships no colour from TypeScript — the theme owns every accent', () => {
// The whole point of keying on `data-ref`: a skin restyles all references
// at once, and no hex or color-mix() is hardcoded in a component.
for (const [kind, style] of Object.entries(REFERENCE_STYLES)) {
expect(style, `${kind} must not carry a colour`).not.toHaveProperty('color')
}
expect(JSON.stringify(refAttrs('url'))).not.toMatch(/color|#[0-9a-f]{3}/i)
})
it('gives every kind a glyph and a label', () => {
for (const [kind, style] of Object.entries(REFERENCE_STYLES)) {
expect(style.codicon, `${kind} codicon`).toBeTruthy()
expect(style.label, `${kind} label`).toBeTruthy()
// Emoji rows render the emoji itself instead of a glyph.
if (kind !== 'emoji') {
expect(style.paths.length, `${kind} paths`).toBeGreaterThan(0)
}
}
})
it('keeps commands and skills visually distinct', () => {
// Different data-ref values, so the stylesheet can accent them apart.
expect(refAttrs('skill')['data-ref']).not.toBe(refAttrs('command')['data-ref'])
expect(referenceStyle('skill').codicon).not.toBe(referenceStyle('command').codicon)
})
})
describe('references are text, not badges', () => {
it('carries no layout, padding, or background of its own', () => {
// Everything visual lives in the stylesheet. If a component starts adding
// its own chrome here, that's the drift this system exists to prevent.
const { className } = refAttrs('file')
expect(className).toBe('ref')
for (const chrome of ['bg-', 'rounded', 'px-', 'py-', 'border', 'inline-flex', 'text-[']) {
expect(className).not.toContain(chrome)
}
})
})
@@ -1,8 +1,9 @@
import { memo, useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { useSessionSlice } from '@/lib/use-session-slice'
import { cn } from '@/lib/utils'
import type { ComposerAction } from '@/store/composer-actions'
import { $composerActionsBySession, type ComposerAction } from '@/store/composer-actions'
import { notifyError } from '@/store/notifications'
/**
@@ -31,19 +32,14 @@ const PILL = cn(
* (`composerFloatingStrip`), this owns only the pills, so the strip above the
* surface and the `composer.underside` strip below it can't drift apart.
*/
export const ActionBadges = memo(function ActionBadges({
actions,
sessionId
}: {
actions: ComposerAction[]
sessionId: string
}) {
export const ActionBadges = memo(function ActionBadges({ sessionId }: { sessionId: null | string }) {
const actions = useSessionSlice($composerActionsBySession, sessionId)
// A pill can kick off async work (a gateway call, a submit). Track which one
// is in flight so it can spin and lock instead of double-firing.
const [runningId, setRunningId] = useState<null | string>(null)
const run = async (action: ComposerAction) => {
if (runningId) {
if (runningId || !sessionId) {
return
}
@@ -1,11 +1,12 @@
import { useStore } from '@nanostores/react'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useSessionView } from '@/app/chat/session-view'
import { ModelMenuCloseContext } from '@/app/shell/model-menu-panel'
import { Button } from '@/components/ui/button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { releaseTypingFocus } from '@/components/ui/keyboard-first'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { ChevronDown } from '@/lib/icons'
@@ -13,6 +14,8 @@ import { formatModelStatusLabel } from '@/lib/model-status-label'
import { cn } from '@/lib/utils'
import { $currentModelSource, $defaultReasoningEffort, setModelPickerOpen } from '@/store/session'
import { onComposerModelMenuRequest } from './focus'
import { useComposerScope } from './scope'
import type { ChatBarState } from './types'
const PILL = cn(
@@ -51,6 +54,28 @@ export function ModelPill({
const defaultEffort = useStore($defaultReasoningEffort)
const runtimeId = useStore(view.$runtimeId)
const [open, setOpen] = useState(false)
const scope = useComposerScope()
const hasLiveMenu = Boolean(model.modelMenuContent)
// The `composer.modelPicker` hotkey, routed to exactly one surface (the pane
// under the pointer, else the active composer — see requestModelMenuToggle).
// Toggles the live dropdown; with no live menu (gateway closed) it opens the
// full picker dialog, same as clicking the pill.
useEffect(
() =>
onComposerModelMenuRequest(target => {
if (target !== scope.target || disabled) {
return
}
if (hasLiveMenu) {
setOpen(prev => !prev)
} else {
setModelPickerOpen(true)
}
}),
[scope.target, disabled, hasLiveMenu]
)
// The composer pick is sticky: a manual selection is pinned and every NEW
// chat uses it instead of the Settings → Model default — silently, which has
@@ -119,8 +144,19 @@ export function ModelPill({
)
}
// Closing the menu ends its claim on the keyboard: Radix restores focus to
// this pill (a toolbar button), so without the release the Enter that
// committed a model also swallows whatever you type next.
const setMenuOpen = (next: boolean) => {
setOpen(next)
if (!next) {
releaseTypingFocus()
}
}
return (
<DropdownMenu onOpenChange={setOpen} open={open}>
<DropdownMenu onOpenChange={setMenuOpen} open={open}>
<Tip label={title} side="top">
<DropdownMenuTrigger asChild>
<Button aria-label={title} className={pillClass} disabled={disabled} type="button" variant="ghost">
@@ -129,7 +165,7 @@ export function ModelPill({
</DropdownMenuTrigger>
</Tip>
<DropdownMenuContent align="end" className="w-64 p-0" side="top" sideOffset={8}>
<ModelMenuCloseContext.Provider value={() => setOpen(false)}>
<ModelMenuCloseContext.Provider value={() => setMenuOpen(false)}>
{model.modelMenuContent}
</ModelMenuCloseContext.Provider>
</DropdownMenuContent>
@@ -35,6 +35,89 @@ describe('renderComposerContents', () => {
expect(editor.textContent).toContain('<b>raw</b>')
expect(composerPlainText(editor)).toBe('@file:`<img src=x onerror=alert(1)>` <b>raw</b>')
})
it('hydrates a committed leading slash command back to its pill', () => {
// Text-hydration parity with @ refs: a re-render from serialized text
// (draft restore, undo, the trigger commit fallback) must not demote a
// committed no-arg command chip to plain text.
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
renderComposerContents(editor, '/some-skill @folder:`Desktop` ')
const pill = editor.querySelector('[data-slash-kind]')
expect(pill?.getAttribute('data-ref-text')).toBe('/some-skill')
expect(editor.querySelector('[data-ref-kind="folder"]')).not.toBeNull()
expect(composerPlainText(editor)).toBe('/some-skill @folder:`Desktop` ')
})
it('keeps a still-typed leading slash token as editable text', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
// No trailing whitespace — not committed yet.
renderComposerContents(editor, '/some-skil')
expect(editor.querySelector('[data-slash-kind]')).toBeNull()
expect(composerPlainText(editor)).toBe('/some-skil')
})
it('keeps an arg-taking command as text — its tail may be uncommitted prose', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
renderComposerContents(editor, '/goal ship the redesign')
expect(editor.querySelector('[data-slash-kind]')).toBeNull()
expect(composerPlainText(editor)).toBe('/goal ship the redesign')
})
})
describe('replaceBeforeCaret across split text nodes', () => {
it('replaces a token that Chromium fragmented into multiple text nodes', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.contentEditable = 'true'
document.body.append(editor)
editor.append(document.createTextNode('see @Desk'), document.createTextNode('top/'))
const caret = document.createRange()
caret.setStart(editor.lastChild!, 4)
caret.collapse(true)
const selection = window.getSelection()!
selection.removeAllRanges()
selection.addRange(caret)
const fragment = document.createDocumentFragment()
fragment.append(refChipElement('folder', '`Desktop`'), document.createTextNode(' '))
// Token `@Desktop/` (9 chars) spans both text nodes.
expect(replaceBeforeCaret(editor, 9, fragment)).toBe(true)
expect(composerPlainText(editor)).toBe('see @folder:`Desktop` ')
editor.remove()
})
it('refuses when a chip interrupts the span — the token is not contiguous text', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.contentEditable = 'true'
document.body.append(editor)
editor.append(document.createTextNode('a'), refChipElement('file', '`x`'), document.createTextNode('bc'))
const caret = document.createRange()
caret.setStart(editor.lastChild!, 2)
caret.collapse(true)
const selection = window.getSelection()!
selection.removeAllRanges()
selection.addRange(caret)
expect(replaceBeforeCaret(editor, 5, document.createDocumentFragment())).toBe(false)
expect(composerPlainText(editor)).toBe('a@file:`x`bc')
editor.remove()
})
})
describe('normalizeComposerEditorDom', () => {
@@ -147,6 +230,82 @@ describe('insertComposerContentsAtCaret', () => {
editor.remove()
})
// A directive typed by hand chips; the same directive pasted has to chip too,
// or copy/pasting a prompt silently drops every command in it.
it('chips a pasted slash command, including one that ends the paste', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
caretIn(editor)
insertComposerContentsAtCaret(editor, '/some-skill')
expect(editor.querySelector('[data-slash-kind]')?.getAttribute('data-ref-text')).toBe('/some-skill')
// Committed pills carry the trailing space the typed path appends, so a
// later full re-render doesn't read the token as half-typed.
expect(composerPlainText(editor)).toBe('/some-skill ')
editor.remove()
})
it('chips a skill named mid-paste alongside a ref', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
caretIn(editor)
insertComposerContentsAtCaret(editor, 'clean @file:`a.ts` with /some-skill then ship')
expect(editor.querySelectorAll('[data-slash-kind]').length).toBe(1)
expect(editor.querySelectorAll('[data-ref-kind="file"]').length).toBe(1)
expect(composerPlainText(editor)).toBe('clean @file:`a.ts` with /some-skill then ship')
editor.remove()
})
it('leaves a pasted path alone — /usr/local is not a command', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
caretIn(editor)
insertComposerContentsAtCaret(editor, 'see /usr/local/bin and /goal ship it')
expect(editor.querySelector('[data-slash-kind]')).toBeNull()
expect(composerPlainText(editor)).toBe('see /usr/local/bin and /goal ship it')
editor.remove()
})
it('does not chip a command pasted against a word — foo/clean is not a command', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.textContent = 'foo'
document.body.append(editor)
caretIn(editor)
insertComposerContentsAtCaret(editor, '/some-skill')
expect(editor.querySelector('[data-slash-kind]')).toBeNull()
expect(composerPlainText(editor)).toBe('foo/some-skill')
editor.remove()
})
it('chips a command pasted right after an existing chip', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.append(refChipElement('file', '`a.ts`'))
document.body.append(editor)
caretIn(editor)
insertComposerContentsAtCaret(editor, '/some-skill')
expect(editor.querySelector('[data-slash-kind]')).not.toBeNull()
editor.remove()
})
})
describe('replaceBeforeCaret', () => {
+253 -45
View File
@@ -7,19 +7,45 @@
* plain-text round-trip.
*/
import {
DIRECTIVE_CHIP_CLASS,
directiveIconElement,
directiveIconSvg,
formatRefValue,
refAttrsHtml,
refChipLabel,
slashChipClass,
type SlashChipKind,
slashIconElement
} from '@/components/assistant-ui/directive-text'
import { referenceKind, referenceRe } from '@/components/assistant-ui/reference-kinds'
import { slashCommandMatches, type SlashCommandScanOptions } from './slash-refs'
export const RICH_INPUT_SLOT = 'composer-rich-input'
export const REF_RE = /@(file|folder|url|image|tool|line|terminal|session):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
/** Paints `data-placeholder` while the editor is empty.
*
* `:empty` can't be the whole test: a cleared editor keeps a scaffolding <br>
* so the contenteditable doesn't collapse, and that break makes `:empty`
* false. Nor can CSS infer it on its own — a text node is invisible to
* selectors, so `one<br>` and a lone `<br>` are the same shape, and
* `:has(> br:only-child)` would paint the placeholder straight over the
* user's text. The code that empties the editor is what knows, so it marks it.
*
* @see markEditorEmptiness */
export const COMPOSER_PLACEHOLDER_CLASS =
'[&:is(:empty,[data-empty])]:before:content-[attr(data-placeholder)] [&:is(:empty,[data-empty])]:before:text-muted-foreground/60'
/** Keep that marker in step with the editor root's contents. */
export function markEditorEmptiness(editor: HTMLElement) {
if (editor.childNodes.length === 0) {
editor.dataset.empty = ''
} else {
delete editor.dataset.empty
}
}
/** @see referenceRe — the shared pattern every surface recognises a reference
* with. Module-level `/g` regexes carry `lastIndex`, so call sites reset it. */
export const REF_RE = referenceRe()
const ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }
@@ -58,42 +84,38 @@ export function refChipHtml(kind: string, rawValue: string, displayLabel?: strin
const label = displayLabel || refChipLabel(kind, id)
return `<span contenteditable="false" title="${escapeHtml(id)}" data-ref-text="${escapeHtml(text)}" data-ref-id="${escapeHtml(id)}" data-ref-kind="${escapeHtml(kind)}" class="${DIRECTIVE_CHIP_CLASS}">${directiveIconSvg(kind)}<span class="truncate">${escapeHtml(label)}</span></span>`
return `<span contenteditable="false" title="${escapeHtml(id)}" data-ref-text="${escapeHtml(text)}" data-ref-id="${escapeHtml(id)}" data-ref-kind="${escapeHtml(kind)}" ${refAttrsHtml(kind)}>${directiveIconSvg(kind)}${escapeHtml(label)}</span>`
}
export function refChipElement(kind: string, rawValue: string, displayLabel?: string) {
const id = unquoteRef(rawValue)
const text = `@${kind}:${quoteRefValue(id)}`
const chip = document.createElement('span')
const label = document.createElement('span')
chip.contentEditable = 'false'
chip.title = id
chip.dataset.refText = text
chip.dataset.refId = id
chip.dataset.refKind = kind
chip.className = DIRECTIVE_CHIP_CLASS
label.className = 'truncate'
label.textContent = displayLabel || refChipLabel(kind, id)
chip.append(directiveIconElement(kind), label)
chip.className = 'ref'
chip.dataset.ref = referenceKind(kind)
chip.append(directiveIconElement(kind), document.createTextNode(displayLabel || refChipLabel(kind, id)))
return chip
}
/** A non-editable pill for a picked slash command (`/skin nous`, `/tropes`).
/** A non-editable reference for a picked slash command (`/skin nous`, `/tropes`).
* `data-ref-text` carries the literal command so `composerPlainText` round-trips
* it back to the exact text that gets submitted. */
export function slashChipElement(command: string, kind: SlashChipKind, label?: string) {
const chip = document.createElement('span')
const text = document.createElement('span')
chip.contentEditable = 'false'
chip.dataset.refText = command
chip.dataset.slashKind = kind
chip.className = slashChipClass(kind)
text.className = 'truncate'
text.textContent = label || command
chip.append(slashIconElement(kind), text)
chip.className = 'ref'
chip.dataset.ref = kind
chip.append(slashIconElement(kind), document.createTextNode(label || command))
return chip
}
@@ -112,24 +134,63 @@ function appendTextWithBreaks(target: DocumentFragment | HTMLElement, text: stri
})
}
export function appendComposerContents(target: DocumentFragment | HTMLElement, text: string) {
let cursor = 0
/** Every span of `text` that renders as a chip, in source order. */
function chipSpans(text: string, options: SlashCommandScanOptions) {
REF_RE.lastIndex = 0
for (const match of text.matchAll(REF_RE)) {
const index = match.index ?? 0
appendTextWithBreaks(target, text.slice(cursor, index))
target.append(refChipElement(match[1] || 'file', match[2] || ''))
cursor = index + match[0].length
const refs = Array.from(text.matchAll(REF_RE)).map(match => {
const start = match.index ?? 0
return { end: start + match[0].length, node: () => refChipElement(match[1] || 'file', match[2] || ''), start }
})
const commands = slashCommandMatches(text, options).map(match => ({
end: match.end,
node: () => slashChipElement(match.command, match.kind),
start: match.start
}))
return [...refs, ...commands].sort((a, b) => a.start - b.start)
}
/** Build the chip/text DOM for `text`. Directives hydrate back to their pills —
* `@kind:value` refs and `/command` invocations both — so text that arrives
* whole (a paste, a restored draft, an undo step, a rebuilt line) carries the
* same chips the typed path would have committed. */
export function appendComposerContents(
target: DocumentFragment | HTMLElement,
text: string,
options: SlashCommandScanOptions = {}
) {
let cursor = 0
for (const span of chipSpans(text, options)) {
// A `@` ref wins an overlap: a command token can't contain an `@`, so the
// only way spans collide is a slash inside a quoted ref value
// (`` @url:`a /clean` ``), which belongs to that value.
if (span.start < cursor) {
continue
}
appendTextWithBreaks(target, text.slice(cursor, span.start))
target.append(span.node())
cursor = span.end
}
appendTextWithBreaks(target, text.slice(cursor))
}
export function renderComposerContents(target: HTMLElement, text: string) {
export function renderComposerContents(target: HTMLElement, text: string, options?: SlashCommandScanOptions) {
target.replaceChildren()
appendComposerContents(target, text)
// Defaults to live editing, where a token ending the text is still being
// typed (`/wor`) and must stay editable. Callers repainting inert text (a
// restored draft, a sent message opened for edit) pass `trailingCommitted`.
appendComposerContents(target, text, options)
// The other writer that reshapes the editor root: painting a restored draft
// in clears the marker, clearing back to '' sets it.
markEditorEmptiness(target)
}
/** Caret range when the selection lives inside `editor`; else null. */
@@ -144,20 +205,95 @@ function composerSelectionRange(editor: HTMLElement) {
return { range, selection }
}
/** Insert text at the caret (replacing any selection), with any `@kind:value`
* directives in it landing as chips. Pastes use this instead of
* `execCommand('insertText')` — Chromium's editing pipeline is ~O(n²) on large
* multiline blobs. */
export function insertComposerContentsAtCaret(editor: HTMLElement, text: string) {
/** Serialized text from the editor's start up to (`container`, `offset`).
*
* Chips are ATOMIC here: each contributes an object-replacement placeholder
* rather than leaking its label text, and a <br> contributes a newline. That
* makes a chip edge read as a token boundary, which is what both trigger
* detection and directive recognition need. */
export function serializeTextBefore(editor: HTMLElement, container: Node, offset: number): string {
const probe = document.createRange()
probe.selectNodeContents(editor)
probe.setEnd(container, offset)
const scratch = document.createElement('div')
scratch.append(probe.cloneContents())
for (const chip of scratch.querySelectorAll('[data-ref-text]')) {
chip.replaceWith('\uFFFC')
}
for (const br of scratch.querySelectorAll('br')) {
br.replaceWith('\n')
}
return scratch.textContent ?? ''
}
/** True when the insertion point starts a token — the editor's start, or after
* whitespace or a chip. `foo` + a pasted `/clean` is `foo/clean`, not a
* command; `foo ` + the same paste is. */
function atTokenBoundary(editor: HTMLElement, range: Range | null): boolean {
// No caret means the insert lands at the end, so the question is about the
// editor's last character either way.
const before = range
? serializeTextBefore(editor, range.startContainer, range.startOffset)
: serializeTextBefore(editor, editor, editor.childNodes.length)
const last = before.slice(-1)
return !last || /[\s\uFFFC]/.test(last)
}
/** Insert text at the caret (replacing any selection), with any directives in
* it landing as chips. Pastes use this instead of `execCommand('insertText')`
* — Chromium's editing pipeline is ~O(n²) on large multiline blobs.
*
* The text arrives whole rather than typed, so a `/command` ending it is
* complete rather than half-written and chips like the rest.
*
* `consumeBefore` characters immediately before the caret are swallowed by the
* insert. That's how a paste into an open `@url:` scope replaces the scope
* instead of stacking on it (`@url:@url:\`https://…\``). */
export function insertComposerContentsAtCaret(editor: HTMLElement, text: string, consumeBefore = 0) {
const scoped = consumeBefore > 0 ? rangeBeforeCaret(editor, consumeBefore) : null
if (scoped) {
scoped.deleteContents()
scoped.collapse(true)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(scoped)
}
const hit = composerSelectionRange(editor)
const fragment = document.createDocumentFragment()
appendComposerContents(fragment, text)
// Before measuring the boundary — a replaced selection puts the insertion
// point where the selection started, not where it ended.
if (hit) {
hit.range.deleteContents()
}
appendComposerContents(fragment, text, {
boundaryBefore: atTokenBoundary(editor, hit?.range ?? null),
trailingCommitted: true
})
// A slash pill ending the insert gets the trailing space the typed commit
// path appends, or the next full re-render reads it as a half-typed token
// and demotes it. `@` refs need no marker — REF_RE re-chips them either way.
if ((fragment.lastChild as HTMLElement | null)?.dataset?.slashKind) {
fragment.append(document.createTextNode(' '))
}
const tail = fragment.lastChild
if (hit) {
hit.range.deleteContents()
hit.range.insertNode(fragment)
} else {
editor.append(fragment)
@@ -173,27 +309,84 @@ export function insertComposerContentsAtCaret(editor: HTMLElement, text: string)
}
}
/** Swap the `length` characters immediately before a collapsed caret for
* `fragment`, leaving the caret after it. Returns whether it ran — a caret that
* isn't inside a text node holding the whole token is left alone. */
export function replaceBeforeCaret(editor: HTMLElement, length: number, fragment: DocumentFragment) {
/** Range covering exactly `length` serialized characters immediately before a
* collapsed caret, spanning Chromium's split text nodes. Null when the caret
* isn't a collapsed selection in `editor`, or when a chip/<br>/block boundary
* interrupts before `length` characters are covered — a trigger token is
* always contiguous text, so anything else means "don't touch the DOM here".
*
* This is what keeps chip insertion stable: Chromium fragments text nodes
* around contenteditable=false chips on every edit, so any commit path that
* demands the whole token inside ONE text node (the old check) degrades to a
* full re-render as soon as a chip exists anywhere in the line. */
export function rangeBeforeCaret(editor: HTMLElement, length: number): Range | null {
const hit = composerSelectionRange(editor)
if (!hit?.range.collapsed) {
return false
if (!hit?.range.collapsed || length <= 0) {
return null
}
const { startContainer, startOffset } = hit.range
let node: Node | null = hit.range.startContainer
let offset = hit.range.startOffset
if (startContainer.nodeType !== Node.TEXT_NODE || startOffset < length) {
return false
// An element-positioned caret (common right after programmatic caret moves)
// resolves to the end of the text node before it. A chip or <br> there means
// no text token precedes the caret — bail rather than guess.
if (node.nodeType !== Node.TEXT_NODE) {
node = node.childNodes[offset - 1] ?? null
if (node?.nodeType !== Node.TEXT_NODE) {
return null
}
offset = (node.textContent || '').length
}
let startNode = node as Text
let startOffset = offset
let remaining = length
while (remaining > 0) {
if (startOffset >= remaining) {
startOffset -= remaining
remaining = 0
break
}
remaining -= startOffset
const prev: Node | null = startNode.previousSibling
if (prev?.nodeType !== Node.TEXT_NODE) {
return null
}
startNode = prev as Text
startOffset = (prev.textContent || '').length
}
const range = document.createRange()
range.setStart(startNode, startOffset)
range.setEnd(hit.range.startContainer, hit.range.startOffset)
return range
}
/** Swap the `length` characters immediately before a collapsed caret for
* `fragment`, leaving the caret after it. Returns whether it ran. Spans split
* text nodes (see rangeBeforeCaret) — a token typed around existing chips
* still commits in place instead of falling back to a full re-render. */
export function replaceBeforeCaret(editor: HTMLElement, length: number, fragment: DocumentFragment) {
const range = rangeBeforeCaret(editor, length)
if (!range) {
return false
}
const tail = fragment.lastChild
range.setStart(startContainer, startOffset - length)
range.setEnd(startContainer, startOffset)
range.deleteContents()
range.insertNode(fragment)
@@ -202,8 +395,11 @@ export function replaceBeforeCaret(editor: HTMLElement, length: number, fragment
}
range.collapse(true)
hit.selection.removeAllRanges()
hit.selection.addRange(range)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
return true
}
@@ -312,6 +508,15 @@ export function composerPlainText(node: Node): string {
return el.dataset.refText
}
// An editor holding nothing but the placeholder <br> is EMPTY. That <br> is
// scaffolding normalizeComposerEditorDom adds so the contenteditable keeps
// its height — not a line the user typed. Reading it as "\n" is how a
// just-cleared composer stayed non-empty: the newline got stashed as the
// session's draft and painted back on return.
if (el.dataset.slot === RICH_INPUT_SLOT && el.childNodes.length === 1 && el.firstChild?.nodeName === 'BR') {
return ''
}
if (el.tagName === 'BR') {
return '\n'
}
@@ -502,6 +707,9 @@ export function normalizeComposerEditorDom(editor: HTMLElement) {
// composer to appear as a tiny dot/pixel. Ensure there's always at least
// one <br> so the element maintains intrinsic height. The CSS min-height
// is a belt; the <br> is suspenders — together they prevent the shrink.
// That break is also why emptiness has to be marked, not inferred.
markEditorEmptiness(editor)
if (editor.childNodes.length === 0) {
editor.appendChild(document.createElement('br'))
}
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { slashCommandMatches } from './slash-refs'
const commands = (text: string, options?: Parameters<typeof slashCommandMatches>[1]) =>
slashCommandMatches(text, options).map(match => `${match.kind}:${match.command}`)
describe('slashCommandMatches', () => {
it('recognizes a leading command and a skill named mid-prose', () => {
expect(commands('/some-skill clean this with /other-skill please')).toEqual([
'skill:/some-skill',
'skill:/other-skill'
])
})
it('leaves a path alone — /usr/local/bin is not a command', () => {
expect(commands('see /usr/local/bin ')).toEqual([])
})
it('holds a trailing token as still-typed unless the text is inert', () => {
expect(commands('/some-skill')).toEqual([])
expect(commands('/some-skill', { trailingCommitted: true })).toEqual(['skill:/some-skill'])
})
it('leaves an arg-taking command as text — its tail may be prose', () => {
expect(commands('/goal ship the redesign')).toEqual([])
})
it('leaves a command with no desktop surface as text', () => {
expect(commands('/exit now')).toEqual([])
})
it('offers a built-in only as an invocation, never mid-message', () => {
// Mirrors what the popover offers: `/new` acts on the app, so it means
// nothing dropped into a sentence, while a skill reads as "handle this
// part with X".
expect(commands('/new ')).toEqual(['command:/new'])
expect(commands('start over with /new ')).toEqual([])
expect(commands('start over with /some-skill ')).toEqual(['skill:/some-skill'])
})
it('disqualifies a leading token when the text lands mid-word', () => {
expect(commands('/some-skill ', { boundaryBefore: false })).toEqual([])
})
})
@@ -0,0 +1,105 @@
/**
* Slash-command recognition for text the composer did not watch being typed —
* a paste, a restored draft, an undo step, a rebuilt line.
*
* The typed path chips a command as it's picked or accepted, so the composer
* agrees with what the sent message renders (`SLASH_SKILL_RE` in
* directive-text). Text that arrives whole never passed through that path, so
* it needs the same commands recognized in place — on exactly the terms the
* typed path would have used, or hydration invents pills the popover would
* never have committed.
*/
import type { SlashChipKind } from '@/components/assistant-ui/directive-text'
import {
desktopSlashCommandArgumentMode,
isDesktopSlashCommand,
resolveDesktopCommand
} from '@/lib/desktop-slash-commands'
// A command token starts a word and doesn't continue into a path: `/usr/local`
// is a path, not a `/usr` command. Same shape the sent message uses to decide
// what renders as a pill, so the composer and the transcript agree.
const SLASH_COMMAND_RE = /(?<=^|\s)\/([a-zA-Z][\w-]*)(?![\w-]*\/)/g
export interface SlashCommandMatch {
/** The command with its leading slash, e.g. `/clean`. */
command: string
end: number
kind: SlashChipKind
start: number
}
export interface SlashCommandScanOptions {
/**
* Whether the text is preceded by a token boundary. False when it's being
* inserted mid-word (a paste landing against existing characters), which
* disqualifies a token at index 0 — `foo/clean` is not a command. It also
* makes that token mid-message rather than an invocation.
*/
boundaryBefore?: boolean
/**
* Whether a token ending the text counts as committed. True for inert text
* (a paste, dropped content): nothing is being typed, so `/clean` at the end
* is the whole command. False while editing live, where a trailing `/wor` is
* a half-typed query the popover owns and must leave editable.
*/
trailingCommitted?: boolean
}
/**
* Only commands with NO argument stage chip: their committed pill is exactly
* the bare `/name`, so the boundary is unambiguous. Arg-taking commands
* (`/goal ship it`) stay text — their tail may be prose. Commands with no
* desktop surface at all (`/exit`, `/config`) stay text too.
*/
function chippableKind(command: string): SlashChipKind | null {
if (!isDesktopSlashCommand(command) || desktopSlashCommandArgumentMode(command) !== null) {
return null
}
return resolveDesktopCommand(command) ? 'command' : 'skill'
}
/** Every `/command` in `text` that should render as a pill, in source order. */
export function slashCommandMatches(text: string, options: SlashCommandScanOptions = {}): SlashCommandMatch[] {
const { boundaryBefore = true, trailingCommitted = false } = options
if (!text.includes('/')) {
return []
}
const matches: SlashCommandMatch[] = []
for (const match of text.matchAll(SLASH_COMMAND_RE)) {
const start = match.index ?? 0
const command = match[0]
const end = start + command.length
const after = text[end]
// A committed pill always carries its auto-inserted trailing space, which
// is what separates it from a token still being typed.
if (after === undefined ? !trailingCommitted : !/\s/.test(after)) {
continue
}
// Only the FIRST token can be an invocation, and only when the text lands
// on a token boundary — `foo` + a pasted `/clean` is `foo/clean`.
const invocation = start === 0
if (invocation && !boundaryBefore) {
continue
}
const kind = chippableKind(command)
// Later tokens are references dropped into prose, where the popover offers
// SKILLS alone — a built-in like `/new` acts on the app and means nothing
// mid-sentence. Hydration has to agree, or pasted text grows pills typing
// never would.
if (kind && (invocation || kind === 'skill')) {
matches.push({ command, end, kind, start })
}
}
return matches
}
@@ -15,7 +15,7 @@ import { Codicon } from '@/components/ui/codicon'
import { DiffCount } from '@/components/ui/diff-count'
import type { HermesGitBranch } from '@/global'
import { useI18n } from '@/i18n'
import { $repoStatus, $repoWorktrees } from '@/store/coding-status'
import { registerRepoStatusCwd, repoStatusForCwd, repoWorktreesForCwd } from '@/store/coding-status'
import { notifyError } from '@/store/notifications'
import { $newWorktreeRequest } from '@/store/projects'
@@ -62,14 +62,24 @@ export const CodingStatusRow = memo(function CodingStatusRow({
const { t } = useI18n()
const s = t.statusStack.coding
const p = t.sidebar.projects
const status = useStore($repoStatus)
const worktrees = useStore($repoWorktrees)
const resolvedRepoPath = repoPath?.trim() || undefined
// This surface's OWN worktree, always — never the primary's. The row used to
// fall back to the global `$repoStatus` for a blank repoPath, which painted
// the main pane's branch/± onto a tile whose cwd hadn't resolved yet. That
// fallback bought nothing (the primary's computed is keyed to `$currentCwd`,
// which is blank in exactly the same case) and cost a wrong-tree rail.
const status = useStore(repoStatusForCwd(resolvedRepoPath))
const worktrees = useStore(repoWorktreesForCwd(resolvedRepoPath))
// While mounted, keep this worktree in the coding-status refresh set so the
// turn-settle / tool-complete / focus edges re-probe it too (tiles otherwise
// only refreshed when the MAIN cwd probe happened to cover them).
useEffect(() => registerRepoStatusCwd(resolvedRepoPath), [resolvedRepoPath])
// Shared worktree dialog — replaces the old inline dialog. Opened by the
// dropdown menu's "branch off" items and the global ⌘⇧B hotkey.
const [worktreeOpen, setWorktreeOpen] = useState(false)
const [worktreeBase, setWorktreeBase] = useState<string | undefined>(undefined)
const resolvedRepoPath = repoPath?.trim() || undefined
const switchToBranch = async (branch: string) => {
if (!onSwitchBranch) {
@@ -1,12 +1,11 @@
import { useStore } from '@nanostores/react'
import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import { type ReactNode, useEffect, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { blurComposerInput } from '@/app/chat/composer/focus'
import { chatSurfaceRoot, clearSurfaceVar, setSurfaceVar, STATUS_STACK_VAR } from '@/app/chat/surface-vars'
import { AGENTS_ROUTE } from '@/app/routes'
import { BillingBanner } from '@/components/billing-banner'
import { composerDockCard, composerFloatingStrip } from '@/components/chat/composer-dock'
import { composerDockCard } from '@/components/chat/composer-dock'
import { StatusSection } from '@/components/chat/status-section'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
@@ -15,7 +14,6 @@ import { type Translations, useI18n } from '@/i18n'
import { useSessionSlice } from '@/lib/use-session-slice'
import { cn } from '@/lib/utils'
import { $billingBlock } from '@/store/billing-block'
import { $composerActionsBySession } from '@/store/composer-actions'
import {
$statusItemsBySession,
type ComposerStatusItem,
@@ -30,7 +28,6 @@ import { $previewStatusBySession, dismissPreviewArtifact } from '@/store/preview
import { $threadScrolledUp } from '@/store/thread-scroll'
import { openSessionInNewWindow } from '@/store/windows'
import { ActionBadges } from './action-badges'
import { PreviewStatusRow } from './preview-row'
import { StatusItemRow } from './status-row'
@@ -95,7 +92,6 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
// items actually changed.
const items = useSessionSlice($statusItemsBySession, sessionId)
const previews = useSessionSlice($previewStatusBySession, sessionId)
const actions = useSessionSlice($composerActionsBySession, sessionId)
const scrolledUp = useStore($threadScrolledUp)
const billing = useStore($billingBlock)
@@ -154,10 +150,6 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
sections.push({ key: 'billing', node: <BillingBanner sessionId={sessionId} /> })
}
// Micro actions ride at the top of the stack — the one block you press
// rather than read. Rendered OUTSIDE the card (see `actionStrip`) so the
// pills float; a blocked account still gets the billing wall above them.
for (const group of groups) {
sections.push({
key: group.type,
@@ -219,50 +211,11 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
// status card, above the billing wall, above everything. They're the only
// rows up here you press instead of read, so nothing may ever stack on top
// of them. Rendered outside the card (below) so the pills float.
const actionStrip = actions.length > 0 && sessionId ? <ActionBadges actions={actions} sessionId={sessionId} /> : null
const visible = sections.length > 0
const visible = sections.length > 0 || Boolean(actionStrip)
const stackRef = useRef<HTMLDivElement | null>(null)
// The stack is out of flow (overlays the thread), so the composer's measured
// height never sees it. Publish our own measured height — bucketed like the
// composer's, to avoid style invalidation churn — so the thread's
// last-message clearance can add it and the stack never hides messages.
// Scoped to THIS surface: tiles render their own stack (see surface-vars.ts).
useLayoutEffect(() => {
const el = stackRef.current
if (!visible || !el) {
return
}
// Resolve the owning surface NOW, while the node is attached. The cleanup
// below runs after the stack collapsed and React removed the div, so
// closest() from the detached node misses [data-chat-surface] and would
// clear the document root instead — leaving the stale height on the
// surface, which keeps inflating the thread's bottom clearance until the
// next publish.
const root = chatSurfaceRoot(el)
let last = -1
const sync = () => {
const bucket = Math.round(el.getBoundingClientRect().height / 8) * 8
if (bucket !== last) {
last = bucket
setSurfaceVar(el, STATUS_STACK_VAR, `${bucket}px`)
}
}
const observer = new ResizeObserver(sync)
observer.observe(el)
sync()
return () => {
observer.disconnect()
clearSurfaceVar(root, STATUS_STACK_VAR)
}
}, [visible])
// No height to publish: the stack is an in-flow child of the composer dock,
// so the dock's own measurement (--composer-measured-height) already covers
// it and the thread clears both with one number.
if (!visible) {
return null
@@ -270,56 +223,34 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
return (
<div
// Sits in the overlay lane above the composer. The composer root has pt-2
// before the actual surface; translate by that amount so the stack returns
// to its original attachment point without intruding into the repo strip.
// pl matches the surface's own left edge: `inset-x-0` resolves against the
// root's PADDING box, while the surface and the underside strip sit in its
// CONTENT box, so without it the lane hangs 5px further left than both.
className="absolute inset-x-0 bottom-full z-3 flex max-h-[40vh] flex-col translate-y-2 pl-[0.3125rem]"
// In flow in the dock column, directly above the composer. The dock is
// bottom-anchored, so this grows upward over the thread without needing
// to be positioned — and it shares the dock's left edge for free.
className="flex max-h-[40vh] min-h-0 flex-col overflow-y-auto"
onPointerDownCapture={() => blurComposerInput()}
ref={stackRef}
>
{/* FIRST in the lane and OUTSIDE the scroller, so nothing can ever sit
above the pills — not the status card, not the billing wall — and a
long todo list can't scroll them out of view. Outside the card too:
they carry their own fill, so they must not paint on its background. */}
{actionStrip && (
{/* The card paints the shared --composer-fill (rest / scrolled / focused
all match the composer surface by construction); on scroll we only
ghost the CONTENT — element opacity on the card would kill the blur.
Rounded top, square bottom; the bottom border is TRANSPARENT — the
composer surface's visible top border (which sits at a higher z) is the
single shared seam, so the two read as one fused capsule. */}
{sections.length > 0 && (
<div
className={cn(
composerFloatingStrip,
'shrink-0 pb-1.5 transition-opacity duration-200 ease-out',
composerDockCard('top'),
// Inset (mx-2) so the stack reads slightly narrower than the composer
// surface below it — the original look.
'mx-2 overflow-hidden rounded-b-none border-b border-b-transparent pt-0.5',
'transition-opacity duration-200 ease-out',
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100' : 'opacity-100'
)}
>
{actionStrip}
{sections.map(section => (
<div key={section.key}>{section.node}</div>
))}
</div>
)}
{/* Everything else scrolls under them. */}
<div className="min-h-0 overflow-y-auto">
{/* The card paints the shared --composer-fill (rest / scrolled / focused
all match the composer surface by construction); on scroll we only
ghost the CONTENT — element opacity on the card would kill the blur.
Rounded top, square bottom; the bottom border is TRANSPARENT — the
composer surface's visible top border (which sits at a higher z) is the
single shared seam, so the two read as one fused capsule. */}
{sections.length > 0 && (
<div
className={cn(
composerDockCard('top'),
// Inset (mx-2) so the stack reads slightly narrower than the composer
// surface below it — the original look.
'mx-2 overflow-hidden rounded-b-none border-b border-b-transparent pt-0.5',
'transition-opacity duration-200 ease-out',
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100' : 'opacity-100'
)}
>
{sections.map(section => (
<div key={section.key}>{section.node}</div>
))}
</div>
)}
</div>
</div>
)
}
@@ -1,94 +0,0 @@
import { act, cleanup, render } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { STATUS_STACK_VAR } from '@/app/chat/surface-vars'
import { I18nProvider } from '@/i18n'
import { $goalsBySession, type SessionGoal } from '@/store/goals'
import { ComposerStatusStack } from './index'
// The stack measures itself into a surface var — jsdom has no ResizeObserver.
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
const SID = 'sess-height-1'
const goal = (): SessionGoal => ({ status: 'active', title: 'ship the feature', updatedAt: Date.now() })
/**
* Regression: when the stack collapses (its last item finishes), React removes
* the stack div BEFORE the layout-effect cleanup runs. Resolving the surface
* from the ref at cleanup time then walks a DETACHED node, misses
* [data-chat-surface], and clears the document root instead — the stale height
* stays on the surface and keeps inflating the thread's bottom clearance
* (`--thread-last-message-clearance`) until the next publish. The effect must
* capture its surface root while the node is still attached.
*/
describe('ComposerStatusStack surface-var lifecycle', () => {
beforeEach(() => {
$goalsBySession.set({})
})
afterEach(() => {
cleanup()
$goalsBySession.set({})
document.documentElement.style.removeProperty(STATUS_STACK_VAR)
})
function renderOnSurface() {
const surface = document.createElement('div')
surface.setAttribute('data-chat-surface', '')
document.body.append(surface)
const view = render(
<MemoryRouter>
<I18nProvider configClient={null} initialLocale="en">
<ComposerStatusStack queue={null} sessionId={SID} />
</I18nProvider>
</MemoryRouter>,
{ container: surface }
)
return { surface, view }
}
it('publishes its measured height onto the owning surface while visible', () => {
$goalsBySession.set({ [SID]: goal() })
const { surface } = renderOnSurface()
// jsdom measures 0 — the value is irrelevant, the target element is not.
expect(surface.style.getPropertyValue(STATUS_STACK_VAR)).toBe('0px')
expect(document.documentElement.style.getPropertyValue(STATUS_STACK_VAR)).toBe('')
})
it('clears the surface var when the stack collapses to nothing', () => {
$goalsBySession.set({ [SID]: goal() })
const { surface } = renderOnSurface()
expect(surface.style.getPropertyValue(STATUS_STACK_VAR)).toBe('0px')
// Last status item goes away → the component renders null and React
// detaches the stack div before the cleanup runs.
act(() => $goalsBySession.set({}))
expect(surface.style.getPropertyValue(STATUS_STACK_VAR)).toBe('')
})
it('clears the surface var on unmount', () => {
$goalsBySession.set({ [SID]: goal() })
const { surface, view } = renderOnSurface()
expect(surface.style.getPropertyValue(STATUS_STACK_VAR)).toBe('0px')
view.unmount()
expect(surface.style.getPropertyValue(STATUS_STACK_VAR)).toBe('')
})
})
@@ -4,19 +4,19 @@ import { blobDedupeKey, detectTrigger, extractClipboardImageBlobs } from './text
describe('detectTrigger', () => {
it('detects a bare slash trigger with an empty query', () => {
expect(detectTrigger('/')).toEqual({ kind: '/', query: '', tokenLength: 1 })
expect(detectTrigger('/')).toEqual({ kind: '/', query: '', tokenLength: 1, value: '' })
})
it('detects a slash command query', () => {
expect(detectTrigger('/skill')).toEqual({ kind: '/', query: 'skill', tokenLength: 6 })
expect(detectTrigger('/skill')).toEqual({ kind: '/', query: 'skill', tokenLength: 6, value: 'skill' })
})
it('detects a bare at-mention trigger with an empty query', () => {
expect(detectTrigger('@')).toEqual({ kind: '@', query: '', tokenLength: 1 })
expect(detectTrigger('@')).toEqual({ kind: '@', query: '', tokenLength: 1, value: '' })
})
it('detects an at-mention query', () => {
expect(detectTrigger('@file')).toEqual({ kind: '@', query: 'file', tokenLength: 5 })
expect(detectTrigger('@file')).toEqual({ kind: '@', query: 'file', tokenLength: 5, value: 'file' })
})
it('returns null for plain text', () => {
@@ -27,17 +27,20 @@ describe('detectTrigger', () => {
expect(detectTrigger('/personality ')).toEqual({
kind: '/',
query: 'personality ',
tokenLength: 13
tokenLength: 13,
value: 'personality '
})
expect(detectTrigger('/personality alic')).toEqual({
kind: '/',
query: 'personality alic',
tokenLength: 17
tokenLength: 17,
value: 'personality alic'
})
expect(detectTrigger('/tools enable foo')).toEqual({
kind: '/',
query: 'tools enable foo',
tokenLength: 17
tokenLength: 17,
value: 'tools enable foo'
})
})
@@ -54,24 +57,75 @@ describe('detectTrigger', () => {
it('keeps the at-mention live while walking into subfolders', () => {
// A `/` inside the query is path navigation, not the end of the token —
// the popover has to stay open so the next directory level can load.
expect(detectTrigger('@./')).toEqual({ kind: '@', query: './', tokenLength: 3 })
expect(detectTrigger('@./src')).toEqual({ kind: '@', query: './src', tokenLength: 6 })
expect(detectTrigger('@~/Desktop/')).toEqual({ kind: '@', query: '~/Desktop/', tokenLength: 11 })
expect(detectTrigger('@/usr/local')).toEqual({ kind: '@', query: '/usr/local', tokenLength: 11 })
expect(detectTrigger('@./')).toEqual({ kind: '@', query: './', tokenLength: 3, value: './' })
expect(detectTrigger('@./src')).toEqual({ kind: '@', query: './src', tokenLength: 6, value: './src' })
expect(detectTrigger('@~/Desktop/')).toEqual({
kind: '@',
query: '~/Desktop/',
tokenLength: 11,
value: '~/Desktop/'
})
expect(detectTrigger('@/usr/local')).toEqual({
kind: '@',
query: '/usr/local',
tokenLength: 11,
value: '/usr/local'
})
expect(detectTrigger('@apps/desktop/src')).toEqual({
kind: '@',
query: 'apps/desktop/src',
tokenLength: 17
tokenLength: 17,
value: 'apps/desktop/src'
})
})
it('keeps the at-mention live for a typed ref kind with a path', () => {
it('treats a chip edge as a token boundary, like whitespace', () => {
// U+FFFC is textBeforeCaret's placeholder for a committed pill. Upstream
// assistant-ui's Lexical DirectivePlugin gets the same semantics from node
// boundaries: typing a trigger right after a chip (no space) still opens
// the popover, and a chip inside a token ends it.
expect(detectTrigger('\uFFFC@Desk')).toEqual({ kind: '@', query: 'Desk', tokenLength: 5, value: 'Desk' })
// Not position 0, so it's an inline reference — not a command invocation.
expect(detectTrigger('\uFFFC/cle')).toEqual({
inline: true,
kind: '/',
query: 'cle',
tokenLength: 4,
value: 'cle'
})
// The placeholder itself never leaks into a query.
expect(detectTrigger('@a\uFFFCb')).toBeNull()
})
it('splits a typed ref kind off as the browse scope', () => {
// `@folder:apps/` is ONE token with TWO parts. The kind is the mode the
// user is browsing in, so it's held as `scope` rather than left in `value`
// for every consumer to re-parse (or, worse, to preserve by hand).
expect(detectTrigger('@file:src/main.tsx')).toEqual({
kind: '@',
query: 'file:src/main.tsx',
tokenLength: 18
scope: 'file',
tokenLength: 18,
value: 'src/main.tsx'
})
expect(detectTrigger('@folder:apps/')).toEqual({ kind: '@', query: 'folder:apps/', tokenLength: 13 })
expect(detectTrigger('@folder:apps/')).toEqual({
kind: '@',
query: 'folder:apps/',
scope: 'folder',
tokenLength: 13,
value: 'apps/'
})
// A scope with nothing typed after it is the empty-browse state the
// popover renders a header for.
expect(detectTrigger('@url:')).toEqual({ kind: '@', query: 'url:', scope: 'url', tokenLength: 5, value: '' })
})
it('only treats a KNOWN kind as a scope', () => {
// `@teknium1:` is a handle with a colon, not a directive — inventing a
// scope for it would make Backspace eat the whole word.
expect(detectTrigger('@teknium1:')?.scope).toBeUndefined()
expect(detectTrigger('@teknium1:')?.value).toBe('teknium1:')
expect(detectTrigger('@localhost:8080')?.scope).toBeUndefined()
})
it('still ends the at-mention token at whitespace', () => {
@@ -80,15 +134,28 @@ describe('detectTrigger', () => {
expect(detectTrigger('look at @apps/desktop')).toEqual({
kind: '@',
query: 'apps/desktop',
tokenLength: 13
tokenLength: 13,
value: 'apps/desktop'
})
})
it('treats a mid-message slash as an inline reference', () => {
// Skills have to be reachable anywhere in a prompt, not just at position 0.
expect(detectTrigger('hello /')).toEqual({ kind: '/', inline: true, query: '', tokenLength: 1 })
expect(detectTrigger('hello /clean')).toEqual({ kind: '/', inline: true, query: 'clean', tokenLength: 6 })
expect(detectTrigger('text\n/skill')).toEqual({ kind: '/', inline: true, query: 'skill', tokenLength: 6 })
expect(detectTrigger('hello /')).toEqual({ kind: '/', inline: true, query: '', tokenLength: 1, value: '' })
expect(detectTrigger('hello /clean')).toEqual({
kind: '/',
inline: true,
query: 'clean',
tokenLength: 6,
value: 'clean'
})
expect(detectTrigger('text\n/skill')).toEqual({
kind: '/',
inline: true,
query: 'skill',
tokenLength: 6,
value: 'skill'
})
})
it('does not carry arg completion into an inline slash reference', () => {
@@ -1,14 +1,35 @@
import { DATA_IMAGE_URL_RE, dataUrlToBlob } from '@/lib/embedded-images'
import { $reactionsEnabled } from '@/store/reactions-enabled'
import { serializeTextBefore } from './rich-editor'
export interface TriggerState {
/** True for a `/` typed mid-message — an inline skill/command reference in
* prose rather than a command invocation. Arg completion doesn't apply. */
inline?: boolean
kind: '@' | '/'
kind: '@' | '/' | ':'
query: string
/** The `@kind:` prefix the user scoped the browse to, when there is one. */
scope?: DirectiveScope
tokenLength: number
/** `query` minus the `scope:` prefix — the value actually being typed. */
value: string
}
/** Directive kinds the `@` popover can scope a browse to. Mirrors the starter
* rows in use-at-completions and the gateway's `complete.path` prefixes. */
export const DIRECTIVE_SCOPES = ['file', 'folder', 'url', 'image', 'tool', 'git'] as const
export type DirectiveScope = (typeof DIRECTIVE_SCOPES)[number]
// Picking "attach a folder" types `@folder:` into the editor, and everything
// after it is the value being browsed. Parsing that prefix off the query is
// what lets the rest of the composer treat it as the BROWSE MODE it is rather
// than characters the user has to maintain by hand — Tab-descending has to
// carry it down, Backspace has to drop it whole, and a chip landing on it has
// to consume it.
const AT_SCOPE_RE = new RegExp(`^(${DIRECTIVE_SCOPES.join('|')}):(.*)$`)
// `@` triggers stop at the first whitespace — `@file:path` and `@diff` are
// single tokens, and a path is part of that token: `@./src/`, `@~/Desktop/`,
// and `@file:src/foo` all have to keep the popover live while the user walks
@@ -36,9 +57,18 @@ export interface TriggerState {
// The inline shape is what makes skills reachable anywhere in a prompt. Both
// shapes need the trailing `$`: detection runs against the text BEFORE the
// caret, so the match must end where the user is typing.
const AT_TRIGGER_RE = /(?:^|[\s])(@)([^\s@]*)$/
//
// U+FFFC is the placeholder textBeforeCaret emits for a committed chip. A chip
// edge is a token boundary just like whitespace (upstream assistant-ui's
// Lexical DirectivePlugin gets the same semantics from node boundaries), so
// `@` or `/` typed immediately after a pill still opens the popover.
const AT_TRIGGER_RE = /(?:^|[\s\uFFFC])(@)([^\s@\uFFFC]*)$/
const SLASH_COMMAND_TRIGGER_RE = /^(\/)((?:[a-zA-Z][\w-]*(?:\s+\S*)*)?)$/
const SLASH_INLINE_TRIGGER_RE = /[\s](\/)([a-zA-Z][\w-]*)?$/
const SLASH_INLINE_TRIGGER_RE = /[\s\uFFFC](\/)([a-zA-Z][\w-]*)?$/
// `:joy` → emoji completions, Slack-style. Boundary-anchored so a mid-word
// colon (`localhost:8080`, `note:`) never fires; two chars minimum so a bare
// `:` or `:D` smiley doesn't open a popover the user didn't ask for.
const EMOJI_TRIGGER_RE = /(?:^|[\s\uFFFC])(:)([a-zA-Z0-9_+-]{2,})$/
/** Stable key for paste dedupe — `items` and `files` often mirror the same image as different objects. */
export function blobDedupeKey(blob: Blob): string {
@@ -112,7 +142,17 @@ export function extractClipboardImageBlobs(clipboard: DataTransfer): Blob[] {
return blobs
}
/** Caret-anchored text before the cursor, or null if the selection isn't a collapsed caret inside `editor`. */
/** Caret-anchored text before the cursor, or null if the selection isn't a
* collapsed caret inside `editor`.
*
* Chips are ATOMIC to trigger detection: a committed pill must not leak its
* label text into the string the trigger regexes see. A `/work` pill whose
* label serialized into this text made the `^`-anchored command regex treat
* everything after it as that command's argument — which silenced the `@`
* popover for the rest of the message (`/work @Desk` → no trigger → the
* typed path never chips and submits as plain text). Each chip contributes
* an object-replacement placeholder instead, and <br> contributes a newline
* so a trigger at the start of a wrapped line still detects. */
export function textBeforeCaret(editor: HTMLDivElement): string | null {
const sel = window.getSelection()
const range = sel?.rangeCount ? sel.getRangeAt(0) : null
@@ -121,11 +161,16 @@ export function textBeforeCaret(editor: HTMLDivElement): string | null {
return null
}
const before = range.cloneRange()
before.selectNodeContents(editor)
before.setEnd(range.startContainer, range.startOffset)
return serializeTextBefore(editor, range.startContainer, range.startOffset)
}
return before.toString()
/** How many characters of directive scope the caret is sitting inside (`@url:`
* with nothing typed after it), or 0. A paste lands INTO that scope: the scope
* text is consumed rather than left in front of the chip as leftover syntax. */
export function openDirectiveScope(editor: HTMLDivElement): number {
const trigger = detectTrigger(textBeforeCaret(editor) ?? '')
return trigger?.kind === '@' && trigger.scope && !trigger.value ? trigger.tokenLength : 0
}
export function detectTrigger(textBefore: string): TriggerState | null {
@@ -138,19 +183,37 @@ export function detectTrigger(textBefore: string): TriggerState | null {
if (inline) {
const query = inline[2] ?? ''
return { inline: true, kind: '/', query, tokenLength: 1 + query.length }
return { inline: true, kind: '/', query, tokenLength: 1 + query.length, value: query }
}
const command = SLASH_COMMAND_TRIGGER_RE.exec(textBefore)
if (command) {
return { kind: '/', query: command[2], tokenLength: 1 + command[2].length }
return { kind: '/', query: command[2], tokenLength: 1 + command[2].length, value: command[2] }
}
const at = AT_TRIGGER_RE.exec(textBefore)
if (at) {
return { kind: '@', query: at[2], tokenLength: 1 + at[2].length }
const query = at[2]
const scoped = AT_SCOPE_RE.exec(query)
return {
kind: '@',
query,
...(scoped ? { scope: scoped[1] as DirectiveScope } : {}),
tokenLength: 1 + query.length,
value: scoped ? (scoped[2] ?? '') : query
}
}
// After `@` so a directive starter's colon (`@file:`) stays an `@` query.
// Rides the reactions opt-in (Settings → Appearance) — both are one
// "emoji features" surface, off by default.
const emoji = $reactionsEnabled.get() ? EMOJI_TRIGGER_RE.exec(textBefore) : null
if (emoji) {
return { kind: ':', query: emoji[2], tokenLength: 1 + emoji[2].length, value: emoji[2] }
}
return null
@@ -0,0 +1,151 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { ComposerTriggerPopover } from './trigger-popover'
vi.mock('@/i18n', () => ({
useI18n: () => ({
t: {
composer: {
lookupLoading: 'Loading…',
lookupNoMatches: 'No matches',
lookupTry: 'Try',
lookupOr: 'or'
}
}
})
}))
function atItem(type: string, display: string, rawText: string, meta = '') {
return {
id: `${rawText}|0`,
type,
label: display,
metadata: { icon: type, display, meta, rawText, insertId: display }
}
}
function slashItem(command: string, group: string, meta = '') {
return {
id: `${command}|0`,
type: 'slash',
label: command.slice(1),
metadata: { command, display: command, meta, group, action: '', rawText: command }
}
}
const noop = () => {}
/** The rendered shape of one row: does it have an icon, and how is it laid out?
* Icons are codicons — an `<i class="codicon codicon-<name>">`, not an SVG. */
function rowShape(root: HTMLElement) {
const row = root.querySelector('button') as HTMLElement
const icon = row.querySelector('i.codicon')
return {
hasIcon: Boolean(icon),
iconName: icon?.className.match(/codicon-([\w-]+)/)?.[1],
classes: row.className
}
}
describe('@ and / are one menu', () => {
it('a slash row has an icon, just like an @ row', () => {
const at = render(
<ComposerTriggerPopover
activeIndex={0}
items={[atItem('folder', 'apps/desktop/', '@folder:apps/desktop/', 'dir')]}
kind="@"
loading={false}
onHover={noop}
onPick={noop}
/>
)
const atShape = rowShape(at.container)
at.unmount()
const slash = render(
<ComposerTriggerPopover
activeIndex={0}
items={[slashItem('/work', 'Skills', 'Start in a worktree')]}
kind="/"
loading={false}
onHover={noop}
onPick={noop}
/>
)
const slashShape = rowShape(slash.container)
// The whole point: `/` used to render a stacked, icon-less row.
expect(slashShape.hasIcon).toBe(true)
expect(atShape.hasIcon).toBe(true)
expect(slashShape.classes).toBe(atShape.classes)
// And the glyph reflects the kind, not one generic bullet.
expect(slashShape.iconName).toBe('zap')
expect(atShape.iconName).toBe('folder')
})
it('renders the name and description for both kinds', () => {
const { rerender } = render(
<ComposerTriggerPopover
activeIndex={0}
items={[slashItem('/work', 'Skills', 'Start in a worktree')]}
kind="/"
loading={false}
onHover={noop}
onPick={noop}
/>
)
expect(screen.getByText('/work')).toBeTruthy()
expect(screen.getByText('Start in a worktree')).toBeTruthy()
rerender(
<ComposerTriggerPopover
activeIndex={0}
items={[atItem('file', 'src/main.tsx', '@file:src/main.tsx', 'src')]}
kind="@"
loading={false}
onHover={noop}
onPick={noop}
/>
)
expect(screen.getByText('src/main.tsx')).toBeTruthy()
expect(screen.getByText('src')).toBeTruthy()
})
it('an emoji row stays icon-less — the emoji IS the icon', () => {
const { container } = render(
<ComposerTriggerPopover
activeIndex={0}
items={[{ id: ':joy:|0', type: 'emoji', label: '😂 :joy:', metadata: { display: '😂 :joy:' } }]}
kind=":"
loading={false}
onHover={noop}
onPick={noop}
/>
)
expect(rowShape(container).hasIcon).toBe(false)
})
it('labels the active browse scope from the shared vocabulary', () => {
render(
<ComposerTriggerPopover
activeIndex={0}
items={[atItem('folder', 'apps/', '@folder:apps/', 'dir')]}
kind="@"
loading={false}
onHover={noop}
onPick={noop}
scope="folder"
/>
)
expect(screen.getByText('Folders')).toBeTruthy()
})
})
@@ -1,39 +1,14 @@
import type { Unstable_TriggerItem } from '@assistant-ui/core'
import { Fragment } from 'react'
import { referenceKind, referenceStyle } from '@/components/assistant-ui/reference-kinds'
import { Codicon } from '@/components/ui/codicon'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import { COMPLETION_DRAWER_BELOW_CLASS, COMPLETION_DRAWER_CLASS, CompletionDrawerEmpty } from './completion-drawer'
const AT_ICON_BY_TYPE: Record<string, string> = {
diff: 'diff',
file: 'book',
folder: 'folder',
git: 'git-branch',
image: 'file-media',
simple: 'symbol-misc',
staged: 'diff-added',
tool: 'tools',
url: 'globe'
}
function atIcon(item: Unstable_TriggerItem) {
const meta = item.metadata as { rawText?: string } | undefined
const raw = meta?.rawText || item.label
if (raw.startsWith('@diff')) {
return AT_ICON_BY_TYPE.diff
}
if (raw.startsWith('@staged')) {
return AT_ICON_BY_TYPE.staged
}
return AT_ICON_BY_TYPE[item.type] || AT_ICON_BY_TYPE.simple
}
import type { DirectiveScope } from './text-utils'
interface RowMeta {
display?: string
@@ -41,22 +16,67 @@ interface RowMeta {
meta?: string
}
const ROW_BASE_CLASS = [
'relative flex w-full cursor-default select-none rounded-md px-2 py-1 text-left',
/** The kind a row represents, for its icon. `@` rows carry it as the item type;
* `/` rows carry it as the completion group (Skills / Themes / Commands). */
function rowKind(item: Unstable_TriggerItem, isSlash: boolean): string {
const meta = item.metadata as (RowMeta & { rawText?: string }) | undefined
if (isSlash) {
const group = meta?.group?.trim()
return group === 'Skills' ? 'skill' : group === 'Themes' ? 'theme' : 'command'
}
// The gateway's simple refs (`@diff`, `@staged`) share one item type, so the
// glyph comes from the directive itself.
const raw = meta?.rawText || item.label
if (raw.startsWith('@diff')) {
return 'diff'
}
if (raw.startsWith('@staged')) {
return 'staged'
}
return item.type
}
const ROW_CLASS = [
'relative flex w-full cursor-default select-none items-center gap-2 rounded-md px-2 py-1 text-left',
'outline-hidden transition-colors hover:bg-(--ui-bg-tertiary)',
'data-[highlighted]:bg-(--ui-bg-tertiary) data-[highlighted]:text-foreground'
].join(' ')
const GROUP_HEADER_CLASS =
'select-none px-2 pb-0.5 text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary)'
interface ComposerTriggerPopoverProps {
activeIndex: number
items: readonly Unstable_TriggerItem[]
kind: '@' | '/'
kind: '@' | '/' | ':'
loading: boolean
onHover: (index: number) => void
onPick: (item: Unstable_TriggerItem) => void
placement?: 'bottom' | 'top'
/** The `@kind:` browse the list is filtered to, when there is one. Rendered
* as a header so the scope reads as the mode it is — the raw `@folder:` in
* the editor otherwise looks like syntax the user has to finish by hand. */
scope?: DirectiveScope
}
/**
* The composer's completion list, for every trigger.
*
* `@` and `/` render through the SAME row: icon, name, description. They used
* to be two layouts in one file — `@` horizontal with an icon, `/` stacked with
* none — which is why picking a file and picking a skill felt like features
* from different apps. Icons and accents come from the shared reference
* vocabulary, so a row looks like the chip it will become.
*
* `:` emoji is the one exception: the emoji IS the icon, so it renders as a
* single display string (Slack's exact shape).
*/
export function ComposerTriggerPopover({
activeIndex,
items,
@@ -64,11 +84,13 @@ export function ComposerTriggerPopover({
loading,
onHover,
onPick,
placement = 'top'
placement = 'top',
scope
}: ComposerTriggerPopoverProps) {
const { t } = useI18n()
const copy = t.composer
const isSlash = kind === '/'
const isEmoji = kind === ':'
let lastGroup: string | undefined
@@ -80,6 +102,7 @@ export function ComposerTriggerPopover({
onMouseDown={event => event.preventDefault()}
role="listbox"
>
{scope && <div className={cn(GROUP_HEADER_CLASS, 'pt-0.5')}>{referenceStyle(scope).label}</div>}
{items.length === 0 ? (
loading ? (
<div className="flex items-center gap-2 px-2 py-1.5 text-(--ui-text-tertiary)">
@@ -93,6 +116,10 @@ export function ComposerTriggerPopover({
{copy.lookupTry} <span className="font-mono text-foreground/80">@file:</span> {copy.lookupOr}{' '}
<span className="font-mono text-foreground/80">@folder:</span>.
</>
) : isEmoji ? (
<>
{copy.lookupTry} <span className="font-mono text-foreground/80">:joy:</span>.
</>
) : (
<>
{copy.lookupTry} <span className="font-mono text-foreground/80">/help</span>.
@@ -110,58 +137,28 @@ export function ComposerTriggerPopover({
const isFirstHeader = lastGroup === undefined
lastGroup = group || lastGroup
const active = index === activeIndex
const refKind = referenceKind(rowKind(item, isSlash))
return (
<Fragment key={item.id}>
{showHeader && (
<div
className={cn(
'select-none px-2 pb-0.5 text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary)',
isFirstHeader ? 'pt-0.5' : 'pt-2'
)}
>
{group}
</div>
)}
{showHeader && <div className={cn(GROUP_HEADER_CLASS, isFirstHeader ? 'pt-0.5' : 'pt-2')}>{group}</div>}
<button
className={cn(ROW_BASE_CLASS, isSlash ? 'flex-col gap-0' : 'items-center gap-2')}
className={ROW_CLASS}
data-highlighted={active ? '' : undefined}
onClick={() => onPick(item)}
onMouseEnter={() => onHover(index)}
type="button"
>
{isSlash ? (
<>
{/* Active row (keyboard nav or hover) un-truncates inline so
long command names / descriptions stay readable without a
floating tooltip. */}
<span
className={cn(
'font-medium leading-snug text-foreground',
active ? 'whitespace-normal break-words' : 'truncate'
)}
>
{display}
</span>
{description && (
<span
className={cn(
'leading-snug text-(--ui-text-tertiary)',
active ? 'whitespace-normal break-words' : 'truncate'
)}
>
{description}
</span>
)}
</>
{isEmoji ? (
// The emoji is its own icon — a glyph column beside it reads
// as decoration.
<span className="min-w-0 shrink truncate leading-5 text-foreground">{display}</span>
) : (
<>
<span className="grid size-4 shrink-0 place-items-center text-(--ui-text-tertiary)">
<Codicon name={atIcon(item)} size="0.875rem" />
</span>
<span className="min-w-0 shrink truncate font-mono font-medium leading-5 text-foreground">
{display}
<span className="grid size-4 shrink-0 place-items-center text-(--ref-color)" data-ref={refKind}>
<Codicon name={referenceStyle(refKind).codicon} size="0.875rem" />
</span>
<span className="min-w-0 shrink truncate font-medium leading-5 text-foreground">{display}</span>
{description && (
<span className="min-w-0 flex-1 truncate leading-5 text-(--ui-text-tertiary)">{description}</span>
)}
+10 -4
View File
@@ -39,7 +39,8 @@ import {
$sessions,
resolveComposerSessionKey,
sessionMatchesStoredId,
sessionPinId
sessionPinId,
shouldMigrateComposerScope
} from '@/store/session'
import { isSecondaryWindow, isWatchWindow } from '@/store/windows'
import type { ModelOptionsResponse } from '@/types/hermes'
@@ -326,14 +327,19 @@ export function ChatView({
// When the tip row arrives after compression, migrate any tip-keyed stash onto
// the durable lineage key before the composer remounts onto that key.
//
// ONLY same-conversation rekeys (tip → root). The route-driven queueSessionKey
// can flip to Session B a frame before the store selection leaves Session A;
// migrating on bare inequality would re-home A's queued prompts onto B and
// auto-drain them into the wrong chat.
useEffect(() => {
if (!selectedSessionId || !queueSessionKey || selectedSessionId === queueSessionKey) {
if (!shouldMigrateComposerScope(selectedSessionId, queueSessionKey, sessions)) {
return
}
migrateSessionDraft(selectedSessionId, queueSessionKey)
migrateQueuedPrompts(selectedSessionId, queueSessionKey)
}, [queueSessionKey, selectedSessionId])
}, [queueSessionKey, selectedSessionId, sessions])
// Transcript-side stops (the streaming message's hover Stop, the runtime's
// cancel) are explicit halts, same as the composer's Stop button: park any
@@ -542,7 +548,7 @@ export function ChatView({
config={COMPOSER_HEART_CONFIG}
style={{
top: 0,
bottom: 'calc(var(--composer-measured-height) + var(--status-stack-measured-height) + 0.25rem)'
bottom: 'calc(var(--composer-measured-height) + 0.25rem)'
}}
/>
)}
@@ -11,8 +11,8 @@ import { $threadJumpButtonVisible, requestScrollToBottom } from '@/store/thread-
/**
* Floating "jump to bottom" control. Sits centered just above the composer,
* clearing the out-of-flow status stack via the same measured-height CSS vars
* the thread's bottom clearance uses (`--composer-measured-height` +
* `--status-stack-measured-height`), so it never overlaps the queue / subagent
* the thread's bottom clearance uses (`--composer-measured-height`, which
* covers the whole dock), so it never overlaps the queue / subagent
* / background cards. Visible only while the user has scrolled meaningfully
* away from the bottom; clicking re-arms sticky-bottom and pins the viewport.
*
@@ -62,7 +62,7 @@ export function ScrollToBottomButton() {
requestScrollToBottom()
}}
style={{
bottom: 'calc(var(--composer-measured-height) + var(--status-stack-measured-height) + 0.625rem)'
bottom: 'calc(var(--composer-measured-height) + 0.625rem)'
}}
tabIndex={visible ? 0 : -1}
type="button"
+11 -5
View File
@@ -27,7 +27,7 @@ import { ModelMenuPanel } from '@/app/shell/model-menu-panel'
import { formatRefValue } from '@/components/assistant-ui/directive-text'
import { CenteredThreadSpinner } from '@/components/assistant-ui/thread/status'
import { findGroupOfPane } from '@/components/pane-shell/tree/model'
import { $layoutTree, moveTreePane, setTreeGroupHeaderHidden } from '@/components/pane-shell/tree/store'
import { $layoutTree, closeTreePane, moveTreePane, setTreeGroupHeaderHidden } from '@/components/pane-shell/tree/store'
import { Button } from '@/components/ui/button'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { transcribeAudio } from '@/hermes'
@@ -504,9 +504,10 @@ export function SessionTabMenu({
}
/** The MAIN tab's menu: the same session verbs targeting the primary's loaded
* session, plus the bar's off switch (the bar sticky-shows once a tab is
* ever gained; this is the explicit way back). A fresh draft has no session —
* no menu. */
* session, plus Close (the tab empties to a fresh draft — the workspace pane
* itself never leaves the tree) and the bar's off switch (the bar sticky-shows
* once a tab is ever gained; this is the explicit way back). A fresh draft has
* no session — no menu. */
export function WorkspaceTabMenu({ children }: { children: React.ReactElement }) {
const selected = useStore($selectedStoredSessionId)
@@ -524,7 +525,12 @@ export function WorkspaceTabMenu({ children }: { children: React.ReactElement })
}
return (
<SessionTabMenu onHideTabBar={hideTabBar} storedSessionId={selected} tabPaneId="workspace">
<SessionTabMenu
onClose={() => closeTreePane('workspace')}
onHideTabBar={hideTabBar}
storedSessionId={selected}
tabPaneId="workspace"
>
{children}
</SessionTabMenu>
)
+1 -1
View File
@@ -10,7 +10,7 @@ import { cn } from '@/lib/utils'
/** The muted slot beside a section label (loading glyph, status hint). */
export function SidebarSectionMeta({ children }: { children: React.ReactNode }) {
return <span className="text-[0.6875rem] font-medium text-(--ui-text-quaternary)">{children}</span>
return <span className="shrink-0 text-[0.6875rem] font-medium text-(--ui-text-quaternary)">{children}</span>
}
// ── Row geometry (session row is canonical — everything composes these) ─────
@@ -135,7 +135,7 @@ export function SidebarCronJobsSection({
<SidebarGroup className="shrink-0 p-0 pb-1">
<div className="group/section flex shrink-0 items-center justify-between pb-1 pt-1.5">
<button
className="group/section-label flex w-fit items-center gap-1 bg-transparent text-left leading-none"
className="group/section-label flex w-fit min-w-0 items-center gap-1 bg-transparent text-left leading-none"
onClick={onToggle}
type="button"
>
+13 -16
View File
@@ -46,6 +46,7 @@ import {
$sidebarSessionOrderManual,
$sidebarWorkspaceOrderIds,
$sidebarWorkspaceParentOrderIds,
filterVisibleProjects,
pinSession,
SESSION_SEARCH_FOCUS_EVENT,
setPinnedSessionOrder,
@@ -609,23 +610,19 @@ export function ChatSidebar({
return []
}
const dismissed = new Set(dismissedAutoProjects)
const sorted = sortProjectsForOverview(
projectTree
.filter(node => !(node.isAuto && dismissed.has(node.id)))
.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
)
),
filterVisibleProjects(projectTree, dismissedAutoProjects).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
)
@@ -17,7 +17,7 @@ vi.mock('@/i18n', () => ({
projects: {
enter: (label: string) => `Enter ${label}`,
reorder: (label: string) => `Reorder ${label}`,
toggle: (label: string) => `Toggle ${label} sessions`
toggle: (label: string, open: boolean) => `${open ? 'Show' : 'Hide'} ${label} sessions`
}
}
}
@@ -60,14 +60,15 @@ describe('ProjectOverviewRow', () => {
/>
)
const button = screen.getByRole('button', { name: 'Toggle Test D sessions' })
// Collapsed by default, so the disclosure offers to show the sessions.
const button = screen.getByRole('button', { name: 'Show Test D sessions' })
expect(tipTrigger(button)).toBeTruthy()
})
it('does not render the disclosure toggle when there is nothing to preview', () => {
render(<ProjectOverviewRow project={project} />)
expect(screen.queryByRole('button', { name: 'Toggle Test D sessions' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Show Test D sessions' })).toBeNull()
})
it('drops the "new session" add button on Home, which has no folder to start in', () => {
@@ -137,9 +137,9 @@ export function ProjectOverviewRow({
{project.label}
</SidebarRowLink>
{preview.length > 0 ? (
<Tip label={s.projects.toggle(project.label)}>
<Tip label={s.projects.toggle(project.label, !open)}>
<button
aria-label={s.projects.toggle(project.label)}
aria-label={s.projects.toggle(project.label, !open)}
className="flex flex-1 items-center self-stretch bg-transparent p-0"
onClick={toggleOpen}
type="button"
@@ -24,7 +24,9 @@ function LaneLabel({ label, title }: { label: string; title?: string }) {
const tail = label.slice(label.length - tailLen)
return (
<span className="flex min-w-0" title={title}>
// overflow-hidden: the pinned tail is shrink-0, so at extreme narrow widths
// it must clip inside the label rather than push the trailing icons out.
<span className="flex min-w-0 overflow-hidden" title={title}>
<span className="truncate">{head}</span>
<span className="shrink-0 whitespace-pre">{tail}</span>
</span>
@@ -13,6 +13,7 @@ import type { SessionInfo } from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { middleClickHandlers } from '@/lib/middle-click'
import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
import { coarseElapsed } from '@/lib/time'
import { cn } from '@/lib/utils'
@@ -168,16 +169,11 @@ function SidebarSessionRowImpl({
)}
<SidebarRowBody
className={cn('z-0 group-hover:pr-12', branchStem && 'pl-3.5')}
// Middle-click = open in a new tab (browser muscle memory). Swallow
// the mousedown so Chromium doesn't enter autoscroll mode.
onAuxClick={event => {
if (event.button === 1) {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
openSession(session.id, () => undefined, 'tab')
}
}}
// Middle-click = open in a new tab (browser muscle memory).
{...middleClickHandlers(() => {
triggerHaptic('selection')
openSession(session.id, () => undefined, 'tab')
})}
onClick={event => {
const mod = event.metaKey || event.ctrlKey
@@ -213,7 +209,6 @@ function SidebarSessionRowImpl({
onResume()
}}
onMouseDown={event => event.button === 1 && event.preventDefault()}
>
{reorderable ? (
<SidebarRowGrab
@@ -64,7 +64,9 @@ function SidebarSectionHeader({
<div className="group/section flex shrink-0 items-center justify-between gap-1 pb-1 pt-1.5">
{collapsible ? (
<button
className="group/section-label flex w-fit items-center gap-1 bg-transparent text-left leading-none"
// min-w-0 lets the label truncate at narrow sidebar widths instead of
// pushing the header's trailing action icons out of view.
className="group/section-label flex w-fit min-w-0 items-center gap-1 bg-transparent text-left leading-none"
onClick={onToggle}
type="button"
>
@@ -75,7 +77,7 @@ function SidebarSectionHeader({
/>
</button>
) : (
<div className="flex w-fit items-center gap-1 leading-none">{labelBody}</div>
<div className="flex w-fit min-w-0 items-center gap-1 leading-none">{labelBody}</div>
)}
{action}
</div>
+68 -47
View File
@@ -1,68 +1,89 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import { chatSurfaceRoot, clearSurfaceVar, setSurfaceVar, STATUS_STACK_VAR } from './surface-vars'
import { chatSurfaceRoot, clearSurfaceVar, COMPOSER_HEIGHT_VAR, setSurfaceVar } from './surface-vars'
/**
* Measured-height vars must be scoped per chat surface. Session tiles render a
* full ChatView — thread, composer, and status stack — beside the workspace
* pane, so a single global slot let a background tab's tall status stack
* inflate the foreground thread's bottom clearance and shove its
* jump-to-bottom button into mid-screen.
* Regression: the thread's bottom gap going randomly huge and staying huge.
*
* `--thread-last-message-clearance` is `composer + status-stack + 2rem`, and
* both inputs are published by JS onto the owning `[data-chat-surface]`. The
* helpers used to fall back to `document.documentElement` when they couldn't
* find a surface — which is exactly the state a publisher is in once React has
* detached it. `:root` is where every surface's DEFAULTS live, so one such
* write becomes a global floor under every thread's clearance, inherited by
* every tab, until reload. Observed in the wild as
* `calc(56px + 176px + 2rem)`: a 176px status stack that had been gone for
* minutes, on a session that never had one.
*/
function surface(): { root: HTMLElement; stack: HTMLElement } {
const root = document.createElement('div')
root.setAttribute('data-chat-surface', '')
const stack = document.createElement('div')
root.append(stack)
document.body.append(root)
return { root, stack }
}
describe('per-surface measured-height vars', () => {
it('publishes onto the owning chat surface, not the document root', () => {
const { root, stack } = surface()
setSurfaceVar(stack, STATUS_STACK_VAR, '96px')
expect(root.style.getPropertyValue(STATUS_STACK_VAR)).toBe('96px')
expect(document.documentElement.style.getPropertyValue(STATUS_STACK_VAR)).toBe('')
describe('surface measured-height vars', () => {
afterEach(() => {
document.documentElement.style.removeProperty(COMPOSER_HEIGHT_VAR)
document.body.replaceChildren()
})
it('keeps two visible surfaces independent', () => {
const background = surface()
const foreground = surface()
function surface() {
const root = document.createElement('div')
root.setAttribute('data-chat-surface', '')
setSurfaceVar(background.stack, STATUS_STACK_VAR, '160px')
setSurfaceVar(foreground.stack, STATUS_STACK_VAR, '0px')
const publisher = document.createElement('div')
root.append(publisher)
document.body.append(root)
expect(background.root.style.getPropertyValue(STATUS_STACK_VAR)).toBe('160px')
expect(foreground.root.style.getPropertyValue(STATUS_STACK_VAR)).toBe('0px')
return { publisher, root }
}
it('publishes onto the owning surface, not the document root', () => {
const { publisher, root } = surface()
setSurfaceVar(publisher, COMPOSER_HEIGHT_VAR, '176px')
expect(root.style.getPropertyValue(COMPOSER_HEIGHT_VAR)).toBe('176px')
expect(document.documentElement.style.getPropertyValue(COMPOSER_HEIGHT_VAR)).toBe('')
})
it('clearing one surface leaves the other intact', () => {
const a = surface()
const b = surface()
it('never poisons the document root from a detached publisher', () => {
const { publisher } = surface()
setSurfaceVar(a.stack, STATUS_STACK_VAR, '48px')
setSurfaceVar(b.stack, STATUS_STACK_VAR, '80px')
clearSurfaceVar(a.stack, STATUS_STACK_VAR)
// How the real leak happens: React removes the stack/composer div when it
// collapses, orphaning it from its surface, and a pending measurement
// publishes anyway. `closest()` from an orphan finds nothing.
publisher.remove()
setSurfaceVar(publisher, COMPOSER_HEIGHT_VAR, '176px')
expect(a.root.style.getPropertyValue(STATUS_STACK_VAR)).toBe('')
expect(b.root.style.getPropertyValue(STATUS_STACK_VAR)).toBe('80px')
expect(document.documentElement.style.getPropertyValue(COMPOSER_HEIGHT_VAR)).toBe('')
})
it('falls back to the document root outside a chat surface (popped-out composer)', () => {
it('never poisons the document root from a publisher outside any surface', () => {
const orphan = document.createElement('div')
document.body.append(orphan)
expect(chatSurfaceRoot(orphan)).toBe(document.documentElement)
setSurfaceVar(orphan, COMPOSER_HEIGHT_VAR, '176px')
setSurfaceVar(orphan, STATUS_STACK_VAR, '24px')
expect(document.documentElement.style.getPropertyValue(STATUS_STACK_VAR)).toBe('24px')
expect(document.documentElement.style.getPropertyValue(COMPOSER_HEIGHT_VAR)).toBe('')
})
clearSurfaceVar(orphan, STATUS_STACK_VAR)
expect(document.documentElement.style.getPropertyValue(STATUS_STACK_VAR)).toBe('')
it('reports no owner for an orphaned or unowned node', () => {
const { publisher, root } = surface()
expect(chatSurfaceRoot(publisher)).toBe(root)
// A subtree detached whole still resolves — the surface is still an
// ancestor. Only losing the surface from the chain orphans the publisher.
root.remove()
expect(chatSurfaceRoot(publisher)).toBe(root)
publisher.remove()
expect(chatSurfaceRoot(publisher)).toBeNull()
expect(chatSurfaceRoot(null)).toBeNull()
})
it('clears from the captured surface and tolerates a missing one', () => {
const { publisher, root } = surface()
setSurfaceVar(publisher, COMPOSER_HEIGHT_VAR, '176px')
clearSurfaceVar(root, COMPOSER_HEIGHT_VAR)
expect(root.style.getPropertyValue(COMPOSER_HEIGHT_VAR)).toBe('')
expect(() => clearSurfaceVar(null, COMPOSER_HEIGHT_VAR)).not.toThrow()
expect(document.documentElement.style.getPropertyValue(COMPOSER_HEIGHT_VAR)).toBe('')
})
})
+22 -11
View File
@@ -14,27 +14,38 @@
* read its own measurement through normal CSS inheritance. Remeasuring on
* session switch cannot fix this — several surfaces are visible at once, so
* there is no single correct value to remeasure to.
*
* A measurement is only ever meaningful to the surface that owns it, so an
* unowned node has nowhere to publish to and must publish NOWHERE. Writing to
* the document root instead is not a graceful fallback: `:root` holds the
* defaults every surface inherits until it publishes its own, so one stale
* write there is a global floor under every thread's bottom clearance, on
* every tab, until reload. That is the "randomly huge gap at the bottom of the
* thread" bug — see surface-vars.test.ts.
*/
export const COMPOSER_HEIGHT_VAR = '--composer-measured-height'
export const COMPOSER_SURFACE_HEIGHT_VAR = '--composer-surface-measured-height'
export const STATUS_STACK_VAR = '--status-stack-measured-height'
/**
* The surface owning `el`, falling back to the document root so a composer
* rendered outside a chat surface (the popped-out window) keeps behaving
* exactly as before.
* The surface owning `el`, or null when `el` is detached or outside one.
*
* Null is the honest answer, not a failure to be papered over. Every publisher
* lives inside a `[data-chat-surface]` for its whole visible life (a
* popped-out composer is `position: fixed`, still a descendant), so the only
* way to miss is a node React already removed — whose measurement is stale by
* definition.
*/
export function chatSurfaceRoot(el: Element | null): HTMLElement {
return el?.closest<HTMLElement>('[data-chat-surface]') ?? document.documentElement
export function chatSurfaceRoot(el: Element | null): HTMLElement | null {
return el?.closest<HTMLElement>('[data-chat-surface]') ?? null
}
/** Publish a measured-height var on the surface owning `el`. */
/** Publish a measured-height var on the surface owning `el`. No owner, no write. */
export function setSurfaceVar(el: Element | null, name: string, value: string): void {
chatSurfaceRoot(el).style.setProperty(name, value)
chatSurfaceRoot(el)?.style.setProperty(name, value)
}
/** Clear a measured-height var from the surface owning `el`. */
export function clearSurfaceVar(el: Element | null, name: string): void {
chatSurfaceRoot(el).style.removeProperty(name)
/** Clear a measured-height var from `root`. */
export function clearSurfaceVar(root: HTMLElement | null, name: string): void {
root?.style.removeProperty(name)
}
@@ -18,6 +18,16 @@ export interface PaletteContribution {
icon?: IconComponent
keywords?: string[]
run: () => void
/**
* Short note after the label — the live state the row acts on. A function
* because contributions register once at boot while that state keeps moving;
* the palette re-reads it on open.
*/
detail?: () => string
/** `state` when running the row CHANGES what `detail` says. */
detailVariant?: 'muted' | 'state'
/** Leave the palette open after running — for rows you may run repeatedly. */
keepOpen?: boolean
}
/** Contributed palette rows, with stable render keys. */
@@ -26,3 +36,31 @@ export function usePaletteContributions(): Array<PaletteContribution & { key: st
.map(c => ({ key: `${c.source ?? 'core'}:${c.id}`, ...(c.data as PaletteContribution) }))
.filter(item => Boolean(item.label && item.run))
}
/**
* A binary setting as one palette row: `Toggle status bar` trailed by the live
* state. The verb says what the row does, the note says where it stands —
* neither alone is enough to act on.
*
* Rows keep the palette open: flipping a setting is the kind of thing you do
* two or three of in a row, and the note updating in place is the receipt.
*/
export function paletteToggle(
spec: Omit<PaletteContribution, 'detail' | 'detailVariant' | 'keepOpen' | 'run'> & {
get: () => boolean
set: (enabled: boolean) => void
}
) {
const { get, keywords = [], set, ...rest } = spec
const data: PaletteContribution = {
...rest,
detail: () => (get() ? 'on' : 'off'),
detailVariant: 'state',
keepOpen: true,
keywords: [...keywords, 'on', 'off', 'enable', 'disable'],
run: () => set(!get())
}
return { id: data.id, area: PALETTE_AREA, data }
}
+424 -156
View File
@@ -1,12 +1,22 @@
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { Dialog as DialogPrimitive } from 'radix-ui'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { memo, useCallback, useDeferredValue, 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'
import {
HUD_HEADING,
HUD_ITEM,
HUD_NOTE,
HUD_NOTE_VARIANT,
HUD_POSITION,
HUD_SURFACE,
HUD_TEXT
} from '@/app/floating-hud'
import { setTerminalTakeover } from '@/app/right-sidebar/store'
import { codiconIcon } from '@/components/ui/codicon'
import { Command, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { HighlightMatches } from '@/components/ui/highlight-matches'
import { KbdCombo } from '@/components/ui/kbd'
import { getHermesConfigRecord, listAllProfileSessions } from '@/hermes'
import { useI18n } from '@/i18n'
@@ -58,8 +68,9 @@ import {
setCommandPaletteOpen
} from '@/store/command-palette'
import { $bindings } from '@/store/keybinds'
import { $dismissedAutoProjectIds, filterVisibleProjects } from '@/store/layout'
import { openPetGenerate } from '@/store/pet-generate'
import { requestStartWorkSession } from '@/store/projects'
import { $projectTree, goToProject, openFolderAsProject, requestStartWorkSession } from '@/store/projects'
import { $connection } from '@/store/session'
import { runGatewayRestart } from '@/store/system-actions'
import {
@@ -102,14 +113,20 @@ interface PaletteItem {
action?: string
/** Renders a trailing check: this row IS the current setting (theme, mode). */
active?: boolean
/** Muted text beside the label — state the row acts on (a version, a count). */
/** Static trailing combo hint for a modifier-variant select (e.g. `mod+enter`). */
comboHint?: string
/** Short note beside the label — state the row acts on (a version, a count). */
detail?: string
/** `state` when the row will change what `detail` says (a toggle's on/off). */
detailVariant?: keyof typeof HUD_NOTE_VARIANT
icon: IconComponent
id: string
/** Keep the palette open after running (live-preview pickers like theme/mode). */
keepOpen?: boolean
keywords?: string[]
label: string
/** Label shown while ⌘/⌃ is held — previews the modifier-variant action. */
modLabel?: string
/**
* When set, ⌘/⌃-select (or ⌘-Enter) opens a new tab and ⇧⌘-select pops a
* window — matching sidebar session rows. Plain select stays in-place.
@@ -229,19 +246,92 @@ const rankGroups = (groups: PaletteGroup[], search: string): PaletteGroup[] => {
// theme lists under both Light and Dark). The id suffix disambiguates.
const paletteValue = (item: PaletteItem): string => `${item.label}\u0001${item.id}`
const EMPTY_GROUPS: PaletteGroup[] = []
// Backstop only. The palette normally retires on the content's real
// `animationend`, so the CSS owns the close duration; this just guarantees the
// body can't stay mounted forever somewhere animations never run (jsdom,
// `animation: none`). Deliberately longer than any plausible exit so it never
// races the real signal and truncates the fade.
const EXIT_FALLBACK_MS = 1000
/**
* The palette's row list, split out so an OPENING palette paints before it
* renders rows. This component mounts with the portal, so `useDeferredValue`'s
* initial value applies per open: the first commit is the frame + input
* (instant), and the several-hundred-row list arrives in an interruptible
* follow-up render. Opening ⌘K must never wait on building the list.
*/
const PaletteGroups = memo(function PaletteGroups({
bindings,
groups,
modHeld,
noResultsLabel,
onSelectItem,
onSelectMods,
search
}: {
bindings: Record<string, string[]>
groups: PaletteGroup[]
modHeld: boolean
noResultsLabel: string
onSelectItem: (item: PaletteItem) => void
onSelectMods: (event: { ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }) => void
search: string
}) {
const deferred = useDeferredValue(groups, EMPTY_GROUPS)
// While the rows are still catching up, an empty list means "not rendered
// yet", not "nothing matched" — don't flash the empty state on open.
const pending = deferred !== groups
return (
<>
{/* Filtering happens in rankGroups, so cmdk's own CommandEmpty
(keyed to its internal filter count) would never fire. */}
{deferred.length === 0 && !pending && (
<div className="py-6 text-center text-sm text-muted-foreground">{noResultsLabel}</div>
)}
{deferred.map((group, index) => (
<CommandGroup className={HUD_HEADING} heading={group.heading} key={group.heading ?? `palette-group-${index}`}>
{group.items.map(item => (
<PaletteRow
bindings={bindings}
item={item}
key={item.id}
modHeld={modHeld}
onSelectItem={onSelectItem}
onSelectMods={onSelectMods}
search={search}
/>
))}
</CommandGroup>
))}
</>
)
})
const PaletteRow = memo(function PaletteRow({
bindings,
item,
modHeld,
onSelectMods,
onSelectItem
onSelectItem,
search
}: {
bindings: Record<string, string[]>
item: PaletteItem
modHeld: boolean
onSelectMods: (event: { ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }) => void
onSelectItem: (item: PaletteItem) => void
search: string
}) {
const Icon = item.icon
const combo = item.action ? bindings[item.action]?.[0] : undefined
// The row's live keybind, else a static modifier-variant hint (⌘↵). One slot,
// so every downstream `ml-auto` fallback below keeps working unchanged.
const combo = (item.action ? bindings[item.action]?.[0] : undefined) ?? item.comboHint
// While ⌘/⌃ is held, a row with a modifier variant previews it: the label
// swaps to the variant's copy so Enter reads as what it will actually do.
const modPreview = modHeld && Boolean(item.modLabel)
return (
<CommandItem
@@ -252,9 +342,21 @@ const PaletteRow = memo(function PaletteRow({
value={paletteValue(item)}
>
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{item.label}</span>
{item.detail && <span className="truncate text-muted-foreground/80">{item.detail}</span>}
{combo && <KbdCombo className="ml-auto opacity-55" combo={combo} size="sm" />}
<span className={cn('truncate', modPreview && 'text-muted-foreground/80')}>
{modPreview ? (
item.modLabel
) : (
/* Same per-term split as scoreItem's AND matcher, so the emphasis
shows exactly which words earned the row its rank. */
<HighlightMatches query={search.split(/\s+/)} text={item.label} />
)}
</span>
{item.detail && (
<span className={cn(HUD_NOTE, HUD_NOTE_VARIANT[item.detailVariant ?? 'muted'])}>{item.detail}</span>
)}
{combo && (
<KbdCombo className={cn('ml-auto', modPreview ? 'opacity-90' : 'opacity-55')} combo={combo} size="sm" />
)}
{item.to && <ChevronRight className={cn('size-3.5 shrink-0 text-muted-foreground/70', !combo && 'ml-auto')} />}
{item.active && <Check className={cn('size-3.5 shrink-0 text-primary', !combo && !item.to && 'ml-auto')} />}
</CommandItem>
@@ -265,6 +367,12 @@ const PaletteRow = memo(function PaletteRow({
// "Go to session ‹id›" jump for ids that aren't in the recent-200 list.
const SESSION_ID_RE = /^\d{8}_\d{6}_[a-f0-9]{6}$/
// A typed/pasted folder path: absolute (`/…`) or a Windows drive (`C:\…`).
// Deliberately NOT `~/…`: the upsert's membership check (projectIdForCwd)
// compares literal strings against the tree's absolute paths, so an unexpanded
// home path would always miss and double-create.
const FOLDER_PATH_RE = /^(\/|[A-Za-z]:[/\\]).+/
type SessionRow = Awaited<ReturnType<typeof listAllProfileSessions>>['sessions'][number]
const toSessionEntry = (session: SessionRow): SessionEntry => ({
@@ -346,12 +454,73 @@ function themeSupportsMode(name: string, target: 'light' | 'dark'): boolean {
return target === 'dark' ? luminance(background) <= 0.5 : luminance(background) > 0.5
}
/**
* ⌘K is an overlay that is stateful to itself: pressing it must open a frame
* immediately, and must not be held up by whatever else the shell is doing. So
* the mounted cost of a CLOSED palette is one store subscription and nothing
* else.
*
* Everything expensive — a dozen store subscriptions (connection, update
* status/apply, keybinds, worktrees, projects, theme, i18n), three server
* queries, and the group builders that assemble a few hundred rows — lives in
* `CommandPaletteBody`, which only exists while the palette is on screen.
* Before this split those hooks ran on every render of the always-mounted
* component: an in-flight update rewrote `$updateApply` per progress line and
* rebuilt the entire row set each time, for a surface nobody could see.
*
* `mounted` lags `open` by the close animation rather than tracking it exactly.
* Unmounting the body the instant `open` flips false would rip the content out
* of the tree before Radix could play `data-[state=closed]`, so the overlay
* would vanish instead of closing. The body reports its own exit via
* `onExited` (the content's real `animationend`), so nothing here has to know
* how long that animation is — the CSS owns the duration.
*
* The `openCount` key remounts the body per open, which is what lets local
* search/sub-page state reset without a close effect.
*/
export function CommandPalette() {
const { t } = useI18n()
const open = useStore($commandPaletteOpen)
const [mounted, setMounted] = useState(open)
const [openCount, setOpenCount] = useState(0)
const retire = useCallback(() => {
// Only retire the body if the palette is still closed — a reopen mid-fade
// must not unmount the fresh instance.
if (!$commandPaletteOpen.get()) {
setMounted(false)
}
}, [])
useEffect(() => {
if (open) {
setOpenCount(count => count + 1)
setMounted(true)
return
}
// Safety net for environments where the exit animation never runs (jsdom,
// `animation: none`), so the body can't be stranded mounted. The real
// unmount is `onExited` below; whichever fires first wins.
const timer = setTimeout(retire, EXIT_FALLBACK_MS)
return () => clearTimeout(timer)
}, [open, retire])
return (
<DialogPrimitive.Root onOpenChange={setCommandPaletteOpen} open={open}>
{mounted && <CommandPaletteBody key={openCount} onExited={retire} />}
</DialogPrimitive.Root>
)
}
function CommandPaletteBody({ onExited }: { onExited: () => void }) {
const { t } = useI18n()
const pendingPage = useStore($commandPalettePage)
const bindings = useStore($bindings)
const worktrees = useStore($repoWorktrees)
const projectTree = useStore($projectTree)
const dismissedAutoProjects = useStore($dismissedAutoProjectIds)
const navigate = useNavigate()
const { availableThemes, mode, resolvedMode, setMode, setTheme, themeName } = useTheme()
const [search, setSearch] = useState('')
@@ -402,24 +571,44 @@ export function CommandPalette() {
}
}
// Server-backed sources for the type-to-search groups, fetched lazily while
// the palette is open. react-query handles caching/dedup/staleness.
// Live ⌘/⌃-held state while the palette is open: rows with a modifier
// variant (projects) preview it by swapping their label. Window-level
// listeners because focus sits in the search input; blur clears so a
// ⌘-Tab away doesn't strand the preview on.
const [modHeld, setModHeld] = useState(false)
useEffect(() => {
const sync = (event: KeyboardEvent) => setModHeld(event.metaKey || event.ctrlKey)
const clear = () => setModHeld(false)
window.addEventListener('keydown', sync, { capture: true })
window.addEventListener('keyup', sync, { capture: true })
window.addEventListener('blur', clear)
return () => {
window.removeEventListener('keydown', sync, { capture: true })
window.removeEventListener('keyup', sync, { capture: true })
window.removeEventListener('blur', clear)
}
}, [])
// Server-backed sources for the type-to-search groups. This component only
// exists while the palette is open, so the queries are inherently lazy — no
// `enabled` gate needed. react-query handles caching/dedup/staleness, so a
// reopen paints from cache and revalidates in the background.
const configQuery = useQuery({
queryKey: ['command-palette', 'config'],
queryFn: getHermesConfigRecord,
enabled: open
queryFn: getHermesConfigRecord
})
const sessionsQuery = useQuery({
queryKey: ['command-palette', 'sessions'],
queryFn: () => listAllProfileSessions(200, 1, 'exclude'),
enabled: open
queryFn: () => listAllProfileSessions(200, 1, 'exclude')
})
const archivedQuery = useQuery({
queryKey: ['command-palette', 'archived'],
queryFn: () => listAllProfileSessions(200, 0, 'only'),
enabled: open
queryFn: () => listAllProfileSessions(200, 0, 'only')
})
const mcpServers = useMemo(() => {
@@ -433,29 +622,26 @@ export function CommandPalette() {
const sessions = useMemo(() => (sessionsQuery.data?.sessions ?? []).map(toSessionEntry), [sessionsQuery.data])
const archivedSessions = useMemo(() => (archivedQuery.data?.sessions ?? []).map(toSessionEntry), [archivedQuery.data])
// Reset the query/sub-page on close so it reopens clean.
useEffect(() => {
if (!open) {
setSearch('')
setPage(null)
}
}, [open])
// Search/sub-page are local to a mount, and this component remounts per open
// (keyed by open count), so each open starts clean without a reset effect.
// Deep-link into a nested page (e.g. `/pet list` → pets picker).
useEffect(() => {
if (open && pendingPage) {
if (pendingPage) {
setPage(pendingPage)
$commandPalettePage.set(null)
}
}, [open, pendingPage])
}, [pendingPage])
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.
// Sessions: plain select = open beside what's already loaded (focus existing
// tile/main, else a new tab — main only when it's a blank draft);
// ⌘/⌃-select / ⌘-Enter = force a new tab; ⇧⌘ = own window. Same door as the
// sidebar, minus the sidebar's licence to spend main.
const goSession = useCallback(
(sessionId: string) => (event?: { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean }) => {
openSession(sessionId, navigate, openSessionIntentFromModifiers(event))
openSession(sessionId, navigate, openSessionIntentFromModifiers(event, 'stack'))
},
[navigate]
)
@@ -480,21 +666,23 @@ export function CommandPalette() {
[t.settings.fieldLabels]
)
// Running a keepOpen row (a toggle) changes state the rows themselves report,
// so the groups have to rebuild without the palette closing. A counter keeps
// that generic — the palette never learns which stores its contributions read.
const [selectTick, setSelectTick] = useState(0)
const contributedItems = usePaletteContributions()
const baseGroups = useMemo<PaletteGroup[]>(() => {
const settingsTab = (tab: string) => `${SETTINGS_ROUTE}?tab=${tab}`
const cc = t.commandCenter
// The active repo's worktrees → "new conversation in <branch>". This is the
// ⌘K-typed "I want to work on <branch>" reflex: each entry seeds a fresh
// session anchored to that worktree's checkout (requestStartWorkSession),
// so git is the source of truth and edits land in the right tree.
const branchGroup: PaletteGroup[] =
// The active repo's worktrees → "new conversation in <branch>". This is the
// ⌘K-typed "I want to work on <branch>" reflex: each entry seeds a fresh
// session anchored to that worktree's checkout (requestStartWorkSession),
// so git is the source of truth and edits land in the right tree.
const branchGroup = useMemo<PaletteGroup[]>(
() =>
worktrees.length > 0
? [
{
heading: cc.branches,
heading: t.commandCenter.branches,
items: worktrees.map(wt => {
const name = wt.branch?.trim() || wt.path.split('/').pop() || wt.path
@@ -502,14 +690,54 @@ export function CommandPalette() {
icon: GitBranch,
id: `worktree-${wt.path}`,
keywords: ['branch', 'worktree', 'switch', name, wt.path],
label: cc.startInBranch(name),
label: t.commandCenter.startInBranch(name),
run: () => requestStartWorkSession(wt.path)
}
})
}
]
: []
: [],
[t, worktrees]
)
const baseGroups = useMemo<PaletteGroup[]>(() => {
const settingsTab = (tab: string) => `${SETTINGS_ROUTE}?tab=${tab}`
const cc = t.commandCenter
// Projects are the primary way the desktop scopes work, so they're jumpable
// from the palette. Plain select is a pure scope switch (sidebar enters the
// project — never spends main); ⌘-Enter / ⌘-click also starts a new session
// at the project root (stacked as a tab when main holds a chat), previewed
// by the label swap while ⌘ is held. Rows carry the project's own codicon,
// matching the sidebar. The pinned "Open folder…" row is the ⌘O upsert.
const projectGroup: PaletteGroup = {
heading: cc.projects,
items: [
{
action: 'workspace.openFolder',
icon: codiconIcon('folder-opened'),
id: 'project-open-folder',
keywords: ['open', 'folder', 'directory', 'project', 'add', 'import', 'workspace'],
label: cc.openFolder,
run: () => void openFolderAsProject()
},
...filterVisibleProjects(projectTree, dismissedAutoProjects).map(project => ({
comboHint: 'mod+enter',
icon: codiconIcon(project.icon || (project.isNoProject ? 'home' : 'folder-library')),
id: `project-${project.id}`,
keywords: ['project', 'workspace', 'go to', project.label, ...(project.path ? [project.path] : [])],
label: project.label,
modLabel: cc.newSessionInProject(project.label),
runWithEvent: (event?: { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean }) =>
goToProject(project.id, { newSession: Boolean(event?.metaKey || event?.ctrlKey) })
}))
]
}
// Group order is the tiebreaker rankGroups falls back on (stable sort), and
// exact ties are the common case — "yolo" hits both "Toggle yolo" and a
// worktree named bb/yolo-* as a whole word. So this order IS the priority:
// where you're going, then what you can do, then what you can configure.
return [
{
heading: cc.goTo,
@@ -590,7 +818,29 @@ export function CommandPalette() {
}
]
},
...branchGroup,
projectGroup,
// Registry-contributed rows (core features + plugins) — one group,
// omitted while nothing contributes.
...(contributedItems.length > 0
? [
{
heading: cc.commands,
items: contributedItems.map(item => ({
action: item.action,
// Read on mount and after every select (the deps below), so a
// row that reports state can't show the state it just left.
detail: item.detail?.(),
detailVariant: item.detailVariant,
icon: item.icon ?? Zap,
id: item.key,
keepOpen: item.keepOpen,
keywords: item.keywords,
label: item.label,
run: item.run
}))
}
]
: []),
{
heading: cc.commandCenter,
items: [
@@ -686,26 +936,22 @@ export function CommandPalette() {
run: go(settingsTab(entry.tab))
}))
]
},
// Registry-contributed rows (core features + plugins) — one group,
// omitted while nothing contributes.
...(contributedItems.length > 0
? [
{
heading: cc.commands,
items: contributedItems.map(item => ({
action: item.action,
icon: item.icon ?? Zap,
id: item.key,
keywords: item.keywords,
label: item.label,
run: item.run
}))
}
]
: [])
}
]
}, [contributedItems, go, settingsSectionLabel, t, updateVersionLabel, worktrees])
// `selectTick` is a deliberate re-read trigger, not a value: rows report
// live state through `detail()`, so the groups must rebuild after a select
// that kept the palette open — eslint only sees an unused dep.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
contributedItems,
dismissedAutoProjects,
go,
projectTree,
selectTick,
settingsSectionLabel,
t,
updateVersionLabel
])
// The long, granular lists (settings fields, API keys, MCP servers, archived
// chats) only surface once the user types — otherwise they'd bury the
@@ -735,6 +981,23 @@ export function CommandPalette() {
})
}
// Paste/type an absolute folder path → open it as a project directly (the
// ⌘O upsert without the native picker). Same reflex as the raw-session-id
// row above.
if (FOLDER_PATH_RE.test(directId)) {
result.push({
items: [
{
icon: codiconIcon('folder-opened'),
id: `open-folder-${directId}`,
keywords: ['open', 'folder', 'project', directId],
label: t.commandCenter.openFolderAt(directId),
run: () => void openFolderAsProject(directId)
}
]
})
}
// Deep-link straight to a Capabilities sub-tab. The root "Go to" entry only
// lands on the top-level Skills view; typing "mcp"/"tools"/"skills" should
// jump to the exact tab (matches the "not just the top lvl" ask).
@@ -885,7 +1148,14 @@ export function CommandPalette() {
themeName
])
const groups = useMemo(() => [...baseGroups, ...searchGroups], [baseGroups, searchGroups])
// Branch rows rank below BOTH the fixed groups and the typed-only lists: they
// scale with whatever worktrees happen to exist, so on a tie they're the least
// likely thing meant. Everything above is either always-present chrome or a
// list the search itself asked for.
const groups = useMemo(
() => [...baseGroups, ...searchGroups, ...branchGroup],
[baseGroups, branchGroup, searchGroups]
)
// Nested palette pages (VS Code-style submenus). Reusable: add an entry here
// and point a root item at it via `to`.
@@ -988,104 +1258,102 @@ export function CommandPalette() {
if (!item.keepOpen) {
closeCommandPalette()
return
}
// Staying open means the rows are still on screen — re-read anything they
// report (a toggle's on/off) so the note isn't showing the previous state.
setSelectTick(tick => tick + 1)
}
return (
<DialogPrimitive.Root onOpenChange={setCommandPaletteOpen} open={open}>
<DialogPrimitive.Portal>
{/* Transparent overlay: keeps click-away + focus trap, but no dim/blur. */}
<DialogPrimitive.Overlay className="fixed inset-0 z-(--z-over-modal)" />
<DialogPrimitive.Content
aria-describedby={undefined}
className={cn(
HUD_POSITION,
HUD_SURFACE,
'z-(--z-over-modal-content) w-[min(34rem,calc(100vw-2rem))] overflow-hidden duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95'
<DialogPrimitive.Portal>
{/* Transparent overlay: keeps click-away + focus trap, but no dim/blur. */}
<DialogPrimitive.Overlay className="fixed inset-0 z-(--z-over-modal)" />
<DialogPrimitive.Content
aria-describedby={undefined}
className={cn(
HUD_POSITION,
HUD_SURFACE,
'z-(--z-over-modal-content) w-[min(34rem,calc(100vw-2rem))] overflow-hidden duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95'
)}
// The close animation finishing is what retires this whole subtree —
// the CSS owns the duration, not a hardcoded timer. Guarded on the
// content itself (descendants animate too) and on the closed state, so
// an OPEN animation never unmounts the palette we just opened.
onAnimationEnd={event => {
if (event.target === event.currentTarget && event.currentTarget.dataset.state === 'closed') {
onExited()
}
}}
>
<DialogPrimitive.Title className="sr-only">{t.commandCenter.paletteTitle}</DialogPrimitive.Title>
<Command className="bg-transparent" loop shouldFilter={false}>
{activePage && (
<button
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:text-foreground"
onClick={goBack}
type="button"
>
<ChevronLeft className="size-3.5" />
<span>{t.commandCenter.back}</span>
<span className="text-muted-foreground/50">/</span>
<span className="font-medium text-foreground">{activePage.title}</span>
</button>
)}
>
<DialogPrimitive.Title className="sr-only">{t.commandCenter.paletteTitle}</DialogPrimitive.Title>
<Command className="bg-transparent" loop shouldFilter={false}>
{activePage && (
<button
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:text-foreground"
onClick={goBack}
type="button"
>
<ChevronLeft className="size-3.5" />
<span>{t.commandCenter.back}</span>
<span className="text-muted-foreground/50">/</span>
<span className="font-medium text-foreground">{activePage.title}</span>
</button>
<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
}
// In a submenu: Esc and empty-input Backspace step back out
// instead of closing the whole palette.
if (event.key === 'Escape' || (event.key === 'Backspace' && search === '')) {
event.preventDefault()
event.stopPropagation()
goBack()
return
}
}}
onValueChange={setSearch}
placeholder={placeholder}
right={page === 'pets' ? <PetInlineToggle /> : undefined}
value={search}
/>
<CommandList className="dt-portal-scrollbar max-h-[min(20rem,56vh)]">
{/* Server-driven pages render their own list; the rest show groups. */}
{page === 'pets' ? (
<PetPalettePage
onGenerate={() => {
closeCommandPalette()
openPetGenerate()
}}
search={search}
/>
) : page === 'install-theme' ? (
<MarketplaceThemePage onPickTheme={setTheme} search={search} />
) : (
<PaletteGroups
bindings={bindings}
groups={visibleGroups}
modHeld={modHeld}
noResultsLabel={t.commandCenter.noResults}
onSelectItem={handleSelect}
onSelectMods={noteSelectMods}
search={search}
/>
)}
<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
}
// In a submenu: Esc and empty-input Backspace step back out
// instead of closing the whole palette.
if (event.key === 'Escape' || (event.key === 'Backspace' && search === '')) {
event.preventDefault()
event.stopPropagation()
goBack()
return
}
}}
onValueChange={setSearch}
placeholder={placeholder}
right={page === 'pets' ? <PetInlineToggle /> : undefined}
value={search}
/>
<CommandList className="dt-portal-scrollbar max-h-[min(20rem,56vh)]">
{/* Server-driven pages render their own list; the rest show groups. */}
{page === 'pets' ? (
<PetPalettePage
onGenerate={() => {
closeCommandPalette()
openPetGenerate()
}}
search={search}
/>
) : page === 'install-theme' ? (
<MarketplaceThemePage onPickTheme={setTheme} search={search} />
) : (
<>
{/* Filtering happens in rankGroups, so cmdk's own CommandEmpty
(keyed to its internal filter count) would never fire. */}
{visibleGroups.length === 0 && (
<div className="py-6 text-center text-sm text-muted-foreground">{t.commandCenter.noResults}</div>
)}
{visibleGroups.map((group, index) => (
<CommandGroup
className={HUD_HEADING}
heading={group.heading}
key={group.heading ?? `palette-group-${index}`}
>
{group.items.map(item => (
<PaletteRow
bindings={bindings}
item={item}
key={item.id}
onSelectItem={handleSelect}
onSelectMods={noteSelectMods}
/>
))}
</CommandGroup>
))}
</>
)}
</CommandList>
</Command>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
</CommandList>
</Command>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
)
}
@@ -173,7 +173,7 @@ export function PetInlineToggle() {
const toggle = () => {
void setPetEnabled(requestGateway, !enabled, {
noneAvailable: copy.noneAvailable,
fallback: copy.toggleFailed
fallback: copy.toggleFailed(!enabled)
}).then(ok => ok && triggerHaptic('crisp'))
}
+43 -48
View File
@@ -4,10 +4,10 @@ import type { CSSProperties, ReactElement, PointerEvent as ReactPointerEvent } f
import { PREVIEW_RAIL_MAX_WIDTH, PREVIEW_RAIL_MIN_WIDTH } from '@/app/chat/right-rail'
import { SessionStatusDot } from '@/app/chat/session-status-dot'
import { PALETTE_AREA, type PaletteContribution } from '@/app/command-palette/contrib'
import { PALETTE_AREA, type PaletteContribution, paletteToggle } from '@/app/command-palette/contrib'
import { type StatusbarItem } from '@/app/shell/statusbar-controls'
import { IdleMount } from '@/components/idle-mount'
import { toggleLayoutEditMode } from '@/components/pane-shell/edit-mode'
import { $layoutEditMode, toggleLayoutEditMode } from '@/components/pane-shell/edit-mode'
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'
@@ -27,8 +27,6 @@ import {
revealTreePane,
setPaneCollapsed,
setTreePaneHidden,
setTreeSideCollapsed,
treeSideOfPane,
watchContributedPanes
} from '@/components/pane-shell/tree/store'
import { SidebarProvider } from '@/components/ui/sidebar'
@@ -38,9 +36,10 @@ import { useContributions } from '@/contrib/react/use-contributions'
import { registry } from '@/contrib/registry'
import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
import { sessionTitle as storedSessionTitle } from '@/lib/chat-runtime'
import { LayoutDashboard, PanelBottom } from '@/lib/icons'
import { FileText, LayoutDashboard, PanelBottom, Zap } from '@/lib/icons'
import { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions'
import { Codecs, persistentAtom } from '@/lib/persisted'
import { setYoloEnabled } from '@/lib/yolo-session'
import { pruneComposerPopoutZones } from '@/store/composer-popout'
import {
$fileBrowserOpen,
@@ -56,9 +55,9 @@ import {
} from '@/store/layout'
import { $previewOpenRequest, $previewTabs, closeRightRail } from '@/store/preview'
import { $reviewOpen, closeReview, REVIEW_PANE_ID } from '@/store/review'
import { $currentCwd, $selectedStoredSessionId, $sessions, sessionMatchesStoredId } from '@/store/session'
import { $currentCwd, $selectedStoredSessionId, $sessions, $yoloActive, sessionMatchesStoredId } from '@/store/session'
import { watchSessionPins } from '@/store/session-pin-sync'
import { $statusbarVisible, toggleStatusbarVisible } from '@/store/statusbar-prefs'
import { $statusbarVisible } from '@/store/statusbar-prefs'
import type { SessionDragPayload } from '../chat/composer/inline-refs'
import { watchRouteTiles } from '../chat/route-tile'
@@ -267,18 +266,15 @@ registry.registerMany([
run: toggleLayoutEditMode
} satisfies KeybindContribution
},
{
paletteToggle({
id: 'layout.editMode',
area: PALETTE_AREA,
data: {
id: 'layout.editMode',
label: 'Toggle layout edit mode',
action: 'layout.editMode',
icon: LayoutDashboard,
keywords: ['layout', 'zones', 'panes', 'edit', 'rearrange'],
run: toggleLayoutEditMode
} satisfies PaletteContribution
},
label: 'Toggle layout edit mode',
action: 'layout.editMode',
icon: LayoutDashboard,
keywords: ['layout', 'zones', 'panes', 'edit', 'rearrange'],
get: () => $layoutEditMode.get(),
set: enabled => $layoutEditMode.set(enabled)
}),
// The agent's write -> see loop: rescan <hermes home>/desktop-plugins
// without relaunching (same-id reloads dispose the previous incarnation).
{
@@ -304,18 +300,15 @@ registry.registerMany([
},
// Hiding the bar removes the surface that would otherwise offer it back, so
// ⌘K is the guaranteed door in (alongside the rebindable ⌘⇧S).
{
paletteToggle({
id: 'view.toggleStatusbar',
area: PALETTE_AREA,
data: {
id: 'view.toggleStatusbar',
label: 'Toggle status bar',
action: 'view.toggleStatusbar',
icon: PanelBottom,
keywords: ['status bar', 'statusbar', 'bottom bar', 'hide', 'show', 'chrome'],
run: toggleStatusbarVisible
} satisfies PaletteContribution
},
label: 'Toggle status bar',
action: 'view.toggleStatusbar',
icon: PanelBottom,
keywords: ['status bar', 'statusbar', 'bottom bar', 'hide', 'show', 'chrome'],
get: () => $statusbarVisible.get(),
set: enabled => $statusbarVisible.set(enabled)
}),
// The keybind panel's non-titlebar door (the keyboard icon is gone).
{
id: 'keybinds.panel',
@@ -599,16 +592,30 @@ bindPaneCollapse(
() => $logsOpen.set(false),
() => $logsOpen.set(true)
)
registry.register({
id: 'logs.toggle',
area: PALETTE_AREA,
data: {
registry.register(
paletteToggle({
id: 'logs.toggle',
label: 'Toggle logs',
icon: FileText,
keywords: ['logs', 'agent log', 'tail', 'debug'],
run: () => $logsOpen.set(!$logsOpen.get())
} satisfies PaletteContribution
})
get: () => $logsOpen.get(),
set: enabled => $logsOpen.set(enabled)
})
)
// YOLO (dangerous-command approval bypass) is a status-bar zap and a /yolo
// command; ⌘K is the third door onto the SAME store function, so a user who
// lives in the palette never has to hunt for the pill.
registry.register(
paletteToggle({
id: 'session.yolo',
label: 'Toggle yolo',
icon: Zap,
keywords: ['yolo', 'approvals', 'auto-approve', 'bypass', 'dangerous', 'commands'],
get: () => $yoloActive.get(),
set: enabled => void setYoloEnabled(enabled).catch(() => undefined)
})
)
// Sessions/files Close = collapse their SIDE (⌘B/⌘J truthful, titlebar button
// flips back) — but only while the pane actually lives in that root side
@@ -629,18 +636,6 @@ registerPaneCloser('files', () =>
// the side, unhide, front — a NEW target while already visible still fronts.
const revealPreview = () => {
dockPaneBeside('preview', 'files')
// The preview shares a collapsible column with the file tree, and
// revealTreePane un-collapses a column through its bound store — here ⌘J /
// $fileBrowserOpen, which IS the tree's toggle. Going through it would open
// the tree every time a preview opened. Un-collapse the column directly and
// leave the toggle alone, so a preview can appear on its own.
const side = treeSideOfPane('preview')
if (side) {
setTreeSideCollapsed(side, false)
}
revealTreePane('preview')
}
@@ -5,6 +5,7 @@ import { openSession } from '@/app/open-session'
import { storedSessionIdForNotification } from '@/lib/session-ids'
import { respondToApprovalAction } from '@/store/native-notifications'
import { $activeGatewayProfile } from '@/store/profile'
import { openFolderAsProject } from '@/store/projects'
import {
$sessions,
getRememberedRoute,
@@ -128,12 +129,14 @@ export function useDesktopIntegrations({
}, [resumeExhaustedSessionId])
// 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.
// tile / main), else beside what's loaded rather than over it — the click
// came from outside the app and shouldn't cost the user the chat they left
// on screen. 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) {
openSession(storedSessionIdForNotification(sessionId, runtimeIdByStoredSessionId.current), navigate)
openSession(storedSessionIdForNotification(sessionId, runtimeIdByStoredSessionId.current), navigate, 'stack')
}
})
@@ -185,6 +188,13 @@ export function useDesktopIntegrations({
return () => unsubscribe?.()
}, [navigate])
// File > Open Folder… — same open-folder-as-project upsert as the ⌘O keybind.
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onOpenFolderRequested?.(() => void openFolderAsProject())
return () => unsubscribe?.()
}, [])
// Another window mutated the shared session list -> re-pull the sidebar.
useEffect(() => {
if (isSecondaryWindow()) {
+26 -7
View File
@@ -20,7 +20,7 @@ import { FindBar } from '@/components/find-bar'
import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay'
import { NotificationStack } from '@/components/notifications'
import { DesktopOnboardingOverlay } from '@/components/onboarding'
import { $newSessionTabAction } from '@/components/pane-shell/tree/store'
import { $newSessionTabAction, registerPaneCloser } from '@/components/pane-shell/tree/store'
import { FloatingPet } from '@/components/pet/floating-pet'
import { RemoteDisplayBanner } from '@/components/remote-display-banner'
import { emitGatewayEvent } from '@/contrib/events'
@@ -68,6 +68,7 @@ import { armWakeWord } from '@/store/wake-word'
import { isSecondaryWindow } from '@/store/windows'
import { useSkinCommand } from '@/themes/use-skin-command'
import { closeWorkspaceTab } from '../chat/close-tab'
import { requestComposerInsert } from '../chat/composer/focus'
import { useComposerActions } from '../chat/hooks/use-composer-actions'
import { CommandPalette } from '../command-palette'
@@ -76,14 +77,21 @@ 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 { mainChatOccupied, 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 { CRON_ROUTE, navigateToWorkspacePage, routeSessionId, SETTINGS_ROUTE, syncWorkspaceRoute } from '../routes'
import {
CRON_ROUTE,
navigateToWorkspacePage,
routeSessionId,
sessionRoute,
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'
@@ -98,7 +106,7 @@ import { useRouteResume } from '../session/hooks/use-route-resume'
import { useSessionActions } from '../session/hooks/use-session-actions'
import { useSessionListActions } from '../session/hooks/use-session-list-actions'
import { useSessionStateCache } from '../session/hooks/use-session-state-cache'
import { newSessionOpensTab, startWorkspaceSession } from '../session/workspace-session-target'
import { startWorkspaceSession } from '../session/workspace-session-target'
import { useOverlayRouting } from '../shell/hooks/use-overlay-routing'
import { useWindowControlsOverlayWidth } from '../shell/hooks/use-window-controls-overlay-width'
import { titlebarControlsPosition } from '../shell/titlebar'
@@ -493,12 +501,12 @@ export function ContribWiring({ children }: { children: ReactNode }) {
// project so the new lane is visible.
//
// `openTab` is the sidebar "+" behavior: once a chat is loaded, stack a new
// tab instead of replacing it (see newSessionOpensTab). The composer's
// tab instead of replacing it (see mainChatOccupied). The composer's
// "branch off into a new worktree" flow keeps the fresh-draft path — it
// prefills the MAIN composer right after, so it has to own that surface.
const startSessionInWorkspace = useCallback(
(path: null | string, options?: { openTab?: boolean }) => {
if (options?.openTab && newSessionOpensTab(activeSessionIdRef.current, $selectedStoredSessionId.get())) {
if (options?.openTab && mainChatOccupied(activeSessionIdRef.current, $selectedStoredSessionId.get())) {
void openNewSessionTile('center', { cwd: path, listed: false })
return
@@ -528,7 +536,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
}
lastStartWorkTokenRef.current = startWorkSessionRequest.token
startSessionInWorkspace(startWorkSessionRequest.path)
startSessionInWorkspace(startWorkSessionRequest.path, { openTab: startWorkSessionRequest.openTab })
if (startWorkSessionRequest.draft) {
requestComposerInsert(startWorkSessionRequest.draft, { target: 'main' })
@@ -819,6 +827,17 @@ export function ContribWiring({ children }: { children: ReactNode }) {
return () => $newSessionTabAction.set(null)
}, [openNewSessionTab])
// The MAIN tab's Close. The workspace pane can't leave the tree, so its
// closer empties it instead: the next stacked session shifts in, else main
// drops to a fresh draft. Registering it here is also what gives the tab its
// close GESTURE (⌘-click / middle-click) — the strip reads the closer, not
// the `uncloseable` flag, so the pane stays undismissable either way.
useEffect(() => {
registerPaneCloser('workspace', () => void closeWorkspaceTab(id => navigate(sessionRoute(id))))
return () => registerPaneCloser('workspace')
}, [navigate])
// The controller's entire callback surface, gathered into the stable
// `actions` bag. `nextActions` is TS-checked against WiringActions each
// render; its fields are copied into the ref object so `actions` keeps one
+20
View File
@@ -31,3 +31,23 @@ export const HUD_ITEM = 'gap-2 px-2 py-1'
// heading via the universal-descendant variant.
export const HUD_HEADING =
'**:[[cmdk-group-heading]]:static **:[[cmdk-group-heading]]:bg-transparent **:[[cmdk-group-heading]]:px-2.5 **:[[cmdk-group-heading]]:pb-1 **:[[cmdk-group-heading]]:pt-2.5 **:[[cmdk-group-heading]]:text-[0.64rem] **:[[cmdk-group-heading]]:font-semibold **:[[cmdk-group-heading]]:uppercase **:[[cmdk-group-heading]]:tracking-[0.16em] **:[[cmdk-group-heading]]:text-(--theme-primary)'
// A short note trailing a row's label — a version, a count, a live state. Sits
// closer than the row's icon-to-label `gap-2` because it reads as a suffix of
// the label, not as another item in the row.
export const HUD_NOTE = '-ml-1'
// `state` marks a note the row will CHANGE (a toggle's on/off) rather than a
// passive fact. It keeps the label's own color so the pair reads as one phrase,
// and earns its separation from a faint underline instead of going muted.
//
// That underline needs room, so `state` skips `truncate`: flex items are
// blockified, which makes its `overflow: hidden` bite, and the app's global
// 0.25rem underline offset (styles.css) already sits at the bottom edge of a
// `text-xs` line box — together they clip the rule away entirely. Pulling the
// offset to 2px keeps it inside the box; dropping truncate costs nothing
// because these notes are one word.
export const HUD_NOTE_VARIANT = {
muted: 'truncate text-muted-foreground/80',
state: 'shrink-0 underline decoration-current/50 underline-offset-2'
} as const
+31 -3
View File
@@ -5,6 +5,7 @@ import { closeActiveTab } from '@/app/chat/close-tab'
import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
import { closeActiveTerminal, createTerminal, cycleTerminal } from '@/app/right-sidebar/terminal/terminals'
import { activateTreeTabSlot, cycleTreeTabInFocusedZone, layoutHasRootSide } from '@/components/pane-shell/tree/store'
import { onReleaseTypingFocus } from '@/components/ui/keyboard-first'
import { findBarClaimsCombo } from '@/lib/find-in-page'
import { contributedKeybindHandler, PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } from '@/lib/keybinds/actions'
import { comboAllowedInInput, comboFromEvent, isEditableTarget } from '@/lib/keybinds/combo'
@@ -33,7 +34,7 @@ import {
switchToDefaultProfile,
toggleShowAllProfiles
} from '@/store/profile'
import { requestNewWorktree } from '@/store/projects'
import { openFolderAsProject, requestNewWorktree } from '@/store/projects'
import { toggleReview } from '@/store/review'
import { setModelPickerOpen } from '@/store/session'
import { reopenLastClosedTile } from '@/store/session-states'
@@ -52,7 +53,7 @@ import { toggleStatusbarVisible } from '@/store/statusbar-prefs'
import { openNewWindow } from '@/store/windows'
import { useTheme } from '@/themes/context'
import { requestComposerFocus, requestVoiceToggle } from '../chat/composer/focus'
import { requestComposerFocus, requestModelMenuToggle, requestVoiceToggle } from '../chat/composer/focus'
import { openSession } from '../open-session'
import {
AGENTS_ROUTE,
@@ -134,7 +135,13 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
'keybinds.openPanel': () => navigate(`${SETTINGS_ROUTE}?tab=keybinds`),
'composer.focus': () => requestComposerFocus('active'),
'composer.modelPicker': () => setModelPickerOpen(true),
// Toggle the composer pill's live model dropdown (pane under the pointer,
// else active composer); no chat surface on screen → the full dialog.
'composer.modelPicker': () => {
if (!requestModelMenuToggle()) {
setModelPickerOpen(true)
}
},
'composer.voice': requestVoiceToggle,
'nav.commandPalette': toggleCommandPalette,
@@ -167,6 +174,9 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
// Only meaningful inside a git repo — a no-op otherwise (the key falls
// through instead of silently doing nothing).
'workspace.newWorktree': () => $repoStatus.get() && requestNewWorktree(),
// ⌘O: native folder picker → open the folder as a project (upsert) with a
// fresh session anchored there.
'workspace.openFolder': () => void openFolderAsProject(),
// Narrow-viewport reveal is handled inside the store toggles now.
'view.toggleSidebar': toggleSidebarOpen,
@@ -218,6 +228,24 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
'profile.create': requestProfileCreate
}
// A keyboard-driven overlay closing hands typing back to the composer: Radix
// restores focus to the trigger (a toolbar button for the model pill), so
// without this the Enter that committed a model also eats the next keystroke.
// Deferred one frame and skipped when something else editable has claimed
// focus, because a palette action can legitimately open a dialog or navigate
// — the release must never steal focus from the surface it just opened.
useEffect(
() =>
onReleaseTypingFocus(() =>
requestAnimationFrame(() => {
if (!isEditableTarget(document.activeElement)) {
requestComposerFocus('active')
}
})
),
[]
)
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
// Capture mode: the next real key becomes the binding. Swallow everything
@@ -7,10 +7,16 @@ import type { MessagingPlatformInfo } from '@/types/hermes'
const getMessagingPlatforms = vi.fn()
const updateMessagingPlatform = vi.fn()
const getPairing = vi.fn()
const approvePairing = vi.fn()
const revokePairing = vi.fn()
const openExternalLink = vi.fn()
vi.mock('@/hermes', () => ({
approvePairing: (platformId: string, requestId: string) => approvePairing(platformId, requestId),
getMessagingPlatforms: () => getMessagingPlatforms(),
getPairing: () => getPairing(),
revokePairing: (platformId: string, userId: string) => revokePairing(platformId, userId),
updateMessagingPlatform: (id: string, body: unknown) => updateMessagingPlatform(id, body)
}))
@@ -44,6 +50,7 @@ function platform(patch: Partial<MessagingPlatformInfo> = {}): MessagingPlatform
beforeEach(() => {
updateMessagingPlatform.mockResolvedValue({ ok: true, platform: 'teams' })
getPairing.mockResolvedValue({ approved: [], pending: [] })
})
afterEach(() => {
@@ -93,3 +100,105 @@ describe('MessagingView setup-guide link', () => {
await waitFor(() => expect(openExternalLink).toHaveBeenCalledWith(docsUrl))
})
})
describe('MessagingView pairing', () => {
const pendingUser = {
age_minutes: 3,
platform: 'teams',
request_id: 'a1b2c3d4e5f60718',
user_id: '7712345',
user_name: 'Bee'
}
it('approves the listed request by its request id, never by a code', async () => {
// The whole point of the request-id grant path: the UI can only ever send
// the server-side row id, because the one-time code is never returned by
// the API. Posting anything derived from the code could not be approved.
getMessagingPlatforms.mockResolvedValue({ platforms: [platform()] })
getPairing.mockResolvedValue({ approved: [], pending: [pendingUser] })
approvePairing.mockResolvedValue({ ok: true, user: { user_id: '7712345', user_name: 'Bee' } })
await renderMessaging()
const approve = await screen.findByRole('button', { name: 'Approve' })
await act(async () => {
fireEvent.click(approve)
})
await waitFor(() => expect(approvePairing).toHaveBeenCalledWith('teams', 'a1b2c3d4e5f60718'))
})
it('restores the pending row when approval fails', async () => {
// Optimistic removal must not silently swallow the request: a failed
// approve has to leave the operator something to retry.
getMessagingPlatforms.mockResolvedValue({ platforms: [platform()] })
getPairing.mockResolvedValue({ approved: [], pending: [pendingUser] })
approvePairing.mockRejectedValue(new Error('500 boom'))
await renderMessaging()
await act(async () => {
fireEvent.click(await screen.findByRole('button', { name: 'Approve' }))
})
expect(await screen.findByRole('button', { name: 'Approve' })).toBeTruthy()
expect(screen.getByText('Bee')).toBeTruthy()
})
it('shows no pairing affordance when nobody is waiting', async () => {
// Approvals are rare; an always-present empty state would be permanent
// chrome on a page that is otherwise about credentials.
getMessagingPlatforms.mockResolvedValue({ platforms: [platform()] })
getPairing.mockResolvedValue({ approved: [], pending: [] })
await renderMessaging()
expect((await screen.findAllByText('Microsoft Teams')).length).toBeGreaterThan(0)
expect(screen.queryByRole('button', { name: 'Approve' })).toBeNull()
expect(screen.queryByText(/Pending requests/)).toBeNull()
})
it('still renders platforms when the pairing endpoint fails', async () => {
// An older backend without the endpoint must not blank the page.
getMessagingPlatforms.mockResolvedValue({ platforms: [platform()] })
getPairing.mockRejectedValue(new Error('404 not found'))
await renderMessaging()
expect((await screen.findAllByText('Microsoft Teams')).length).toBeGreaterThan(0)
expect(screen.queryByRole('button', { name: 'Approve' })).toBeNull()
})
it('refetches pending rows on pairing.changed, not on platforms.changed', async () => {
// The two signals are not interchangeable: platforms.changed tracks
// connect/disconnect health via gateway_state.json, which a new pairing
// request never moves. Riding it would leave someone invisible in the
// pending list until an unrelated reconnect happened to fire.
const { $changeEventsAvailable, $pairingChangeTick, $platformsChangeTick } = await import('@/store/live-sync')
getMessagingPlatforms.mockResolvedValue({ platforms: [platform()] })
getPairing.mockResolvedValue({ approved: [], pending: [] })
await renderMessaging()
await act(async () => {
$changeEventsAvailable.set(true)
})
getPairing.mockClear()
// Someone DMs the bot: the store moves, the watcher ticks pairing.changed.
getPairing.mockResolvedValue({ approved: [], pending: [pendingUser] })
await act(async () => {
$pairingChangeTick.set($pairingChangeTick.get() + 1)
})
await waitFor(() => expect(getPairing).toHaveBeenCalled())
expect(await screen.findByRole('button', { name: 'Approve' })).toBeTruthy()
// A platform health tick alone must not be what fetches pairing.
getPairing.mockClear()
await act(async () => {
$platformsChangeTick.set($platformsChangeTick.get() + 1)
})
expect(getPairing).not.toHaveBeenCalled()
})
})
+249 -8
View File
@@ -1,18 +1,24 @@
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { StatusDot, type StatusTone } from '@/components/status-dot'
import { Button } from '@/components/ui/button'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { ErrorBanner } from '@/components/ui/error-state'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Tip } from '@/components/ui/tooltip'
import {
approvePairing,
getMessagingPlatforms,
getPairing,
type MessagingEnvVarInfo,
type MessagingPlatformInfo,
type PairingUser,
revokePairing,
updateMessagingPlatform
} from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
@@ -20,6 +26,7 @@ import { openExternalLink } from '@/lib/external-link'
import { ExternalLink, Save, Trash2 } from '@/lib/icons'
import { normalize } from '@/lib/text'
import { cn } from '@/lib/utils'
import { $changeEventsAvailable, $pairingChangeTick, $platformsChangeTick } from '@/store/live-sync'
import { notify, notifyError } from '@/store/notifications'
import { runGatewayRestart } from '@/store/system-actions'
@@ -72,6 +79,22 @@ const trimEdits = (edits: Record<string, string>): Record<string, string> =>
.filter(([, v]) => v)
)
/** Stable row identity: a user id is only unique within its platform. */
const pairingKey = (user: PairingUser) => `${user.platform}:${user.user_id}`
const pairingLabel = (user: PairingUser) => user.user_name || user.user_id
/** Group pairing rows by platform id so a detail pane can slice its own. */
function byPlatform(rows: PairingUser[]): Record<string, PairingUser[]> {
const grouped: Record<string, PairingUser[]> = {}
for (const row of rows) {
;(grouped[row.platform] ||= []).push(row)
}
return grouped
}
const FIELD_COPY: Record<string, { advanced?: boolean }> = {
TELEGRAM_PROXY: { advanced: true },
DISCORD_REPLY_TO_MODE: { advanced: true },
@@ -106,6 +129,14 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
// Both save/toggle toasts offer the same one-click restart.
const restartGatewayAction = { label: t.commandCenter.restartGateway, onClick: () => void runGatewayRestart() }
const [platforms, setPlatforms] = useState<MessagingPlatformInfo[] | null>(null)
const [pairing, setPairing] = useState<{ approved: PairingUser[]; pending: PairingUser[] }>({
approved: [],
pending: []
})
const [approving, setApproving] = useState<null | string>(null)
const [pendingRevoke, setPendingRevoke] = useState<null | PairingUser>(null)
const [edits, setEdits] = useState<EditMap>({})
const [query, setQuery] = useState('')
const [refreshing, setRefreshing] = useState(false)
@@ -135,15 +166,64 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
[m]
)
useRefreshHotkey(() => void refreshPlatforms())
// Pairing has its own signal. platforms.changed tracks connect/disconnect
// health via gateway_state.json, which a new pairing request never moves —
// riding it would leave a pending row invisible until something unrelated
// reconnected. Failures stay silent: an older backend without the endpoint
// should show no rows, not an error banner over a working page.
const refreshPairing = useCallback(async () => {
try {
const result = await getPairing()
setPairing({ approved: result.approved ?? [], pending: result.pending ?? [] })
} catch {
// Leave the last known rows in place rather than blanking them.
}
}, [])
const refreshAll = useCallback(
async (silent = false) => {
await Promise.all([refreshPlatforms(silent), refreshPairing()])
},
[refreshPairing, refreshPlatforms]
)
useRefreshHotkey(() => void refreshAll())
useEffect(() => {
void refreshPlatforms()
}, [refreshPlatforms])
void refreshAll()
}, [refreshAll])
// Auto-poll while the user is on the messaging page so connection status
// updates without a manual "check" click. Pause when the tab is hidden.
const changeEventsAvailable = useStore($changeEventsAvailable)
const platformsChangeTick = useStore($platformsChangeTick)
const pairingChangeTick = useStore($pairingChangeTick)
// A new pending request (or a grant from another surface) moves the pairing
// store on disk; the change watcher turns that into pairing.changed.
useEffect(() => {
if (!changeEventsAvailable || pairingChangeTick === 0 || document.hidden) {
return
}
void refreshPairing()
}, [changeEventsAvailable, pairingChangeTick, refreshPairing])
// Connection status updates without a manual "check" click. platforms.changed
// (the gateway persisting connect/disconnect/health to gateway_state.json)
// drives the refresh on event-capable backends — no timer; older backends
// keep the legacy visible-tab poll.
useEffect(() => {
if (!changeEventsAvailable || platformsChangeTick === 0 || document.hidden) {
return
}
void refreshPlatforms(true)
}, [changeEventsAvailable, platformsChangeTick, refreshPlatforms])
useEffect(() => {
if (changeEventsAvailable) {
return
}
let cancelled = false
function tick() {
@@ -151,7 +231,7 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
return
}
void refreshPlatforms(true)
void refreshAll(true)
}
const id = window.setInterval(tick, 6000)
@@ -160,7 +240,7 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
cancelled = true
window.clearInterval(id)
}
}, [refreshPlatforms])
}, [changeEventsAvailable, refreshAll])
const selected = useMemo(() => {
if (!platforms) {
@@ -170,6 +250,9 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
return platforms.find(platform => platform.id === selectedId) || platforms[0] || null
}, [platforms, selectedId])
const pendingByPlatform = useMemo(() => byPlatform(pairing.pending), [pairing.pending])
const approvedByPlatform = useMemo(() => byPlatform(pairing.approved), [pairing.approved])
const visiblePlatforms = useMemo(() => {
if (!platforms) {
return []
@@ -265,6 +348,57 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
}
}
// Approve/revoke paint from a snapshot immediately, then let the
// authoritative refresh have the last word. A failed write restores the
// snapshot so the row never silently disappears on an error.
async function handleApprove(user: PairingUser) {
if (!user.request_id) {
return
}
const key = pairingKey(user)
const snapshot = pairing
setApproving(key)
setPairing(current => ({
approved: current.approved,
pending: current.pending.filter(row => pairingKey(row) !== key)
}))
try {
await approvePairing(user.platform, user.request_id)
notify({ kind: 'success', title: m.approvedUser(pairingLabel(user)), message: m.approvedHint })
await refreshPairing()
} catch (err) {
setPairing(snapshot)
// 429 is the code path's brute-force lockout — a distinct condition the
// operator can only wait out, so it gets its own message.
const lockedOut = err instanceof Error && err.message.includes('429')
notifyError(err, lockedOut ? m.pairingLockedOut : m.failedApprove(pairingLabel(user)))
} finally {
setApproving(null)
}
}
// ConfirmDialog owns the pending → done → close beat and shows an inline
// error when onConfirm throws, so this rethrows instead of swallowing.
async function handleRevoke(user: PairingUser) {
const key = pairingKey(user)
const snapshot = pairing
setPairing(current => ({
approved: current.approved.filter(row => pairingKey(row) !== key),
pending: current.pending
}))
try {
await revokePairing(user.platform, user.user_id)
notify({ kind: 'success', title: m.revokedUser(pairingLabel(user)), message: user.platform })
await refreshPairing()
} catch (err) {
setPairing(snapshot)
throw err
}
}
return (
<PageSearchShell
{...props}
@@ -285,6 +419,7 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
<PlatformRow
active={selected?.id === platform.id}
onSelect={() => setSelectedId(platform.id)}
pendingCount={pendingByPlatform[platform.id]?.length ?? 0}
platform={platform}
/>
</li>
@@ -307,7 +442,10 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
>
{selected && (
<PlatformDetail
approved={approvedByPlatform[selected.id] ?? []}
approving={approving}
edits={edits[selected.id] || {}}
onApprove={user => void handleApprove(user)}
onClear={key => void handleClear(selected, key)}
onEdit={(key, value) =>
setEdits(current => ({
@@ -318,6 +456,8 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
}
}))
}
onRevoke={setPendingRevoke}
pending={pendingByPlatform[selected.id] ?? []}
platform={selected}
saving={saving}
/>
@@ -325,6 +465,18 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
</DetailColumn>
</MasterDetail>
)}
<ConfirmDialog
busyLabel={m.revoking}
cancelLabel={t.common.cancel}
confirmLabel={m.revoke}
description={pendingRevoke ? m.revokeDesc(pairingLabel(pendingRevoke)) : null}
destructive
onClose={() => setPendingRevoke(null)}
onConfirm={() => (pendingRevoke ? handleRevoke(pendingRevoke) : undefined)}
open={Boolean(pendingRevoke)}
title={m.revokeTitle}
/>
</PageSearchShell>
)
}
@@ -332,12 +484,16 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
function PlatformRow({
active,
onSelect,
pendingCount,
platform
}: {
active: boolean
onSelect: () => void
pendingCount: number
platform: MessagingPlatformInfo
}) {
const { t } = useI18n()
return (
<button
className={cn(
@@ -350,22 +506,47 @@ function PlatformRow({
<PlatformAvatar platformId={platform.id} platformName={platform.name} />
<span className="flex min-w-0 flex-1 items-center justify-between gap-2">
<span className="truncate text-[length:var(--conversation-text-font-size)] font-normal">{platform.name}</span>
<StatusDot tone={stateTone(platform)} />
<span className="flex shrink-0 items-center gap-1.5">
{/* Someone is waiting to be let in — the only way this page tells
you so before you open the platform. */}
{pendingCount > 0 && (
<span
aria-label={t.messaging.pendingAria(pendingCount)}
className={cn(
'inline-flex min-w-4 items-center justify-center rounded-full px-1 text-[0.66rem] font-medium tabular-nums',
PILL_TONE.warn
)}
>
{pendingCount}
</span>
)}
<StatusDot tone={stateTone(platform)} />
</span>
</span>
</button>
)
}
function PlatformDetail({
approved,
approving,
edits,
onApprove,
onClear,
onEdit,
onRevoke,
pending,
platform,
saving
}: {
approved: PairingUser[]
approving: null | string
edits: Record<string, string>
onApprove: (user: PairingUser) => void
onClear: (key: string) => void
onEdit: (key: string, value: string) => void
onRevoke: (user: PairingUser) => void
pending: PairingUser[]
platform: MessagingPlatformInfo
saving: string | null
}) {
@@ -399,6 +580,66 @@ function PlatformDetail({
{platform.error_message && <ErrorBanner>{platform.error_message}</ErrorBanner>}
{/* Pending pairing requests. Rendered only when someone is actually
waiting — an empty-state card here would be permanent chrome on a
page that is usually about credentials, not approvals. */}
{pending.length > 0 && (
<section>
<SectionTitle>{m.pendingRequests(pending.length)}</SectionTitle>
<div className="mt-1 grid gap-1">
{pending.map(user => {
const busy = approving === pairingKey(user)
const waited = typeof user.age_minutes === 'number' ? m.waitingSince(user.age_minutes) : null
return (
<ListRow
action={
<Button
disabled={busy || !user.request_id}
onClick={() => onApprove(user)}
size="sm"
variant="secondary"
>
{busy ? m.approving : m.approve}
</Button>
}
// An unnamed requester is only a user id — showing it as
// both title and description just repeats itself.
description={[user.user_name ? user.user_id : null, waited].filter(Boolean).join(' · ')}
key={pairingKey(user)}
title={pairingLabel(user)}
/>
)
})}
</div>
</section>
)}
{approved.length > 0 && (
<section>
<SectionTitle>{m.approvedUsers(approved.length)}</SectionTitle>
<div className="mt-1 grid gap-1">
{approved.map(user => (
<ListRow
action={
<Button
aria-label={m.revokeAria(pairingLabel(user))}
onClick={() => onRevoke(user)}
size="sm"
variant="ghost"
>
{m.revoke}
</Button>
}
description={user.user_name ? user.user_id : undefined}
key={pairingKey(user)}
title={pairingLabel(user)}
/>
))}
</div>
</section>
)}
<section>
<SectionTitle>{m.getCredentials}</SectionTitle>
<p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
+92 -2
View File
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const focusOpenSession = vi.fn()
const openSessionTile = vi.fn()
const reuseBlankDraftTile = vi.fn()
const openSessionInNewWindow = vi.fn()
const canOpenSessionWindow = vi.fn(() => true)
const workspaceIsPageGet = vi.fn(() => false)
@@ -10,7 +11,8 @@ 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)
openSessionTile: (...args: unknown[]) => openSessionTile(...args),
reuseBlankDraftTile: (...args: unknown[]) => reuseBlankDraftTile(...args)
}))
vi.mock('@/store/windows', () => ({
@@ -23,7 +25,33 @@ vi.mock('./routes', () => ({
sessionRoute: (id: string) => `/c/${encodeURIComponent(id)}`
}))
import { openSession, openSessionIntentFromModifiers } from './open-session'
import { $activeSessionId, $selectedStoredSessionId } from '@/store/session'
import { mainChatOccupied, openSession, openSessionIntentFromModifiers } from './open-session'
/**
* The question behind both the sidebar "+" and a palette open: is there a
* conversation on main that must not be discarded? A create affordance stacks a
* tab rather than replacing a chat that may still be mid-turn, and an open from
* nowhere does the same.
*/
describe('mainChatOccupied', () => {
it('is occupied once a conversation is on screen', () => {
expect(mainChatOccupied('runtime-a', 'stored-a')).toBe(true)
})
it('is occupied by a live runtime whose stored id has not landed yet', () => {
expect(mainChatOccupied('runtime-a', null)).toBe(true)
})
it('is occupied by a selected session still resuming into a runtime', () => {
expect(mainChatOccupied(null, 'stored-a')).toBe(true)
})
it('is free when nothing is open', () => {
expect(mainChatOccupied(null, null)).toBe(false)
})
})
describe('openSessionIntentFromModifiers', () => {
it('defaults to in-place', () => {
@@ -32,12 +60,22 @@ describe('openSessionIntentFromModifiers', () => {
expect(openSessionIntentFromModifiers({})).toBe('in-place')
})
it('returns the caller base for an unmodified select', () => {
expect(openSessionIntentFromModifiers(undefined, 'stack')).toBe('stack')
expect(openSessionIntentFromModifiers({}, 'stack')).toBe('stack')
})
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')
})
it('lets modifiers override the base', () => {
expect(openSessionIntentFromModifiers({ metaKey: true }, 'stack')).toBe('tab')
expect(openSessionIntentFromModifiers({ metaKey: true, shiftKey: true }, 'stack')).toBe('window')
})
})
describe('openSession', () => {
@@ -50,6 +88,9 @@ describe('openSession', () => {
openSessionInNewWindow.mockReset()
canOpenSessionWindow.mockReturnValue(true)
workspaceIsPageGet.mockReturnValue(false)
reuseBlankDraftTile.mockReset()
$activeSessionId.set(null)
$selectedStoredSessionId.set(null)
})
it('in-place focuses an existing tile and does not navigate', () => {
@@ -94,6 +135,55 @@ describe('openSession', () => {
expect(navigate).not.toHaveBeenCalled()
})
it('stack focuses a session that is already on screen', () => {
$selectedStoredSessionId.set('s0')
focusOpenSession.mockReturnValue('tile')
openSession('s1', navigate, 'stack')
expect(openSessionTile).not.toHaveBeenCalled()
expect(navigate).not.toHaveBeenCalled()
})
it('stack opens a tab rather than taking main from a loaded chat', () => {
$selectedStoredSessionId.set('s0')
focusOpenSession.mockReturnValue(null)
openSession('s1', navigate, 'stack')
expect(openSessionTile).toHaveBeenCalledWith('s1', 'center')
expect(navigate).not.toHaveBeenCalled()
})
it('stack spends an open blank draft tab before stacking a new one', () => {
$selectedStoredSessionId.set('s0')
focusOpenSession.mockReturnValue(null)
reuseBlankDraftTile.mockReturnValue(true)
openSession('s1', navigate, 'stack')
expect(reuseBlankDraftTile).toHaveBeenCalledWith('s1')
expect(openSessionTile).not.toHaveBeenCalled()
expect(navigate).not.toHaveBeenCalled()
})
it('stack prefers the session already on screen over a blank draft tab', () => {
$selectedStoredSessionId.set('s0')
focusOpenSession.mockReturnValue('tile')
reuseBlankDraftTile.mockReturnValue(true)
openSession('s1', navigate, 'stack')
expect(reuseBlankDraftTile).not.toHaveBeenCalled()
expect(openSessionTile).not.toHaveBeenCalled()
})
it('stack opens a tab while main is mid-turn on an unsaved session', () => {
$activeSessionId.set('runtime-a')
focusOpenSession.mockReturnValue(null)
openSession('s1', navigate, 'stack')
expect(openSessionTile).toHaveBeenCalledWith('s1', 'center')
})
it('stack loads into main when it holds only a blank draft', () => {
focusOpenSession.mockReturnValue(null)
openSession('s1', navigate, 'stack')
expect(navigate).toHaveBeenCalledWith('/c/s1')
expect(openSessionTile).not.toHaveBeenCalled()
})
it('window pops out when the bridge supports it', () => {
openSession('s1', navigate, 'window')
expect(openSessionInNewWindow).toHaveBeenCalledWith('s1')
+50 -8
View File
@@ -4,28 +4,52 @@
* 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).
* - `in-place` (sidebar click / Enter) — focus existing tile/main if on
* screen; else load into main (same as the left sessions sidebar).
* - `stack` (⌘K, notifications — anything that opens a chat from outside the
* workspace) — like `tab`, but may spend main or an open blank draft tab
* when either is empty.
* - `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 { $activeSessionId, $selectedStoredSessionId } from '@/store/session'
import {
focusedSessionNeedsRoute,
focusOpenSession,
openSessionTile,
reuseBlankDraftTile
} from '@/store/session-states'
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
import { $workspaceIsPage, sessionRoute } from './routes'
export type OpenSessionIntent = 'in-place' | 'tab' | 'window'
export type OpenSessionIntent = 'in-place' | 'stack' | '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. */
/**
* Is the main tab holding a conversation worth preserving?
*
* A loaded chat may still be mid-turn, so replacing it with something else
* throws away work the user can see. A blank draft has nothing to lose, which
* is what lets the sidebar "+" and a `stack` open take the cheaper main path
* instead of stacking a tab nobody asked for.
*/
export function mainChatOccupied(activeSessionId: null | string, selectedStoredSessionId: null | string): boolean {
return Boolean(activeSessionId || selectedStoredSessionId)
}
/** Read modifiers the way session rows do — meta OR ctrl for tab, +shift for
* window. `base` is what an unmodified select means for the caller: the
* sidebar spends main (`in-place`), a palette-style open doesn't (`stack`). */
export function openSessionIntentFromModifiers(
event?: null | { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean }
event?: null | { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean },
base: OpenSessionIntent = 'in-place'
): OpenSessionIntent {
if (!event) {
return 'in-place'
return base
}
const mod = Boolean(event.metaKey || event.ctrlKey)
@@ -38,7 +62,7 @@ export function openSessionIntentFromModifiers(
return 'tab'
}
return 'in-place'
return base
}
/**
@@ -67,6 +91,17 @@ export function openSession(
resolved = 'tab'
}
// A `stack` open arrives from outside the workspace, so unlike a sidebar
// click it can't assume main is spendable: it behaves like `tab`, except main
// IS fair game while it's only a blank draft, and an already-open blank draft
// tab is spent before a new one is stacked.
let spendBlankDraft = false
if (resolved === 'stack') {
spendBlankDraft = mainChatOccupied($activeSessionId.get(), $selectedStoredSessionId.get())
resolved = spendBlankDraft ? 'tab' : 'in-place'
}
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
@@ -75,6 +110,13 @@ export function openSession(
return
}
// Nothing to jump to, but an open tab may still be an empty "New session" —
// that's the tab the user would have typed into, so spend it rather than
// stacking a second blank one beside it.
if (spendBlankDraft && reuseBlankDraftTile(storedSessionId)) {
return
}
openSessionTile(storedSessionId, 'center')
return

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