Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts: # tests/run_agent/test_run_agent_codex_responses.py
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
started=1785343166.9711895
|
||||
pid=2093896
|
||||
@@ -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.
|
||||
@@ -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
@@ -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")`
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
+5
-9
@@ -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', () => {
|
||||
|
||||
@@ -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> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)">
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user