Compare commits

...
Author SHA1 Message Date
Jai Suphavadeeprasit 06d11e3316 test(providers): expect conversation tags in Nous summaries
Update max-iteration summary assertions to include the agent session ID now attached to Nous Portal requests.
2026-07-15 16:39:06 -04:00
Jai Suphavadeeprasit 341f093b25 test(providers): update Nous parity test for conversation tag
The end-to-end _build_api_kwargs parity test asserted the Nous Portal
tags exactly equal the base two-tag list. With the per-session
conversation tag, a real agent (which has a session_id) now emits a
third `conversation=<session_id>` tag. Assert against
nous_portal_tags(session_id=agent.session_id) so the check stays exact.
2026-07-15 16:12:38 -04:00
Jai Suphavadeeprasit 76500c8b73 init 2026-07-15 16:06:49 -04:00
ethernetandBrooklyn Nicholson b80b52aa46 feat(desktop): add background-task indicator to sidebar session rows (#65174)
* feat(desktop): add background-task indicator to sidebar session rows

A session with a live terminal(background=true) process but no active LLM
turn now shows a pulsing gray dot in the sidebar — distinct from the accent
pulse of an active turn and the steady amber/green of needs-input/unread.

New $backgroundRunningSessionIds computed atom joins
$backgroundStatusBySession (runtime-keyed) with $sessionStates
(runtime→stored) to produce stored session ids the sidebar row can match
against — same pattern as $attentionSessionIds and $unreadFinishedSessionIds.

Also refactors SidebarRowDot from a 3-branch if-else chain into a
table-driven priority array of DotState entries. Each state declares its
active flag, className, ariaLabel, and title in one place — adding a new
indicator state is now one array entry instead of another conditional.

* fix(desktop): keep pulse-dot before:bg-* static so Tailwind emits it

The PING() helper interpolated the color into `before:bg-${color}`, but
Tailwind v4 only generates utilities it finds as complete static strings — a
template-composed `before:bg-(--ui-accent)` / `before:bg-muted-foreground/50`
is never emitted, so both pulse rings (working + new background) lost their
halo color. Make PING a static scaffold and write the before:bg color inline
per variant.

---------

Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
2026-07-15 20:02:23 +00:00
xxxigm 8b209e0dd7 test(agent): cover think scrubber leak after flush-then-retry 2026-07-16 01:07:29 +05:30
xxxigm a569226f88 fix(agent): re-arm think scrubber boundary after stream flush
Thinking-only retries flush the scrubber then stream again without
reset(). flush() was leaving _last_emitted_ended_newline False, so the
next stream's opening <think> looked mid-line and leaked into the UI.
2026-07-16 01:07:29 +05:30
ethernet 4e7b0389ea fix(desktop): match sessions by git branch in ctrl-k palette and sidebar search (#65172)
SessionInfo already carries git_branch from the backend, but neither the
ctrl-k command palette nor the sidebar search function included it in
their matching fields. Typing a branch name matched nothing.

- session-search.ts: add session.git_branch to sessionMatchesSearch
- command-palette/index.tsx: thread git_branch through SessionEntry and
  into the keywords array for both sessions and archived sessions groups
- session-search.test.ts: cover full, partial, and "main" branch matches
2026-07-15 15:37:12 -04:00
ethernet c1945d410b fix(desktop): panel layout toggle bugs — terminal reveal, side collapse in column-root layouts, zone-menu restore (#65162)
* fix(desktop): un-minimize zone when revealing a collapsed tool panel

`revealTreePane()` fronted the pane's tab but never un-minimized its zone
when the pane lived in a shared zone (terminal + logs, or a tool panel
stacked with the workspace). The shared-zone branch of `setPaneCollapsed`
routes opens through `revealTreePane` instead of `toggleTreeGroupMinimized`,
so the zone stayed collapsed and the terminal appeared to "close but not
open" on Ctrl+` — and tab clicks needed a double-click because the first
click's `restoreTreePane` → opener → listener → `revealTreePane` chain
didn't un-minimize either.

The fix: `revealTreePane` now restores a minimized zone before fronting
the pane, chaining the un-minimize + activate into a single `commit` so
the second op sees the updated tree.

* fix(desktop): side collapse in column-root layouts + restore after zone-menu minimize

Two follow-up fixes for the panel layout:

1. Ctrl+B / Ctrl+J sidebar toggles didn't work in the Terminal deck (and
   Quad) layout. The side-collapse system (treeSideOfPane, paneRootSide,
   layoutHasRootSide, and the renderer's semanticSides) only operated on the
   ROOT split when it was a row — but Terminal deck's root is a column, so the
   side columns (sessions/files) nested inside a child row were invisible to
   the collapse system. Extracted a rootRow() helper that finds the row
   containing main (the root itself for row layouts, or the row child of a
   column root), and a rootRow prop propagated through TreeNode → TreeSplit so
   semanticSides fires on the right split regardless of root orientation.

2. Clicking the "logs" tab in a minimized terminal/logs zone needed a
   double-click when the zone was minimized via the zone menu (right-click →
   minimize), not the toggle. restoreTreePane called the opener
   ($logsOpen.set(true)) and returned early — but when the store was already
   true, nanostores don't fire the listener, so setPaneCollapsed/revealTreePane
   never ran and the zone stayed minimized. Now restoreTreePane also
   un-minimizes directly + calls revealTreePane when the zone is still
   minimized after the opener runs.
2026-07-15 15:37:09 -04:00
Teknium b6c11a35ac refactor(skills): adapt salvaged references to the MCP-tool surface, bump to 2.1.0
Follow-up on kshitijk4poor's cherry-picked references:
- pitfalls.md rewritten from the raw-socket blender_exec() frame to the
  MCP-tool frame (dropped 'MCP server is optional, talk to the socket
  directly' — now the anti-pattern; dropped TCP-helper internals items;
  kept all bpy/addon knowledge: empty code results in 5.x, temp-file
  readback, ops-vs-data context, engine names by version, GPU setup)
- recipes.md: blender_exec -> execute_blender_code in the agent-side
  verification snippet
- SKILL.md: reference-file table added to Quick Reference, version
  2.1.0, kshitijk4poor added to authors
- docs page regenerated (scoped to this skill)
2026-07-15 12:28:13 -07:00
kshitijk4poor 5601f24449 feat(skills): enhance blender-mcp with comprehensive references and recipes
- Rewrite SKILL.md with full setup guide, MCP config, typed commands, object types table
- Add references/bpy-api.md: scene, transforms, bmesh, materials, modifiers, camera, rendering, animation
- Add references/pitfalls.md: 19 real-session pitfalls (connection, Python exec, rendering, version quirks)
- Add references/recipes.md: 5 copy-paste recipes (landscape, glass sphere, donut, turntable, render)
- Preserve original blender-mcp name and credit to alireza78a
- Tested with Blender 5.1 on macOS
2026-07-15 12:28:13 -07:00
Teknium bd7e480236 fix(compression): give fallback candidates their own timeout budget + escalate repeat-timeout cooldowns (#65143)
Fixes #62452. Two amplifiers turned one slow auxiliary route into a
per-turn multi-minute stall:

1. Fallback candidates inherited the exact effective_timeout the primary
   was called with. When the primary's deadline was short (tuned or
   already burned), an independently healthy fallback died on the same
   clock — the reporter's 163k-token compression needed ~90s on the
   fallback and got the primary's 30s, every turn. fallback_chain
   entries may now declare their own 'timeout' (seconds); both fallback
   candidate call sites (sync + async) resolve it via
   _fallback_entry_timeout, label-scoped so only configured-chain
   candidates are affected. No entry timeout → task-level timeout,
   preserving existing behavior.

2. A session whose transcript structurally cannot be summarized within
   the deadline re-attempted every 60s, re-burning the full timeout on
   every subsequent turn. Consecutive timeout-class failures now
   escalate the cooldown 60s → 300s → 900s (capped); any successful
   summary or session reset clears the streak. Timeout classification
   takes precedence over the streaming-closed 30s rung ('timed out'
   also matches _is_connection_error) and now recognizes the SDK's
   'Request timed out.' phrasing.

Fail-safe behavior is unchanged: all messages are preserved when every
candidate fails; the cooldown only spaces out retries.
2026-07-15 12:28:09 -07:00
brooklyn! c81afd6e50 Merge pull request #65156 from NousResearch/bb/fix-desktop-reasoning-flash
fix(desktop): stop reasoning text flashing on every stream delta
2026-07-15 15:16:25 -04:00
Teknium 5f1991bf6b fix(gateway): import PairingStore in _start_secondary_profile_adapters (#65118)
The served-profiles block in _start_secondary_profile_adapters references
PairingStore, but the class's only import in gateway/run.py is method-local
inside __init__ — so the reference raised NameError at runtime, silently
swallowed by the enclosing try/except ('could not record served_profiles').
Result: multiplexing gateways never created per-profile pairing stores, and
authz pairing checks for secondary profiles fell through to the global
whitelist. Also masked the served_profiles runtime-status write.

One-line fix (local import alongside write_runtime_status) + regression
tests that drive the real method and assert the stores materialize,
verified red without the import and green with it.

Surfaced during the profile-routing sweep by @CocaKova's PR #61689, which
included the same fix as part of a larger feature.
2026-07-15 12:11:16 -07:00
2b6897f982 fix(computer-use): target Linux app windows reliably (#63725)
* fix(computer-use): target Linux app windows reliably

Resolve app filters through the canonical cua-driver MCP app metadata and join running app PIDs back to windows. Preserve an exact selected window across capture_after, support direct capture by pid/window_id, and send the active window ID for coordinate pointer actions on Linux.

Co-authored-by: annguyenNous <annguyenNous@users.noreply.github.com>
Co-authored-by: grimmjoww578 <willies578@gmail.com>
Co-authored-by: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com>

* fix(computer-use): address review on PR #63725

Address three review comments from @f-trycua:

1. type_text, press_key, and hotkey now carry _active_window_id and
   fail closed when it is missing. Previously they sent only the PID,
   so CUA Driver fell back to the first window for that PID — input
   could reach the wrong window in multi-window apps.

2. Coordinate scroll x/y are now capability-gated behind
   input.scroll.coordinates. CUA Driver 0.7.1 Linux schema rejects
   x/y on scroll; omitting them when the driver doesn't advertise
   support avoids the schema rejection while still routing via
   window_id.

3. Windows are sorted by z_index descending (higher = front, per CUA
   Driver semantics) instead of ascending. Null z_index (Wayland) is
   coerced to 0 in _ingest_windows so it doesn't crash the sort and
   sorts to the back instead of being selected as the capture target.

---------

Co-authored-by: LeonSGP43 <cine.dreamer.one@gmail.com>
Co-authored-by: annguyenNous <annguyenNous@users.noreply.github.com>
Co-authored-by: grimmjoww578 <willies578@gmail.com>
Co-authored-by: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com>
2026-07-16 00:34:20 +05:30
kshitijk4poor 58033baba2 fix(plugins): classify stale-call circuit breaker as failover, not retry
The cross-turn stale-call circuit breaker (_check_stale_giveup in
agent/chat_completion_helpers.py) raises a RuntimeError when the
provider has been unresponsive for N consecutive stale attempts.
This error was classified as FailoverReason.unknown (retryable=True,
should_fallback=False), causing the retry loop to burn all max_retries
against the same dead provider — each retry hitting the circuit breaker
instantly with zero network overhead — before fallback was attempted.

Add a classification rule (section 7b in classify_api_error) that
recognizes the stale-breaker RuntimeError by its signature phrases and
classifies it as FailoverReason.timeout with retryable=False,
should_fallback=True. This makes the retry loop skip retries and go
straight to fallback provider activation on the first hit.

Test: test_stale_breaker_runtime_error_triggers_fallback_not_retry
2026-07-16 00:27:39 +05:30
Jason c346f018d4 fix(gateway): evict stale-self-heal agent cache entries pointing at dead sessions
The #54878 self-heal (SessionStore.get_or_create_session) drops a routing
key pointing at a session already ended in state.db and recovers/recreates
a fresh session_id under the same session_key. The #54947 fix (agent-cache
cache-hit guard in gateway/run.py) treats a cached agent whose snapshot
session_id differs from the current session_id, under the same
session_key, as an intentional /resume-/branch-style switch between two
live sibling conversations, and reuses it unchanged to protect the prompt
cache.

These two fixes compose incorrectly: when the #54878 self-heal just fired,
the cached agent's session_id is not a live sibling — it's the dead session
just routed away from. #54947's "different session_id -> reuse freely" rule
reuses it anyway. The stale agent runs the turn, and the post-run "session
split" sync (agent.session_id != session_id) then writes the routing key
straight back onto the dead session_id, undoing the self-heal. This repeats
on every subsequent message until an interrupt (e.g. /stop) happens to race
in before that post-run sync, silently discarding conversation context.

Reproduced live on the engineering gateway (2026-07-12, routing key
agent:main:telegram:dm:170829464:544520): 5 consecutive self-heal log lines
over ~40 minutes, each followed by the dead session_id being reused and
re-synced back, until an interrupted /stop finally let a fresh session
stick — at which point all prior context was gone.

No open upstream issue tracks this specific interaction as of 2026-07-12
(checked #54878, #54947, #59580, #59597, #61220 — all cover adjacent but
distinct edges of the self-heal / agent-cache system).

Fix: before applying #54947's reuse-on-mismatch rule, check (outside the
cache lock, via SessionStore._is_session_ended_in_db) whether the cached
snapshot's session_id is itself ended in state.db. If so, treat it as a
stale self-heal artifact and evict/rebuild fresh -- same as a genuine
cross-process write -- instead of reusing it. Re-validates the peeked
verdict against the tuple actually held under the lock so a race can't
apply a stale verdict to a different (possibly live) cache entry.

Tests: tests/gateway/test_stale_self_heal_agent_cache_eviction.py (5 new
cases: dead-session eviction, live-sibling reuse preserved [#54947 intact],
cross-process invalidation preserved [#45966 intact], same-session_id dead
edge case, lock-race re-validation). Full tests/gateway/ suite: 14 failed,
9040 passed, 11 skipped -- all 14 failures verified pre-existing on
unpatched main (confirmed via git stash + re-run), unrelated to this
change.
2026-07-16 00:11:22 +05:30
Brooklyn Nicholson a4c9b069dc fix(desktop): stop reasoning text flashing/re-typing on every delta
Reasoning ("Thinking") rendered through MarkdownTextContent with a smooth
typewriter reveal. TextMessagePartProvider mints a fresh part object on every
text change, and useSmooth resets its reveal to empty whenever the part
identity changes — so each reasoning delta restarted the animation from the
first character (h / hell / hello wo ...). Token-streaming reasoners
(R1/Qwen/GLM/Claude thinking) fire a delta per token and flash hard; GPT-5's
coarse reasoning summary updates too rarely to notice, which is why it looked
fine.

Drop smooth on the reasoning path so it plain-appends, exactly like the
assistant answer already does. Removes the now-dead smooth plumbing too.
2026-07-15 14:35:15 -04:00
Siddharth Balyan e0e7cfa673 fix(dashboard): add HTTP MCP authentication (#65146) 2026-07-16 00:02:09 +05:30
Frowtek 170959d80a fix(gateway): sanitize sender-name prefix in shared multi-user sessions
GatewayRunner._prepare_inbound_message_text() interpolated
source.user_name — the platform-supplied, user-settable display name —
directly into the message text of every turn in a shared multi-user
session: f"[{source.user_name}] {message_text}". Shared sessions are the
default for any threaded conversation (thread_sessions_per_user defaults
to False) and apply to any group with group_sessions_per_user=False, so
no special configuration is needed to reach this path.

An unescaped display name containing embedded newlines could therefore
masquerade as a new markdown section (a fake "## Override" heading)
inside the live conversation the model reads on every turn — the same
indirect-prompt-injection vector gateway/session.py's
build_session_context_prompt() already guards against for the identical
user_name field via _format_untrusted_prompt_value(), which was never
applied to this sibling call site.

Add neutralize_untrusted_inline_text() alongside the existing helper in
gateway/session.py: it collapses embedded newlines/control characters to
a single inert line without JSON-quoting, so inline "[Name] message"
formatting is preserved byte-for-byte for the common case (unlike
reusing _format_untrusted_prompt_value directly, which would add visible
quote marks to every sender prefix). Wire it into the sender-prefix
construction in gateway/run.py.
2026-07-15 23:48:34 +05:30
brooklyn! 91f87137b6 Merge pull request #65142 from NousResearch/bb/fix-image-tool-overflow
fix(desktop): keep generated images out of tool overflow
2026-07-15 14:13:16 -04:00
Brooklyn Nicholson 7a5a6ef99b fix(desktop): keep generated images out of tool overflow
Generalize the clarify opt-out into an UNBOUNDABLE_TOOLS set so a run
containing image_generate also stays a plain, fully-visible stack instead
of collapsing into the bounded window, where the max-height + gradient mask
clipped the image the same way it clipped clarify forms.
2026-07-15 14:08:35 -04:00
brooklyn! da4a28ec6d Merge pull request #65109 from NousResearch/ethie/finished-indicator
feat(desktop): green unread dot for background-finished sessions
2026-07-15 13:55:41 -04:00
587e76fbc2 feat(desktop): green unread dot for background-finished sessions
When an agent turn finishes while the user is viewing a different
session, the sidebar now shows a steady green dot on that session —
distinct from the blue pulsing dot of a running turn and the gray dot
of an idle one. Opening the session clears the indicator.

The unread state is ephemeral renderer-side state, matching the
existing $workingSessionIds and $attentionSessionIds pattern: no
persistence, no backend involvement, wiped on gateway-mode switch.

Co-authored-by: liuhao1024 <liuhao1024@users.noreply.github.com>
Co-authored-by: dschnurbusch <dschnurbusch@users.noreply.github.com>
Co-authored-by: Flow Digital Inc. <flow-digital-ny@users.noreply.github.com>
2026-07-15 13:51:41 -04:00
brooklyn! 1f89f3102f Merge pull request #60638 from NousResearch/bb/contrib-areas
feat(desktop): contribution-driven shell on a layout-tree model
2026-07-15 13:28:38 -04:00
Brooklyn Nicholson 5c03e27ce2 Merge remote-tracking branch 'origin/main' into bb/contrib-areas
# Conflicts:
#	apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts
2026-07-15 13:22:08 -04:00
Teknium 6020b9f4fe chore: AUTHOR_MAP entry for JiaDe-Wu (PR #34742 salvage) 2026-07-15 09:59:38 -07:00
Teknium 3b15afafa2 fix(bedrock): region-scoped model picker + geo-aware recommendations (#28156)
Bug 2 of #28156: the picker offered us./global. inference profiles to
EU-region endpoints (unroutable — AWS rejects them regardless of
credentials) and _RECOMMENDED hardcoded us.anthropic.* ids, so non-US
pickers pinned profiles their endpoint can't invoke.

- bedrock_model_routable_from_region(): geo-prefixed profiles are only
  offered in their own geography (full AWS prefix set incl. apac./jp./
  ca./sa./me./af.); bare ids and global.* pass everywhere; unknown
  region shapes hide nothing.
- Recommendations match geo-agnostically on the base model id, so an EU
  picker pins eu.anthropic.claude-sonnet-4-6; in-region geo profiles
  sort above global.* for the same model (addresses the global.*-first
  ordering complaint from the issue thread).
- Dedup generalized from (us., global.) to all profile prefixes.
2026-07-15 09:59:38 -07:00
Teknium b6f749af94 test(bedrock): monkeypatch-based bearer routing tests + SigV4 regression
Rewrites the cherry-picked test to import os locally and monkeypatch the
resolver seams instead of patching bedrock_adapter internals; adds the
inverse assertion (no bearer -> AnthropicBedrock SDK path preserved).
2026-07-15 09:59:38 -07:00
JiaDe-Wu 5e6a0d9eea fix(bedrock): streaming fallback to Converse API + image base64 decode + bearer token routing
Three fixes for the Bedrock Claude path:

1. Streaming fallback: When AnthropicBedrock SDK raises 'Unexpected event
   order' (SDK misparses Bedrock error events as message_start), auto-switch
   to native Converse API for the rest of the session instead of failing
   after 3 retries.

2. Image base64 decode (#33317): data URL payloads were passed as base64
   strings to source.bytes, but boto3 re-encodes at the wire layer. Now
   decoded to raw bytes before passing to Converse API.

3. Bearer token routing (#28156): Users with AWS_BEARER_TOKEN_BEDROCK are
   now routed through Converse API regardless of model, since the
   AnthropicBedrock SDK only supports SigV4 signing.

3 new tests. 121 bedrock_adapter tests passing.
2026-07-15 09:59:38 -07:00
Teknium 2106f63723 fix(docs): disable fuzzy matching in docs search (exact word or prefix only) (#65103)
The docs search theme (@easyops-cn/docusaurus-search-local) defaults
fuzzyMatchingDistance to 1, so every term also matched words one edit
away. Two user-visible failures on the 14.4 MB production index:

- Wrong results: 'keet' returned 'Microsoft Teams Meetings',
  'google_meet', 'Keep the Model Loaded' etc. — 'meet' and 'keep' are
  both one edit from 'keet', and the stemmer indexes 'meetings' as
  'meet'.
- Search appearing to die: fuzzy matching multiplies the generated
  lunr queries (distance matrix x maybe-typing variants x
  leave-one-out terms — up to 210 queries per keystroke on multi-word
  input), and fuzzy REQUIRED terms are the expensive scan kind. A
  typo'd 3-word query stalled the single-threaded search Web Worker
  for 25+ seconds; every later keystroke's search queued behind it,
  so the bar stopped returning results.

Setting fuzzyMatchingDistance: 0 keeps exact-word-or-prefix semantics
(keet -> keet*), which is the behavior users asked for. Validated by
running the plugin's shipped smartQueries/tokenize code against the
downloaded production search-index.json: legitimate queries (cron,
telegram, prefix 'memor') return identical results; worst-case typo
queries drop from 210 queries / 365-532ms per keystroke to 50-105 /
53-188ms; false 'keet' matches gone.
2026-07-15 09:57:46 -07:00
Teknium fcdc10a0f3 fix(moa): reject half-filled MoA saves at the API boundary and hold desktop autosave until slots are complete
Follow-up hardening on top of #64158 (@DavidMetcalfe):

Backend (the root-cause fix):
- hermes_cli/moa_config.py: add validate_moa_payload() — strict write-time
  counterpart to the deliberately tolerant normalize_moa_config(). Flags
  half-filled slots, empty reference lists, recursive moa slots, naming the
  exact preset/slot.
- hermes_cli/web_server.py: PUT /api/model/moa validates before normalizing
  and returns 422 with the specific problems instead of silently swapping the
  user's preset for hardcoded defaults (#64156). Also declares
  fanout / reference_max_tokens / reasoning_effort on the Pydantic payload so
  client round-trips no longer erase hand-set values.

Desktop:
- Replace sanitize-then-send with hold-while-incomplete: the debounced
  autosave is deferred (not repaired) while any slot is half-filled, and
  flushes once the model pick completes the edit. Mid-edit UI state is never
  repainted by a save response (generation guard covers held edits too).
- updateMoaSlot only clears the model when the provider actually changed.
- Explicit preset ops (set default / add / delete) cancel the pending
  autosave and invalidate in-flight responses so the two writers can't race.
- Stable row keys (preset+index) so mid-edit rows don't remount; cleared
  model shows the 'Model' placeholder instead of vanishing.

Both TS clients' MoaConfigResponse types now declare the round-tripped
fields (fanout, reference_max_tokens, reasoning_effort).

Tests: 12 new backend unit tests (validate_moa_payload contract incl.
validate/normalize agreement), 3 new web_server endpoint tests (422 on
half-filled ref/aggregator, fanout round-trip), 3 new desktop vitest cases
(autosave held while half-filled, flush on completion, same-provider
reselect no-op). E2E validated against a live TestClient with isolated
HERMES_HOME: bug sequence now 422s with config untouched.

Fixes #64156
2026-07-15 09:50:08 -07:00
David Metcalfe a61a0bc019 fix(desktop): prevent MoA autosave defaults explosion from half-filled slots
Three fixes in model-settings.tsx:

1. Filter incomplete slots before autosave: sanitizeMoaRefsForSave() strips
   reference slots with empty model before any autosave (the 600ms debounce
   was sending half-filled provider-but-no-model slots to the backend, where
   _clean_slot rejects them and _normalize_preset falls back to hardcoded
   defaults). Presets with zero valid refs keep their empty reference_models
   array rather than being silently dropped. The aggregator slot is also
   sanitized when its model is empty.

2. Add withActive() to MoA provider dropdowns: the reference and aggregator
   provider Selects filtered to authenticated-only, so unauthenticated
   current values (e.g. openai-codex) rendered blank. Mirror the existing
   pattern from the model Select.

3. Add generation counter to scheduleMoaSave(): stale save responses could
   overwrite newer state. Bump a counter on each save and skip setMoa/setError
   if a newer save was scheduled in the meantime.
2026-07-15 09:50:08 -07:00
Teknium f8630a1456 chore: add Burgunthy to AUTHOR_MAP (PR #20096 salvage) 2026-07-15 09:50:05 -07:00
Teknium 647520f83e fix(gateway): gate profile routing on multiplex_profiles + widen batch-key routing to all adapters
Follow-ups on the salvaged #20096 profile-routing feature:

- _profile_name_for_source now returns None unless gateway.multiplex_profiles
  is on. Routing stamps source.profile, which namespaces session/batch keys,
  but the profile-scoped agent run only activates under multiplexing — without
  the gate, configured routes with multiplexing off split batch/session keys
  into agent:<profile> while the agent still ran from agent:main.
- Widen the profile-aware _text_batch_key fix from Discord to every adapter
  that builds batch keys via build_session_key (telegram, whatsapp, matrix,
  feishu, wecom, weixin) — routing is platform-generic, so the batch-key
  namespace fix must be too.
- Downgrade the no-route-matched log from INFO to DEBUG (fired on every
  unrouted inbound message).
- GatewayConfig.to_dict(): serialize profile_routes as plain dicts
  (ProfileRoute dataclasses are not JSON-safe).
- Docs: correct the 'independent of multiplexing' claim in
  docs/profile-routing.md (routing requires multiplexing), fix the
  platform-only specificity row (0, not 1), and document profile_routes in
  website/docs/user-guide/multi-profile-gateways.md.
- Tests: pin the multiplex gate (routes ignored when off, active when on,
  build_source end-to-end stays in agent:main when off).
2026-07-15 09:50:05 -07:00
BurgunthyandClaude c29ab7b9fe test(gateway): adapter→session-key integration for Discord + Telegram
Completes the review's ask for "adapter-to-session-key integration coverage
for Discord and a non-Discord platform" on #20096.

Drives a concrete adapter's real BasePlatformAdapter.build_source with an
injected gateway_runner, asserts the matched route's profile is stamped on
the source, and that build_session_key scopes the key under agent:<profile>:
(versus the shared agent:main: namespace). Covers Discord and Telegram — the
Telegram case is the bug-#2 path that previously fell through to default.
Adds a regression anchor: without gateway_runner, profile stays None and the
key lands in agent:main (the silent fallback the fix removes for non-Discord).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-15 09:50:05 -07:00
BurgunthyandClaude f58e4622cf fix(gateway): profile routing — conjunctive matching + universal gateway_runner
Addresses hermes-sweeper review on #20096.

Problem 1 (profile_routing.py): route matching returned True on a chat_id
hit before the guild_id constraint was consulted, so a route declaring both
guild_id and chat_id matched on chat_id alone. Restored conjunctive (AND)
semantics — every declared discriminator must hold; hierarchical parent_chat_id
matching is preserved. Added a regression test for the guild+chat case.

Problem 2 (base.py / run.py): gateway_runner was injected only when an adapter
pre-declared the attribute, and only Discord did — so build_source never called
_profile_name_for_source for Telegram/Feishu/Slack/etc., despite the
platform-generic claim. Declared gateway_runner on BasePlatformAdapter and made
the plugin-registry injection unconditional, so profile routing now reaches
every platform. Added non-Discord (Telegram) resolution coverage and an
injection-inheritance test.

Also adds docs/profile-routing.md documenting gateway.profile_routes
(matching rules, specificity, profile isolation) — requested in review.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-15 09:50:05 -07:00
BurgunthyandClaude Opus 4.7 e8b7ce8c19 fix(session): persist profile_name and route batch key by profile
Two follow-ups observed after deploying profile routing:

1. sessions.profile_name was NULL even when the agent ran inside the
   routed profile scope. _insert_session_row never wrote it,
   get_or_create_session / reset_session never passed it through, and
   the agent-side _ensure_db_session fallback had no way to read it.
   - Declare profile_name TEXT in SCHEMA_SQL so _reconcile_columns
     auto-adds it on existing DBs.
   - _insert_session_row takes profile_name and writes it.
   - SessionStore passes source.profile (or old_entry.origin.profile
     on reset) into db_create_kwargs.
   - _ensure_db_session reads the active profile via
     get_active_profile_name() inside _profile_runtime_scope.

2. DiscordAdapter._text_batch_key called build_session_key without
   profile=, so the batch key always landed in agent:main even when
   the routed profile differed — diverging from the agent session
   key namespace (agent:crypto-trader, agent:ai-expert, ...).
   Pass event.source.profile through so both namespaces agree.

Live verification (jth-server-2, 2026-06-28): a test message in a
routed #coin thread produced agent:crypto-trader:discord🧵...
in the batch log and profile_name=crypto-trader in the sessions
row. Default-routed chat still produced agent:main / NULL.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-15 09:50:05 -07:00
BurgunthyandClaude Opus 4.7 d7993ab178 fix(config): honor gateway.multiplex_profiles nested form
load_gateway_config only forwarded the top-level multiplex_profiles
key, ignoring the nested gateway.multiplex_profiles form. The latter
is what `hermes config set gateway.multiplex_profiles true` writes,
so users who ran that command got multiplex_profiles=False silently
— no warning, no fallback, profile_routes just stopped matching.

Loader now checks the top-level key first, falls back to the nested
gateway section, and only then defaults to False. Same precedence is
applied to other nested-form keys (profile_routes already did this).

Tests cover: top-level honored, nested honored (regression test for
the silent-fallback bug), default False, top-level overrides nested.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-15 09:50:05 -07:00
BurgunthyandClaude Opus 4.7 a1d6654264 fix(gateway): read adapter token from config for fingerprint check
_adapter_credential_fingerprint only looked at adapter.token directly,
but Discord (and similar) adapters store the bot token on their config
sub-object, not on self. Every Discord adapter in a multiplexed
gateway therefore returned None, the same-token conflict check was
silently skipped, and N adapters all polled the same bot token —
producing a per-message race where whichever adapter won the GIL
answered the user.

Adds a config-token fallback (token, then bot_token) so the check
actually fires for config-backed adapters. Direct adapter.token
still takes precedence when both exist.

Tests cover: config-backed token produces a fingerprint, distinct
tokens produce distinct fingerprints, direct token wins over config,
config without token attributes returns None.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-15 09:50:05 -07:00
BurgunthyandClaude Opus 4.7 a55523fd6d refactor(profiles): drop dead helpers from hermes_constants
STANDARD_PROFILES, normalize_profile, validate_profile_name, and
is_standard_profile in hermes_constants were superseded by
hermes_cli.profiles.{normalize_profile_name, validate_profile_name}
but never removed. profile_routing.py is updated to import from the
canonical location; the old helpers are deleted.

Lazy import inside parse_profile_routes avoids the circular dependency
at module load time (hermes_constants -> hermes_cli -> hermes_constants).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-15 09:50:05 -07:00
BurgunthyandClaude Opus 4.7 9166d727b8 fix(profile-routing): remove dead forum cache, warn on missing profile
Three follow-ups to the initial routing PR after code review:

1. Remove dead forum-post hierarchy cache. The `_forum_post_cache`,
   `register_forum_post()`, and `resolve_forum_channel()` were never
   wired up — no caller in the codebase. Discord's adapter already
   sets `parent_chat_id` to the immediate parent (forum channel for a
   forum post), so the existing `self.chat_id == parent_chat_id`
   branch in `matches()` handles forum posts correctly without a
   cache. The hierarchical-resolution branch in `matches()` and the
   bounded-LRU infrastructure are removed.

2. Fix docstring specificity numbers (8 → 14, 4 → 6) and rewrite the
   "Hierarchical matching" section to describe the actual one-level
   parent_chat_id behavior. Removed unused `OrderedDict` and `Set`
   imports.

3. Warn loudly when a routed profile doesn't exist on disk. Previously,
   a typo in `profile_routes` (e.g. `crypto-tradr`) silently fell back
   to the global HERMES_HOME, causing the message to read the default
   profile's memory/credentials with no signal to the operator. Now
   emits a `logger.warning` with the profile name, source identifier,
   and the fallback reason. Bare-exception path also gets `exc_info`.

Tests:
- test_profile_routing.py: +2 tests verifying forum post matching via
  direct parent_chat_id (covers the case the removed cache was meant
  for). 31 total, all pass.
- test_profile_resolution.py: NEW, 12 tests covering resolution order
  (source.profile > routing > active > default), missing-profile
  warning, exception handling, and routing consultation. All pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-15 09:50:05 -07:00
BurgunthyandClaude Opus 4.7 5e65f6d79f feat(gateway): add profile-based routing for inbound messages
Adds gateway.profile_routes config that routes specific Discord
guilds/channels/threads (and other platforms) to different profiles.
The routing engine uses hierarchical specificity matching
(thread > channel > guild) with bounded LRU caching for forum post
resolution.

Routing result is stamped on source.profile by BasePlatformAdapter
.build_source() at inbound time. When gateway.multiplex_profiles is on,
the existing _profile_runtime_scope machinery picks up source.profile
and runs the whole turn inside the profile's HERMES_HOME — so memory,
skills, config, and secrets all resolve to that profile automatically.
No new isolation code is added; this PR only adds the routing decision
layer on top of the existing multiplexing infrastructure.

Configuration:

    gateway:
      multiplex_profiles: true
      profile_routes:
        - name: server-default
          platform: discord
          guild_id: "GUILD_ID"
          profile: server-profile
        - name: special-channel
          platform: discord
          guild_id: "GUILD_ID"
          chat_id: "CHANNEL_ID"
          profile: channel-profile

When multiplex_profiles is off, profile_routes is ignored (no behavior
change for single-profile gateways).

Tests: 29 unit tests covering specificity scoring, hierarchical
matching, path-traversal validation, and config parsing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-15 09:50:05 -07:00
Teknium 6efec39ecf refactor(skills): rework blender-mcp skill around the catalog MCP entry (#65066)
The optional blender-mcp skill predates the blender MCP catalog entry
(#64463) and taught the agent to hand-roll raw TCP JSON to the addon's
socket on port 9876 from execute_code — bypassing the catalog's version
pinning and install-time tool curation.

Reworked to v2.0.0 as the companion skill for the catalog entry:
- prerequisites now go through 'hermes mcp install blender'
- interaction surface is the four curated MCP tools, not a raw socket
- keeps the valuable content: addon setup, bpy recipes (materials,
  keyframes, render-to-file), pitfalls (timeouts, absolute paths,
  object mode), plus new pitfalls (xvfb headless, no-sandbox warning,
  remote-host path resolution)
- explicit anti-pattern note: do not hand-roll TCP to 9876
- description shortened to <=60 chars per skill authoring standards

alireza78a's original bpy patterns and pitfalls are preserved and
credited. Docs page regenerated via generate-skill-docs.py (scoped to
this skill only; unrelated generator drift left untouched).
2026-07-15 09:49:42 -07:00
liuhao1024 6a8e7069b4 fix(agent): dedup codex incomplete interims on visible content
Two consecutive incomplete assistant interims with identical visible
content (content + reasoning) are collapsed even when opaque provider
state (encrypted reasoning item ids, message item phases) drifts per
continuation — previously that drift defeated dedup and caused message
storms (#52711). The latest opaque payload is written onto the existing
message in place, so continuation replay still uses fresh provider
state.

Salvage note: the original PR also made the 3-retry continuation cap
cumulative per turn (no reset on progress). That half is intentionally
dropped — a legitimate long turn alternating incomplete/progress would
hard-fail at 3 cumulative, changing semantics beyond the reported bug.
2026-07-15 09:49:11 -07:00
Teknium 09b6d22dfe fix(gateway): harden hidden-incomplete detection against the sentinel final_response
Follow-up to the salvaged #51657: the conversation loop returns the
retry-exhaustion sentinel as BOTH final_response and error, so the
original detector (which required final_response to be falsy) never
fired on real exhaustion turns — the sentinel text was delivered
verbatim into the channel, exactly the #51628 poisoning vector. Detect
the sentinel echo, blank it before empty-response normalization, and
never suppress a turn whose final_response is genuine model text.
Also: dedupe-guard mock fix in the test fixture (has_platform_message_id
must return False, not a truthy MagicMock) and two guard tests
(real answer never suppressed; interrupted/failed never classified).
2026-07-15 09:48:24 -07:00
LeonSGP43 08015c3a8f fix(gateway): suppress hidden-only incomplete codex turns 2026-07-15 09:48:24 -07:00
Sk fe1ab949fd fix(agent): treat Codex incomplete content filter as refusal
Map Codex Responses status=incomplete with incomplete_details.reason=content_filter to finish_reason=content_filter so the existing refusal/fallback path runs instead of burning incomplete continuation attempts.
2026-07-15 09:47:37 -07:00
Teknium 704bbcca80 feat(compressor): keep image URLs and unknown-part markers in summarizer serialization
Follow-up on @AlexFucuson9's cherry-picked multimodal flattening:

- http(s) image parts render as '[image: <url>]' so the summary keeps a
  referenceable handle after compaction (base64 data: URLs still
  collapse to '[image]' — no reusable reference, and leaking them is
  the bug being fixed)
- unknown part types (document, future shapes) render as '[<type>]'
  instead of being silently dropped
- test docstrings corrected: the original crash premise is stale
  (redact_sensitive_text grew str() coercion in #52147); the live bug
  is repr-noise + base64 leakage into the summarizer input
2026-07-15 09:47:04 -07:00
AlexFucuson9 868a2f7d17 fix: handle list content in _serialize_for_summary for multimodal messages
msg.get('content') can return a list of parts for multimodal messages
(containing text, images, etc.). The old code passed this list directly
to redact_sensitive_text(text: str), which raised AttributeError on
list.replace(), causing context compression to fail entirely for any
session with attached images.

Fix: detect list content and extract text parts before redacting.
Image parts are replaced with '[image]' placeholder.
2026-07-15 09:47:04 -07:00
ethernet 1600008ab0 fix(desktop): show +/- summary on collapsed review folders
ReviewDirRow never rendered a DiffCount, so collapsed folders gave no
indication of the additions/deletions inside them. The tree builder
already aggregates added/removed onto directory nodes (verified by
tree-data tests) — this just renders them, matching the file rows.
2026-07-15 11:57:33 -04:00
ethernet 1d48863b85 fix(desktop): inline git identity in worktree tracking test for CI
CI runners have no global git identity. The remote repo seed commit
needs inline -c user.email/user.name flags, matching the pattern
already used by ensureGitRepo.
2026-07-15 11:46:29 -04:00
ethernet 678b86df1c fix(desktop): worktree from origin/main should not set up upstream tracking
When "new worktree" branches off a remote-tracking ref like origin/main,
`git worktree add -b <branch> <dir> origin/main` auto-sets upstream
tracking (branch → origin/main), producing `branch:origin/main` in branch
listings. The user wants a standalone local branch — like `git checkout
origin/main && git checkout -b branch` — not one silently wired to the
remote. Add `--no-track` when the base is an `origin/` ref. Local branch
bases are unaffected (they never triggered tracking).
2026-07-15 11:46:29 -04:00
Teknium 07be37d996 fix(auxiliary_client): warn once + regression tests for bootstrap version skew
Follow-up on the salvaged fallback: silent degradation is how #64333 went
unnoticed (jobs dead on arrival, only errors.log knew). Warn once with a
resync hint, and cover both the skewed and healthy paths with tests.
2026-07-15 07:47:18 -07:00
liuhao1024 f3ec79964e fix(auxiliary_client): add backward compatibility for build_keepalive_http_client import (#64333) 2026-07-15 07:47:18 -07:00
HexLab98 2388e0687b test(input): preservation regressions and prompt.submit boundary (#62557)
Add cases for mid-string markers, trailing punctuation, and insufficient
tail repeats; verify prompt.submit passes sanitized text to run_conversation.
2026-07-15 07:39:42 -07:00
HexLab98 1011cd24e2 fix(input): strip bracketed-paste leaks before prompt persistence (#62557)
Extract shared hermes_cli/input_sanitize.py (bracketed-paste wrapper stripping
and terminal ~[[e artifact suffix collapse) and wire it into prompt.submit
and the Desktop composer so corrupted user text is cleaned before
messages.content is persisted.
2026-07-15 07:39:42 -07:00
Teknium 7c954969b7 fix(auxiliary): route direct-create aux callers through call_llm (#65029)
* fix(auxiliary): route direct-create aux callers through call_llm (#35566)

Five callers (kanban_decompose, kanban_specify, profile_describer, and
goals.py's judge + draft-contract) built raw clients via
get_text_auxiliary_client() and passed extra_body=get_auxiliary_extra_body()
— which only returns Nous portal tags and ignores
auxiliary.<task>.extra_body from config.yaml entirely. That was the
remaining half of #35566 after the call_llm path was fixed.

Routing them through call_llm(task=...) gives each caller the full
auxiliary contract for free: task extra_body, the reasoning_effort
shorthand, transient retries, provider-profile projection, and fallback
chains. goal_judge gains a DEFAULT_CONFIG block (it had none — its
provider/model overrides silently didn't exist as documented keys).

get_auxiliary_extra_body() now has zero non-test callers; kept for
plugin back-compat.

Fixes #35566.

* test: migrate kanban dashboard + CLI specify mocks to call_llm

Two more consumers of specify_task mocked the old
get_text_auxiliary_client symbol (missed in the first sibling sweep —
they live outside tests/hermes_cli's kanban files): the dashboard
plugin's /specify endpoint tests and the /kanban slash-command E2E.
Same migration as the rest: mock call_llm at the source, no-provider
now surfaces via the LLM-error branch.
2026-07-15 07:39:17 -07:00
Teknium 306e2d2318 chore: AUTHOR_MAP entry for Epoxidex (PR #29820 salvage) 2026-07-15 06:38:28 -07:00
Epoxidex 8662254ab2 fix(ollama): emit top-level reasoning_effort=none on /v1/chat/completions (#25758)
Ollama's /v1/chat/completions silently ignores extra_body.think (it only
honours it on /api/chat — ollama/ollama#14820), so agent.reasoning_effort:
none never actually disabled thinking on OpenAI-compatible Ollama routes.
Emit the top-level reasoning_effort='none' field (which Ollama respects)
alongside think=False (kept for proxies and the native /api/chat path).

The PR's second half (propagating reasoning_config to the background-review
fork) already landed on main via agent/background_review.py, so only the
provider-profile change is salvaged here, resolved onto the current
GLM/effort-aware profile.

Salvaged from PR #29820 by @Epoxidex.
2026-07-15 06:38:28 -07:00
Teknium a7ef17da7a chore(release): map dorokuma in AUTHOR_MAP 2026-07-15 06:25:10 -07:00
Teknium 31dcd68bfa test: cover Anthropic aux extra_body passthrough (Bug B scope + exclusions)
Five tests for the salvaged #37217 Bug B fix: vendor-field passthrough,
reasoning-key + private-key exclusion, merge-over-existing (fast-mode
speed), no-extra_body regression guard, reasoning-only adds nothing.

Live probes against api.anthropic.com informed the exclusion design:
Anthropic strictly validates the request body (unknown keys 400 with
'Extra inputs are not permitted'), so the passthrough forwards only
caller-configured fields and never the OpenAI-shaped reasoning dict
(translated natively) or _-private plumbing keys.
2026-07-15 06:25:10 -07:00
dorokuma 771571aee4 fix(auxiliary): pass reasoning_config and extra_body through to auxiliary Anthropic calls
Two related bugs in _AnthropicCompletionsAdapter.create() in
agent/auxiliary_client.py silently discard caller-supplied
reasoning_config and extra_body on the Anthropic-Messages
auxiliary-protocol path:

  * Bug A: reasoning_config=None was hardcoded at L1000, so the
    reasoning_config parameter on build_anthropic_kwargs was
    unreachable for any auxiliary task. The main agent path
    (agent/transports/anthropic.py) already reads
    reasoning_config from caller params; this PR aligns the
    auxiliary adapter with the same pattern.

  * Bug B: create(**kwargs) accepts an OpenAI-style kwargs
    payload from the caller but only forwards a hand-picked
    subset to self._client.messages.create(). Any caller-supplied
    extra_body (e.g. thinking control, metadata, service_tier,
    vendor-specific fields) was dropped on the floor. The
    codex/responses transport in the same file already merges
    extra_body; the Anthropic branch is the gap.

This unlocks the caller-supplied extra_body path so auxiliary
callers can set per-vendor request fields (including
thinking: {type: "disabled"} for Anthropic-compatible vendors
that require an explicit disable on the wire), and lets the
reasoning_config kwarg flow into build_anthropic_kwargs like the
main agent does. Both changes are backward-compatible for
callers that don't pass the affected kwargs.

Affected providers (all routed through _AnthropicCompletionsAdapter
via _maybe_wrap_anthropic): anthropic (native), minimax /
minimax-cn, kimi-coding / kimi-coding-cn, z.ai / GLM, and any
custom /anthropic-suffixed endpoint. See PR description for
related issues (#35566, #7209, #16533, #32813, #29248).
2026-07-15 06:25:10 -07:00
Teknium 9df5f879b4 feat(mcp): enforce exact version pins across the whole MCP catalog
Catalog entries now follow the same supply-chain rules as pyproject
dependencies:

- n8n: install.ref main -> full commit SHA 7a9ae007 (2026-05-23,
  branches/tags can be moved by the upstream owner; SHAs cannot)
- new contract test: every shipped manifest must pin exactly —
  git installs need a 40-char SHA, uvx/npx-style launchers need
  pkg==X / pkg@X with a digit-leading version (rejects bare names,
  ranges, and npm dist-tags like @latest)
- module docstring documents the pin policy (exact version, 2-week
  cooldown)

unreal-engine and linear are http transports (server runs elsewhere)
so there is nothing to pin at the transport layer.

Verified: unpinning blender-mcp in the manifest makes the contract
test fail with a named diagnostic; restoring the pin passes.
2026-07-15 04:56:25 -07:00
Teknium a52393a3b6 fix(mcp): pin blender-mcp to 1.6.4 per catalog dependency policy
MCP catalog entries follow the same supply-chain rules as pyproject
dependencies: exact version pin, and the pinned release must be at
least 2 weeks old. blender-mcp 1.6.4 released 2026-06-11 (~5 weeks
old, also the latest release). uvx now resolves the exact version
instead of latest-at-launch.
2026-07-15 04:56:25 -07:00
Teknium 9be941dac1 feat(mcp): add Blender to the MCP catalog with a curated 4-tool default
Adds optional-mcps/blender (ahujasid/blender-mcp, stdio via uvx). The
server advertises 22 tools; 18 front optional asset services with no
upstream trim mechanism, so tools.default_enabled pins the install to
the core surface (scene/object info, viewport screenshot, code exec)
and the rest stay opt-in through 'hermes mcp configure blender'.

Manifests can now declare transport.env (static, non-secret subprocess
env vars), parsed/validated in _parse_manifest and written by
_build_server_config — used here to ship DISABLE_TELEMETRY=true per
the no-telemetry-without-opt-in policy. Runtime already honored
per-server env; manifests just couldn't declare it.
2026-07-15 04:56:25 -07:00
Teknium 7af59f474d fix(codex): raise hard-ceiling default above the max stale floor
With the TTFB watchdog now scaled (not disabled) for large requests on
main, the hard ceiling is a backstop against mid-stream wedges, not the
primary stall detector. The original 600s default clamped BELOW the
intentional 1200s stale floor for >100k-token requests, partially
reverting the floor that keeps healthy gateway-scale payloads alive.
Raise the default to 1500s so the ceiling only catches unbounded
growth, never healthy slow turns.
2026-07-15 04:55:48 -07:00
Ahmett101 bcd7e2ce89 fix(agent): add finite hard ceiling on openai-codex request time (#64507)
A large Codex request (estimated context >= 10k tokens) disables the no-byte
TTFB watchdog on purpose, and openai_codex_stale_timeout_floor *raises* the
stale timeout (up to 1200s at >100k tokens) so healthy gateway-scale payloads
aren't aborted mid-prefill. When the backend genuinely stalls — no first byte
AND no events, exactly the #64507 symptom — the request is only reclaimed at
that high stale floor, so the session can hang for 13+ minutes with an idle
slash_worker and no ended_at/end_reason while Desktop still shows it as active.

Add a flat, finite hard ceiling on total openai-codex request time that always
applies (min() of the computed stale timeout and the ceiling) regardless of the
TTFB-disable / stale-floor interaction. A stalled large request is now killed
at the ceiling and the retry loop / visible failure path takes over instead of
hanging indefinitely. Tunable via HERMES_CODEX_HARD_TIMEOUT_SECONDS (default
600s; 0 disables the ceiling to restore pre-fix behavior).

Closes #64507
2026-07-15 04:55:48 -07:00
liuhao1024 c2a3b9ce58 fix(state): use PASSIVE checkpoint for periodic WAL flush to prevent B-tree corruption
TRUNCATE checkpoint every 50 writes causes B-tree corruption on large
databases (65K+ pages) due to the exclusive-lock I/O pressure from
checkpointing thousands of frames at once.

Switch periodic _try_wal_checkpoint() to PASSIVE mode which does not
require an exclusive lock and cannot corrupt pages under I/O pressure.
Keep TRUNCATE in close() and pre-VACUUM paths where it is safe
(infrequent, controlled conditions).

Also replace silent `except Exception: pass` with logged warnings for
checkpoint failures so operators can detect early corruption signals.

Fixes #45383
2026-07-15 04:55:46 -07:00
Frowtek 779c0dd80d fix(compressor): unwrap web_extract dict URLs in tool-result summaries
_summarize_tool_result() built the web_extract summary straight from the
first `urls` entry. When web_search results are forwarded into web_extract
(a common chain), that entry is a dict ({"url"/"href": ...}) rather than a
URL string. With 2+ URLs the `url_desc += " (+N more)"` step then raised
`TypeError: unsupported operand type(s) for +=: 'dict' and 'str'`, aborting
the pre-compression pruning pass; with a single URL the raw dict repr
leaked into the summary text.

Unwrap the URL from dict entries before use — mirroring the existing
web_extract handling in agent/display.py (`_display_url`),
tools/web_tools.py (`_web_extract_url`) and acp_adapter/tools.py
(`build_tool_title`) — so `url_desc` is always a string and the
concatenation is always str + str.

Adds regression tests covering multiple/single dict URLs, the href key,
malformed dicts, and the plain-string path.
2026-07-15 04:55:36 -07:00
Teknium 8fa8aabbbb test(codex): pin codex_backend issuer in xai-scoped salvage test (#64844)
test_normalize_codex_response_salvage_is_xai_scoped broke on main when
two same-day merges crossed: #64764 (#64434 — trust response.status for
reasoning-only turns on UNRECOGNIZED Responses backends) changed what a
bare _normalize_codex_response(response) call returns for
status='completed' reasoning-only output (now 'stop'), while #64768
added this test calling with no issuer_kind and expecting 'incomplete'.

The test's intent is that the xAI reasoning-channel salvage does not
leak into other special-cased backends — pin issuer_kind='codex_backend'
so it exercises exactly that (same pattern as
test_normalize_codex_response_treats_summary_only_reasoning_as_incomplete,
which was already pinned for #64434).
2026-07-15 04:27:32 -07:00
HexLab98 1b059d1ae7 test(config): regression for merge_existing partial saves (#62723) 2026-07-15 04:26:20 -07:00
HexLab98 0ab90040ad fix(config): preserve platforms on partial save_config writes (#62723)
Add merge_existing to save_config (default False for full-document callers
like the dashboard YAML editor) and route partial writes through
_merge_partial_save. _persist_migration writes the full migrated dict
directly so deleted keys are not resurrected from the on-disk file.
2026-07-15 04:26:20 -07:00
Brooklyn Nicholson b5aef05e2c fix(desktop): layout reset reopens collapsed sidebars
resetLayoutTree() claims to "restore everything" but never reopened collapsed
SIDES. A sidebar hidden before a reset survived it, so the next ⌘B toggled from
that stale-hidden state into a SHOW — and the user's hide never persisted across
reload. Reopen every bound side through its store so the toggles stay truthful.

Adds a store-level regression test (real modules, re-import = reload) covering
hide→reload and the reset→hide→reload repro.
2026-07-15 03:30:25 -04:00
Brooklyn Nicholson c18edf4ac0 Merge remote-tracking branch 'origin/main' into bb/contrib-areas 2026-07-15 03:18:22 -04:00
Brooklyn Nicholson 305e26558e fix(desktop): composer progressively collapses on narrow tiles
Drive the composer's layout off its OWN measured width (the existing
ResizeObserver, so it reacts per-tile, not per-viewport) instead of only
stacking or overflowing:

- >=440px: full inline row, full model label.
- 320-440px: still inline, but the model pill sheds its label for its chevron
  icon (~120px back) so the controls stop crowding the placeholder.
- <320px: stacks to the two-row layout, pill stays iconized.

Adds COMPOSER_COMPACT_PILL_PX above the stack breakpoint and a `compactPill`
signal from useComposerMetrics, wired to the model pill's existing compact mode.
2026-07-15 03:17:50 -04:00
Brooklyn Nicholson 092a97ef75 fix(desktop): session-tab drag, focus sync, and pop-out isolation
Make every session tab speak the same drag language as a sidebar row, and
keep tab focus 1:1 across the sidebar, tiles, and main:

- Drag a session tile's OR the main workspace tab onto a composer to link the
  chat (@session chip), or onto a zone/edge to stack/split — tabDrag now
  returns whether it took the drag, so the workspace tab defers to the generic
  pane move on a fresh draft (nothing to link).
- A lone tool panel (terminal/logs) dragged to its own zone keeps its header,
  so it stays draggable/closable instead of stranding a dead, tab-less zone.
- The sidebar highlight follows the FOCUSED session (interacted tile, else the
  main selection) rather than only the main one.
- Clicking an already-open session jumps to its tab — an open tile, or the
  workspace tab when it's the main session and focus sits on a tile — instead
  of a dead no-op reload.
- Secondary (single-chat pop-out) windows boot to the default tree with no
  tiles and never persist their stripped-down layout back, so they can't
  inherit or clobber the primary window's tabs/splits.

The pointer drag ghost is extracted to a shared lib/drag-ghost and the drop
affordances drop their now-unused labels.
2026-07-15 03:13:33 -04:00
Brooklyn Nicholson db488df1c3 Merge origin/main into bb/contrib-areas 2026-07-15 02:30:17 -04:00
Brooklyn Nicholson 798e602a8e feat(desktop): tool panels collapse to a persistent rail, ✕ removes them
Terminal and logs now follow the IntelliJ/VS-Code tool-window model: their
toggle (⌃`, ⌘K) COLLAPSES the zone to a rail with the tab still showing instead
of hiding it outright, so "toggle" and "the tab bar" stop fighting. Restore
routes through the pane's store opener (rail click / chevron) so the shortcut and
titlebar toggle stay truthful; the tab's ✕ dismisses the panel (comes back via
its toggle), while a session tile's ✕ still closes the session. New store
primitives (setPaneCollapsed / restoreTreePane / collapseTreePane + a
collapse-pane registry) via a bindPaneCollapse in the controller.
2026-07-14 21:06:40 -04:00
Brooklyn Nicholson ec926ce89e fix(desktop): full-reset the thread runtime on a disjoint transcript swap
The incremental external-store runtime reconciles message repositories in place
(addOrUpdateMessage + prune-non-incoming). On a session switch the incoming
transcript shares no ids with the current one, and grafting the new chain onto
the old tree before pruning can strand a stale head/branch — the thread keeps
showing the previous session. When nothing carries over there's nothing to
preserve, so clear the tree first (leaves→root) then rebuild clean. Belt-and-
suspenders alongside the $messages-carryover fix.
2026-07-14 17:20:35 -04:00
Brooklyn Nicholson 7dc21f08a1 fix(desktop): clear the transcript on every cold resume so sessions can't share one
resumeSession hand-rolls $messages (it paints before a runtime id is bound), and
only cleared the old transcript on the cold path at entry. But a warm-cache hit
can bail down to the full resume — an empty-transcript drop, or the cache being
purged during the profile-swap await — without ever clearing, so the previous
session's array leaked into the next one. Symptom: switching sessions kept
showing the same messages (deterministic once tiling pre-warms the cache on
boot). Clear $messages at the single point every cold/bail path converges, so
carryover is structurally impossible; the warm fast-path still repaints in place.
2026-07-14 16:37:54 -04:00
Brooklyn Nicholson 5f37c9e85c refactor(desktop): tighten reasoning part typing, drop dead useRef
Self-review nits on the Thinking-widget fix:
- type ReasoningTextPart as ReasoningMessagePartComponent and read the
  typed useMessagePartReasoning() directly, dropping the ad-hoc cast
  (the hook already returns text/status).
- remove useRef, now unused after deleting useSmoothReveal.
- trim the autopsy comments; the PR body carries the narrative.
2026-07-13 20:38:34 -04:00
Brooklyn Nicholson 8bd4a419de fix(desktop): render reasoning text in the Thinking widget
The Thinking disclosure rendered blank for every reasoning-emitting model
(Fable, DeepSeek, GPT-5.5, ...). Two causes:

1. ReasoningTextPart read a `text` prop that assistant-ui never populates —
   reasoning parts arrive via context, same as text parts — so it always got
   an empty string. Read the text via useMessagePartReasoning() instead,
   mirroring how MarkdownText uses useMessagePartText().

2. The reasoning-only SmoothStreamingText / useSmoothReveal layer stalled at
   revealed="": the reasoning part stays isRunning for the whole message while
   the answer streams and thrashes re-renders, so the char-reveal never
   advanced past 0. Render reasoning through the same DeferStreamingText →
   surface path the assistant answer uses, and drop the dead smoothing code.
2026-07-13 17:57:54 -04:00
Brooklyn Nicholson d14bf23fa3 chore(desktop): build config — keep tsc emit out of src, gitignore artifacts 2026-07-13 17:57:54 -04:00
Brooklyn Nicholson a57b33782e docs(desktop): hermes-desktop-plugins skill + starter template 2026-07-13 17:57:54 -04:00
Brooklyn Nicholson aff205dcc2 chore(desktop): i18n strings for tabs, zones, and session menus 2026-07-13 17:57:54 -04:00
Brooklyn Nicholson 369d0eeeff refactor(desktop): retire desktop-controller for the contribution shell; views as contributions 2026-07-13 17:57:54 -04:00
Brooklyn Nicholson 10fbade64b feat(desktop): electron — openDir IPC + ⌘W menu bridge (tabs, not windows) 2026-07-13 17:57:54 -04:00
Brooklyn Nicholson 7f74b324c3 feat(desktop): store + lib — layout/preview/session atoms, escape-layers, keybind helpers 2026-07-13 17:57:54 -04:00
Brooklyn Nicholson 0f922002eb feat(desktop): contribution controller, surfaces, and wiring 2026-07-13 17:57:54 -04:00
Brooklyn Nicholson 0f398f8e9c feat(desktop): focused-session-aware titlebar + statusbar 2026-07-13 17:57:54 -04:00
Brooklyn Nicholson 2afbe77763 feat(desktop): session hooks — open-in-tile, per-session actions, resilient resume 2026-07-13 17:57:54 -04:00
Brooklyn Nicholson 860a3f67bb feat(desktop): chat view — drop overlays, composer scoping, tile integration 2026-07-13 17:57:54 -04:00
Brooklyn Nicholson eae1d7d147 feat(desktop): ⌘W close-tab, ⌘⇧T reopen, ⌘T new tab, ⌘1-9 + ⌃Tab tab switching 2026-07-13 17:57:54 -04:00
Brooklyn Nicholson ac4f596ca2 feat(desktop): pointer session drag/drop + row/tab menus with close others/right/all 2026-07-13 17:57:54 -04:00
Brooklyn Nicholson e6fea77d14 feat(desktop): multi-session tiles — per-profile state, tile pane, pane mirror 2026-07-13 17:57:54 -04:00
Brooklyn Nicholson f1379bd6c2 feat(desktop): routes, nav, and command palette as contributions 2026-07-13 17:57:54 -04:00
Brooklyn Nicholson aae35c5ee4 feat(desktop): shared UI — per-session prompt overlays, gateway overlays, tab primitives 2026-07-13 17:57:54 -04:00
Brooklyn Nicholson 63a9bde77b feat(desktop): layout-tree renderer — splits, zones, pointer drag-session, tab strip 2026-07-13 17:57:54 -04:00
Brooklyn Nicholson c388daa665 feat(desktop): layout-tree model + store + workspace geometry 2026-07-13 17:57:54 -04:00
Brooklyn Nicholson 7ed7170960 feat(desktop): plugin manager, runtime loader, and plugins settings 2026-07-13 17:57:53 -04:00
Brooklyn Nicholson aefb36299a feat(desktop): contribution registry — namespaced areas, keybinds, palette 2026-07-13 17:57:53 -04:00
Brooklyn Nicholson 99ff67eb03 feat(desktop): plugin SDK surface — rest door, socket, react-query, UI kit 2026-07-13 17:57:53 -04:00
295 changed files with 24309 additions and 4512 deletions
+11
View File
@@ -68,6 +68,17 @@ environments/benchmarks/evals/
hermes_cli/web_dist/
apps/desktop/build/
apps/desktop/dist/
# tsc-emitted artifacts (a stray `tsc -b` compiles into src/, and vite then
# resolves the stale .js OVER the .tsx — never track these)
apps/desktop/src/**/*.js
apps/desktop/src/**/*.js.map
apps/desktop/src/**/*.d.ts
!apps/desktop/src/global.d.ts
!apps/desktop/src/vite-env.d.ts
apps/shared/src/**/*.js
apps/shared/src/**/*.js.map
apps/shared/src/**/*.d.ts
apps/desktop/release/
*.tsbuildinfo
+112 -7
View File
@@ -44,6 +44,7 @@ import contextlib
import json
import logging
import os
import re
import threading
import time
from pathlib import Path # noqa: F401 — used by test mocks
@@ -102,7 +103,6 @@ OpenAI = _OpenAIProxy() # module-level name, resolves lazily on call/isinstance
from agent.credential_pool import load_pool
from agent.model_metadata import MINIMUM_CONTEXT_LENGTH, get_model_context_length
from agent.process_bootstrap import build_keepalive_http_client
from hermes_cli.config import get_hermes_home
from hermes_constants import OPENROUTER_BASE_URL
from utils import base_url_host_matches, base_url_hostname, env_float, model_forces_max_completion_tokens, normalize_proxy_env_vars
@@ -156,22 +156,47 @@ def _resolve_aux_verify(base_url: Optional[str]) -> Any:
return True
_WARNED_KEEPALIVE_IMPORT_SKEW = False
def _openai_http_client_kwargs(
base_url: Optional[str],
*,
async_mode: bool = False,
) -> Dict[str, Any]:
"""Inject keepalive httpx client with env-only proxy (not macOS system proxy)."""
client = build_keepalive_http_client(
str(base_url or ""),
async_mode=async_mode,
verify=_resolve_aux_verify(base_url),
)
try:
from agent.process_bootstrap import build_keepalive_http_client
client = build_keepalive_http_client(
str(base_url or ""),
async_mode=async_mode,
verify=_resolve_aux_verify(base_url),
)
except (ImportError, AttributeError):
# Version-skewed installs (#64333): a process whose sys.path resolves
# an older agent/process_bootstrap.py without this helper — seen when
# the Desktop app's bundled runtime lags a git-installed source tree
# that newer callers (cron scheduler) were written against. Every cron
# job died on this ImportError before any agent logic ran. Degrade
# gracefully to the OpenAI SDK's default httpx client (respects macOS
# system proxy, no pool-level keepalive expiry) instead of failing the
# whole job, and say so once — silent version skew is how this bug
# went unnoticed until jobs were already dead on arrival.
global _WARNED_KEEPALIVE_IMPORT_SKEW
if not _WARNED_KEEPALIVE_IMPORT_SKEW:
_WARNED_KEEPALIVE_IMPORT_SKEW = True
logger.warning(
"agent.process_bootstrap.build_keepalive_http_client is "
"unavailable — mixed/stale install detected (#64333). Falling "
"back to the SDK default HTTP client. Run `hermes update` (or "
"reinstall the Desktop app) to resync the runtime."
)
client = None
if client is None:
return {}
return {"http_client": client}
def _create_openai_client(*, api_key: str, base_url: str, **kwargs: Any) -> Any:
kwargs = {**_openai_http_client_kwargs(base_url), **kwargs}
# Hermes owns auxiliary retry + provider/model fallback policy (the
@@ -1337,6 +1362,31 @@ class _AnthropicCompletionsAdapter:
if not _forbids_sampling_params(model):
anthropic_kwargs["temperature"] = temperature
# Pass through caller-supplied extra_body so providers behind
# Anthropic-compatible gateways receive their per-vendor request
# fields (thinking control, metadata, portal tags, ...). The dict
# form is the documented Anthropic SDK passthrough for non-standard
# request body keys; merge on top of whatever build_anthropic_kwargs
# already produced (e.g. fast-mode ``speed``) so call-time settings
# survive. Two exclusions:
# - ``reasoning``: the OpenAI-shaped config dict is TRANSLATED into
# the native ``thinking`` field above (build_anthropic_kwargs);
# forwarding the raw field alongside would double-specify
# reasoning and 400 on strict gateways.
# - ``_``-prefixed keys: private Hermes plumbing (_reasoning_config
# et al.), never wire fields.
caller_extra_body = kwargs.get("extra_body")
if caller_extra_body and isinstance(caller_extra_body, dict):
passthrough = {
k: v for k, v in caller_extra_body.items()
if k != "reasoning" and not str(k).startswith("_")
}
if passthrough:
existing = anthropic_kwargs.get("extra_body") or {}
if not isinstance(existing, dict):
existing = {}
anthropic_kwargs["extra_body"] = {**existing, **passthrough}
response = create_anthropic_message(self._client, anthropic_kwargs)
_transport = get_transport("anthropic_messages")
_nr = _transport.normalize_response(
@@ -3644,6 +3694,40 @@ def _auth_refresh_provider_for_route(
return normalized
def _fallback_entry_timeout(task: Optional[str], fb_label: str) -> Optional[float]:
"""Resolve a per-entry ``timeout`` for a configured fallback candidate.
A fallback candidate previously inherited the exact timeout the primary
provider was called with. When that deadline was tuned for the primary
(or the primary simply consumed its whole budget before failing over),
the fallback aborted on the same clock even when independently healthy —
a 163k-token compression that needs ~90s on the fallback died at the
primary's 30s deadline every turn (#62452).
Entries in ``auxiliary.<task>.fallback_chain`` may declare their own
``timeout`` (seconds). This helper reads it by parsing the entry index
out of the label minted by :func:`_try_configured_fallback_chain`
(``fallback_chain[<i>](<provider>)`` — our own stable format). Returns
``None`` when the label is not a configured-chain candidate, the entry
has no ``timeout``, or the value is invalid — callers then keep the
task-level timeout, preserving existing behavior.
"""
if not task or not fb_label:
return None
m = re.match(r"fallback_chain\[(\d+)\]", fb_label)
if not m:
return None
try:
chain = _get_auxiliary_task_config(task).get("fallback_chain")
entry = chain[int(m.group(1))] if isinstance(chain, list) else None
raw = entry.get("timeout") if isinstance(entry, dict) else None
except Exception:
return None
if isinstance(raw, (int, float)) and not isinstance(raw, bool) and raw > 0:
return float(raw)
return None
def _call_fallback_candidate_sync(
fb_client: Any,
fb_model: Optional[str],
@@ -3672,7 +3756,20 @@ def _call_fallback_candidate_sync(
once with a rebuilt client; if the retry also auth-fails (non-refreshable
expired token), mark the provider unhealthy and return ``None`` so the
caller can continue to the next fallback layer. Non-auth errors raise.
``effective_timeout`` is the task-level deadline; a configured-chain
candidate with its own ``timeout`` entry gets that instead, so a
fallback tuned differently from the primary is allowed its own budget
(#62452).
"""
fb_timeout = _fallback_entry_timeout(task, fb_label)
if fb_timeout is not None and fb_timeout != effective_timeout:
logger.info(
"Auxiliary %s: %s using its configured timeout %.0fs "
"(task-level was %.0fs)",
task or "call", fb_label, fb_timeout, effective_timeout,
)
effective_timeout = fb_timeout
fb_base = str(getattr(fb_client, "base_url", "") or "")
fb_kwargs = _build_call_kwargs(
fb_label, fb_model, messages,
@@ -3731,6 +3828,14 @@ async def _call_fallback_candidate_async(
reasoning_config: Optional[dict],
) -> Optional[Any]:
"""Async mirror of :func:`_call_fallback_candidate_sync`."""
fb_timeout = _fallback_entry_timeout(task, fb_label)
if fb_timeout is not None and fb_timeout != effective_timeout:
logger.info(
"Auxiliary %s: %s using its configured timeout %.0fs "
"(task-level was %.0fs)",
task or "call", fb_label, fb_timeout, effective_timeout,
)
effective_timeout = fb_timeout
fb_base = str(getattr(fb_client, "base_url", "") or "")
fb_kwargs = _build_call_kwargs(
fb_label, fb_model, messages,
+10 -1
View File
@@ -528,10 +528,19 @@ def _convert_content_to_converse(content) -> List[Dict]:
mime_part = header[5:].split(";")[0]
if mime_part:
media_type = mime_part
# Decode base64 to raw bytes — boto3 re-encodes at the
# wire layer, so passing the base64 string directly
# results in double-encoding and Bedrock rejects it with
# "Failed to sanitize image". Ref: #33317.
import base64
try:
raw_bytes = base64.b64decode(data)
except Exception:
raw_bytes = data.encode("utf-8")
blocks.append({
"image": {
"format": media_type.split("/")[-1] if "/" in media_type else "jpeg",
"source": {"bytes": data},
"source": {"bytes": raw_bytes},
}
})
else:
+23
View File
@@ -504,6 +504,29 @@ def interruptible_api_call(agent, api_kwargs: dict):
if _codex_floor:
_stale_timeout = max(_stale_timeout, _codex_floor)
# ── Codex absolute hard ceiling (#64507) ──────────────────────────
# ``openai_codex_stale_timeout_floor`` *raises* the stale timeout (up to
# 1200s at >100k tokens) so healthy gateway-scale payloads aren't aborted.
# The scaled no-byte TTFB watchdog catches dead streams that never emit a
# first byte, but a request that emits SOME bytes and then wedges (the
# issue-64507 symptom: vision-inflated request, worker idle, no ended_at)
# is only reclaimed at the (high) stale floor. Add a flat, finite hard
# ceiling on total request time that ALWAYS applies to openai-codex
# requests regardless of the TTFB/stale interaction, so a stalled request
# is recovered (retry loop / visible failure) instead of hanging
# indefinitely. The default sits ABOVE the maximum stale floor (1200s) so
# it never clamps an intentionally-raised timeout for healthy large
# requests — it is a backstop against unbounded growth, not a tighter
# limit. Tunable via HERMES_CODEX_HARD_TIMEOUT_SECONDS (set to 0 to
# disable the ceiling entirely; that restores the pre-fix behavior).
_codex_hard_timeout = _env_float("HERMES_CODEX_HARD_TIMEOUT_SECONDS", 1500.0)
if (
_codex_watchdog_enabled
and _openai_codex_backend
and _codex_hard_timeout > 0
):
_stale_timeout = min(_stale_timeout, _codex_hard_timeout)
if _est_tokens_for_codex_watchdog > 100_000:
_codex_idle_timeout_default = 180.0
elif _est_tokens_for_codex_watchdog > 50_000:
+27 -6
View File
@@ -1118,6 +1118,22 @@ def _normalize_codex_response(
differs from the one that minted the encrypted_content blob and drop
the item instead of triggering HTTP 400 invalid_encrypted_content.
"""
response_status = getattr(response, "status", None)
if isinstance(response_status, str):
response_status = response_status.strip().lower()
else:
response_status = None
incomplete_details = getattr(response, "incomplete_details", None)
incomplete_reason = ""
if isinstance(incomplete_details, dict):
incomplete_reason = str(incomplete_details.get("reason") or "").strip().lower()
elif incomplete_details is not None:
incomplete_reason = str(getattr(incomplete_details, "reason", "") or "").strip().lower()
response_incomplete_content_filter = (
response_status == "incomplete" and incomplete_reason == "content_filter"
)
output = getattr(response, "output", None)
if not isinstance(output, list) or not output:
# The Codex backend can return empty output when the answer was
@@ -1134,15 +1150,18 @@ def _normalize_codex_response(
content=[SimpleNamespace(type="output_text", text=out_text.strip())],
)]
response.output = output
elif response_incomplete_content_filter:
# This is a deterministic provider safety block, not a partial
# answer. Synthesize an empty message so finish_reason below becomes
# content_filter and the conversation loop can fallback/surface it
# instead of burning three continuation attempts.
output = [SimpleNamespace(
type="message", role="assistant", status="completed", content=[]
)]
response.output = output
else:
raise RuntimeError("Responses API returned no output items")
response_status = getattr(response, "status", None)
if isinstance(response_status, str):
response_status = response_status.strip().lower()
else:
response_status = None
if response_status in {"failed", "cancelled"}:
error_obj = getattr(response, "error", None)
error_msg = _format_responses_error(error_obj, response_status)
@@ -1411,6 +1430,8 @@ def _normalize_codex_response(
if tool_calls:
finish_reason = "tool_calls"
elif response_incomplete_content_filter:
finish_reason = "content_filter"
elif leaked_tool_call_text:
finish_reason = "incomplete"
elif saw_streaming_or_item_incomplete:
+78 -3
View File
@@ -586,6 +586,27 @@ def _strip_historical_media(messages: List[Dict[str, Any]]) -> List[Dict[str, An
return result if changed else messages
def _image_part_label(part: Dict[str, Any]) -> str:
"""Render a multimodal image part as a short text label for the summarizer.
Keeps a real, referenceable URL when the image lives at an http(s)
address — the summary can then preserve the handle so the agent (or a
later vision_analyze call) can still reach the image after compaction.
Base64 ``data:`` URLs carry no reusable reference and would flood the
summarizer input, so they collapse to ``[image]``.
"""
url = ""
if isinstance(part.get("image_url"), dict):
url = str(part["image_url"].get("url") or "")
elif isinstance(part.get("image_url"), str):
url = part["image_url"]
elif isinstance(part.get("url"), str):
url = part["url"]
if url.startswith(("http://", "https://")):
return f"[image: {url}]"
return "[image]"
def _str_arg(args: dict, key: str, default: str = "") -> str:
"""Safely get a string argument from parsed tool args.
@@ -686,7 +707,16 @@ def _summarize_tool_result_unguarded(tool_name: str, tool_args: str, tool_conten
if tool_name == "web_extract":
urls = args.get("urls", [])
url_desc = urls[0] if isinstance(urls, list) and urls else "?"
first = urls[0] if isinstance(urls, list) and urls else "?"
# web_search results are dicts ({"url"/"href": ...}) and models often
# forward them straight into web_extract. Unwrap to the URL string so
# the summary stays readable and the ``+=`` below never hits the
# ``dict + str`` TypeError that would abort pre-compression pruning.
if isinstance(first, dict):
first = first.get("url") or first.get("href") or "?"
elif not isinstance(first, str):
first = "?"
url_desc = first
if isinstance(urls, list) and len(urls) > 1:
url_desc += f" (+{len(urls) - 1} more)"
return f"[web_extract] {url_desc} ({content_len:,} chars)"
@@ -765,6 +795,7 @@ class ContextCompressor(ContextEngine):
self._context_probe_persistable = False
self._previous_summary = None
self._last_summary_error = None
self._consecutive_timeout_failures = 0
self._last_summary_dropped_count = 0
self._last_summary_fallback_used = False
self._last_aux_model_failure_error = None
@@ -803,6 +834,7 @@ class ContextCompressor(ContextEngine):
"""
self._previous_summary = None
self._last_summary_error = None
self._consecutive_timeout_failures = 0
self._last_summary_dropped_count = 0
self._last_summary_fallback_used = False
self._last_aux_model_failure_error = None
@@ -827,6 +859,7 @@ class ContextCompressor(ContextEngine):
self._session_id = session_id or ""
self._summary_failure_cooldown_until = 0.0
self._last_summary_error = None
self._consecutive_timeout_failures = 0
self._fallback_compression_streak = 0
self.get_active_compression_failure_cooldown()
self._load_fallback_compression_streak()
@@ -975,6 +1008,7 @@ class ContextCompressor(ContextEngine):
def _clear_compression_failure_cooldown(self) -> None:
self._summary_failure_cooldown_until = 0.0
self._last_summary_error = None
self._consecutive_timeout_failures = 0
session_db = getattr(self, "_session_db", None)
session_id = getattr(self, "_session_id", "")
@@ -1655,7 +1689,24 @@ class ContextCompressor(ContextEngine):
parts = []
for msg in turns:
role = msg.get("role", "unknown")
content = redact_sensitive_text(msg.get("content") or "")
content = msg.get("content")
if isinstance(content, list):
text_parts: list[str] = []
for part in content:
if isinstance(part, dict):
ptype = part.get("type")
if ptype == "text":
text_parts.append(part.get("text", ""))
elif ptype in {"image", "image_url", "input_image"}:
text_parts.append(_image_part_label(part))
else:
# Unknown part type — keep a marker so the
# summarizer knows content existed here.
text_parts.append(f"[{ptype or 'attachment'}]")
elif isinstance(part, str):
text_parts.append(part)
content = "\n".join(text_parts)
content = redact_sensitive_text(content or "")
content = _MEDIA_DIRECTIVE_RE.sub("[media attachment]", content)
# Strip inline reasoning blocks (<think>, <reasoning>, etc.) from
# assistant content before it reaches the summarizer. Reasoning
@@ -2241,6 +2292,7 @@ This compaction should PRIORITISE preserving all information related to the focu
_is_timeout = (
_status in {408, 429, 502, 504}
or "timeout" in _err_str
or "timed out" in _err_str
)
# Non-JSON / malformed-body responses from misconfigured providers
# or proxies (e.g. an HTML 502 page returned with
@@ -2330,7 +2382,30 @@ This compaction should PRIORITISE preserving all information related to the focu
# Transient errors (timeout, rate limit, network, JSON decode,
# streaming premature-close) — shorter cooldown for JSON decode and
# streaming-closed since those conditions can self-resolve quickly.
_transient_cooldown = 30 if (_is_json_decode or _is_streaming_closed) else 60
# Timeout-class failures escalate with consecutive occurrences:
# a session whose transcript structurally exceeds what the
# summary route can produce within its deadline will fail the
# same way every time, and re-burning the full timeout every
# 60s turns each subsequent turn into a multi-minute stall
# (#62452). 60s → 300s → 900s (capped); any successful summary
# resets the streak via _clear_compression_failure_cooldown().
# Timeout takes precedence over the streaming-closed short rung:
# a "timed out" error also matches _is_connection_error, but a
# deadline exhaustion is the structural repeat-offender class,
# not a transient mid-stream drop.
if _is_timeout:
self._consecutive_timeout_failures = (
getattr(self, "_consecutive_timeout_failures", 0) + 1
)
_TIMEOUT_COOLDOWN_LADDER = (60, 300, 900)
_transient_cooldown = _TIMEOUT_COOLDOWN_LADDER[
min(self._consecutive_timeout_failures,
len(_TIMEOUT_COOLDOWN_LADDER)) - 1
]
elif _is_json_decode or _is_streaming_closed:
_transient_cooldown = 30
else:
_transient_cooldown = 60
err_text = str(e).strip() or e.__class__.__name__
if len(err_text) > 220:
err_text = err_text[:217].rstrip() + "..."
+47 -15
View File
@@ -1642,12 +1642,16 @@ def run_conversation(
# Check finish_reason before proceeding
if agent.api_mode == "codex_responses":
status = getattr(response, "status", None)
if isinstance(status, str):
status = status.strip().lower()
incomplete_details = getattr(response, "incomplete_details", None)
incomplete_reason = None
if isinstance(incomplete_details, dict):
incomplete_reason = incomplete_details.get("reason")
else:
incomplete_reason = getattr(incomplete_details, "reason", None)
if incomplete_reason is not None:
incomplete_reason = str(incomplete_reason).strip().lower()
if status == "incomplete" and incomplete_reason in {"max_output_tokens", "length"}:
# Responses API max-output exhaustion is a normal
# Codex incomplete turn. Let the Codex-specific
@@ -1657,6 +1661,8 @@ def run_conversation(
# emits "Response truncated due to output length
# limit" and stops gateway turns.
finish_reason = "incomplete"
elif status == "incomplete" and incomplete_reason == "content_filter":
finish_reason = "content_filter"
else:
finish_reason = "stop"
elif agent.api_mode == "anthropic_messages":
@@ -2619,6 +2625,31 @@ def run_conversation(
)
continue
# ── Bedrock AnthropicBedrock SDK streaming failure ──
# The Anthropic SDK's stream accumulator raises RuntimeError
# "Unexpected event order" when Bedrock returns an error event
# before message_start (throttling, overload, validation).
# Fall back to the native Converse API path for the rest of
# this session — it handles these errors gracefully. Ref: #28156.
if (
isinstance(api_error, RuntimeError)
and "unexpected event order" in str(api_error).lower()
and getattr(agent, "provider", "") == "bedrock"
and agent.api_mode == "anthropic_messages"
and not getattr(agent, "_bedrock_converse_fallback_attempted", False)
):
agent._bedrock_converse_fallback_attempted = True
agent.api_mode = "bedrock_converse"
agent._bedrock_region = getattr(agent, "_bedrock_region", None) or "us-east-1"
agent.client = None # Drop the AnthropicBedrock client
agent._client_kwargs = {}
agent._vprint(
f"{agent.log_prefix}⚠️ AnthropicBedrock SDK streaming failed — "
f"falling back to native Converse API for this session.",
force=True,
)
continue
status_code = getattr(api_error, "status_code", None)
error_context = agent._extract_api_error_context(api_error)
@@ -4459,27 +4490,28 @@ def run_conversation(
or interim_has_codex_message_items
):
last_msg = messages[-1] if messages else None
# Duplicate detection: two consecutive incomplete assistant
# messages with identical content AND reasoning are collapsed.
# For provider-state-only changes (encrypted reasoning
# items or replayable message ids/phases/statuses differ
# while visible content/reasoning are unchanged), compare
# those opaque payloads too so we don't silently drop the
# newer continuation state.
last_codex_items = last_msg.get("codex_reasoning_items") if isinstance(last_msg, dict) else None
interim_codex_items = interim_msg.get("codex_reasoning_items")
last_codex_message_items = last_msg.get("codex_message_items") if isinstance(last_msg, dict) else None
interim_codex_message_items = interim_msg.get("codex_message_items")
duplicate_interim = (
# Duplicate detection: compare only visible content
# (content + reasoning). Opaque provider state
# (encrypted reasoning items, message item ids/phases)
# drifts per continuation even when the visible output
# is identical, so including it in the comparison defeats
# dedup and causes message storms (#52711).
visible_duplicate = (
isinstance(last_msg, dict)
and last_msg.get("role") == "assistant"
and last_msg.get("finish_reason") == "incomplete"
and (last_msg.get("content") or "") == (interim_msg.get("content") or "")
and (last_msg.get("reasoning") or "") == (interim_msg.get("reasoning") or "")
and last_codex_items == interim_codex_items
and last_codex_message_items == interim_codex_message_items
)
if not duplicate_interim:
if visible_duplicate:
# Update opaque state in-place so the latest
# provider payload is preserved without emitting
# a duplicate visible message.
for _key in ("codex_reasoning_items", "codex_message_items"):
_new_val = interim_msg.get(_key)
if _new_val is not None:
last_msg[_key] = _new_val
else:
messages.append(interim_msg)
agent._emit_interim_assistant_message(interim_msg)
+24 -2
View File
@@ -840,12 +840,34 @@ def classify_api_error(
)
return _result(FailoverReason.timeout, retryable=True)
# ── 7. Transport / timeout heuristics ───────────────────────────
# ── 7b. Stale-call circuit breaker → failover immediately ──────
# _check_stale_giveup() in agent/chat_completion_helpers.py raises a
# RuntimeError when the provider has been unresponsive for N
# consecutive stale attempts (default 5). The error is NOT a transport
# timeout — the circuit breaker fires *before* any network call to avoid
# an indefinite stall. Without this classification the RuntimeError
# falls through to FailoverReason.unknown (retryable=True), which burns
# all max_retries against the same dead provider (each retry hitting the
# circuit breaker instantly with zero network overhead) before fallback
# is attempted. Classify as non-retryable + should_fallback so the
# retry loop activates the next fallback provider on the first hit.
if (
error_type == "RuntimeError"
and "consecutive stale attempts" in error_msg
and "aborting this call" in error_msg
):
return _result(
FailoverReason.timeout,
retryable=False,
should_fallback=True,
)
# ── 8. Transport / timeout heuristics ───────────────────────────
if error_type in _TRANSPORT_ERROR_TYPES or isinstance(error, (TimeoutError, ConnectionError, OSError)):
return _result(FailoverReason.timeout, retryable=True)
# ── 8. Fallback: unknown ────────────────────────────────────────
# ── 9. Fallback: unknown ────────────────────────────────────────
return _result(FailoverReason.unknown, retryable=True)
+24 -2
View File
@@ -55,10 +55,32 @@ def hermes_client_tag() -> str:
return f"client=hermes-client-v{_hermes_version()}"
def nous_portal_tags() -> List[str]:
def conversation_tag(session_id: str) -> str:
"""Return the ``conversation=...`` tag for a Hermes session/conversation.
Format: ``conversation=<session_id>``. ``session_id`` is the canonical
Hermes conversation identifier (``AIAgent.session_id``) — the same value
used for ``~/.hermes/sessions/`` storage, session logs, and lineage.
Unlike the product/client tags this is high-cardinality (one value per
conversation), so it is only appended when a session id is actually
available — never as part of the always-on base tag set.
"""
return f"conversation={session_id}"
def nous_portal_tags(session_id: str | None = None) -> List[str]:
"""Return the canonical list of Nous Portal product tags.
Always returns a fresh list so callers can mutate it freely
(e.g. ``merged_extra.setdefault("tags", []).extend(nous_portal_tags())``).
When ``session_id`` is provided, a ``conversation=<session_id>`` tag is
appended so Portal usage can be attributed to a specific Hermes
conversation. Callers without a session id (e.g. the auxiliary client's
always-on base tags) omit it and get the canonical two-tag set.
"""
return ["product=hermes-agent", hermes_client_tag()]
tags = ["product=hermes-agent", hermes_client_tag()]
if session_id:
tags.append(conversation_tag(session_id))
return tags
+14 -4
View File
@@ -208,19 +208,29 @@ class StreamingThinkScrubber:
discarded — leaking partial reasoning is worse than a
truncated answer. Otherwise the held-back partial-tag tail is
emitted verbatim (it turned out not to be a real tag prefix).
Always treats the next ``feed()`` as a fresh stream boundary.
Intra-turn retries (thinking-only prefill, empty-response
retry) flush then stream again without calling ``reset()``;
leaving ``_last_emitted_ended_newline`` False made a new
stream's opening ``<think>`` look mid-line and leak into the
visible reply.
"""
if self._in_block:
self._buf = ""
self._in_block = False
# Next feed() is a new stream — start-of-stream is a boundary.
self._last_emitted_ended_newline = True
return ""
tail = self._buf
self._buf = ""
# Same for the non-block path: do NOT derive the boundary flag
# from the flushed tail (e.g. a held-back '<'). End-of-stream
# means the next feed() starts a new model response.
self._last_emitted_ended_newline = True
if not tail:
return ""
tail = self._strip_orphan_close_tags(tail)
if tail:
self._last_emitted_ended_newline = tail.endswith("\n")
return tail
return self._strip_orphan_close_tags(tail)
# ── internal helpers ───────────────────────────────────────────────
+16 -4
View File
@@ -435,15 +435,27 @@ class ResponsesApiTransport(ProviderTransport):
def validate_response(self, response: Any) -> bool:
"""Check Codex Responses API response has valid output structure.
Returns True only if response.output is a non-empty list.
Does NOT check output_text fallback — the caller handles that
with diagnostic logging for stream backfill recovery.
Returns True only if response.output is a non-empty list. Also treats
terminal content-filter incomplete responses as valid: the Responses API
may return status=incomplete with incomplete_details.reason='content_filter'
and no output items. That is a provider refusal signal, not a malformed
response, and must reach normalization so the agent loop can use the
content-policy / fallback path instead of invalid-response retries.
Does NOT check output_text fallback — the caller handles that with
diagnostic logging for stream backfill recovery.
"""
if response is None:
return False
output = getattr(response, "output", None)
if not isinstance(output, list) or not output:
return False
status = str(getattr(response, "status", "") or "").strip().lower()
incomplete_details = getattr(response, "incomplete_details", None)
if isinstance(incomplete_details, dict):
reason = str(incomplete_details.get("reason") or "").strip().lower()
else:
reason = str(getattr(incomplete_details, "reason", "") or "").strip().lower()
return status == "incomplete" and reason == "content_filter"
return True
def preflight_kwargs(
@@ -262,3 +262,40 @@ test('addWorktree: base param branches off a specified local branch', async () =
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('addWorktree: base origin/main does not set up upstream tracking', async () => {
// Two repos: a bare "remote" and a clone, so origin/main resolves as a
// remote-tracking ref — the condition that triggers auto-tracking.
const remoteDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-remote-'))
const cloneDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-clone-'))
const git = (...args) => execFileSync('git', args, { cwd: cloneDir }).toString().trim()
try {
// Seed the remote with a commit on main. Inline identity so it works
// on CI runners with no global git config.
execFileSync('git', ['init', '-b', 'main', remoteDir])
execFileSync('git', ['-C', remoteDir, '-c', 'user.email=hermes@localhost', '-c', 'user.name=Hermes', 'commit', '--allow-empty', '-m', 'root'])
// Clone so origin/main exists as a remote-tracking ref.
execFileSync('git', ['clone', remoteDir, cloneDir])
const result = await addWorktree(cloneDir, { base: 'origin/main', branch: 'feature-branch', name: 'feature-branch' }, 'git')
assert.equal(result.branch, 'feature-branch')
// The new branch must NOT have an upstream — like `git checkout origin/main
// && git checkout -b feature-branch`, not `git worktree add -b … origin/main`.
let hasUpstream = true
try {
execFileSync('git', ['-C', result.path, 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])
} catch {
hasUpstream = false
}
assert.equal(hasUpstream, false)
} finally {
fs.rmSync(remoteDir, { recursive: true, force: true })
fs.rmSync(cloneDir, { recursive: true, force: true })
}
})
+8 -1
View File
@@ -265,8 +265,15 @@ async function addWorktree(repoPath, options, gitBin) {
} catch {
// The fetch isn't mandatory, but it would be nice to do if possible.
// If it's not possible, just use the local ref of the remote branch.
// If it doesn't exist locally, we'll get an error anyways
// If it doesn't exist locally, we'll get an error
}
// When branching off a remote-tracking ref, git auto-sets up tracking
// (e.g. `new-branch` → tracks `origin/main`). The user almost certainly
// wants a standalone local branch — like `git checkout origin/main &&
// git checkout -b new-branch` — not a branch silently wired to the
// remote's upstream. `--no-track` prevents that.
args.push('--no-track')
}
args.push(base)
+69 -12
View File
@@ -3597,9 +3597,35 @@ async function ensureRuntime(backend) {
return backend
}
// Assemble a single-file multipart/form-data body (FastAPI `UploadFile`
// endpoints, e.g. kanban attachments). Hand-rolled because node's http has no
// FormData and the payload is one file — a dependency would be overkill.
function multipartBody(upload) {
const boundary = `----hermes-${crypto.randomBytes(12).toString('hex')}`
const filename = String(upload.filename || 'file').replace(/["\r\n]/g, '_')
const body = Buffer.concat([
Buffer.from(
`--${boundary}\r\n` +
`Content-Disposition: form-data; name="file"; filename="${filename}"\r\n` +
`Content-Type: ${upload.contentType || 'application/octet-stream'}\r\n\r\n`
),
Buffer.from(upload.bytes),
Buffer.from(`\r\n--${boundary}--\r\n`)
])
return { body, contentType: `multipart/form-data; boundary=${boundary}` }
}
function fetchJson(url, token, options: any = {}) {
return new Promise((resolve, reject) => {
const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body))
const { body, contentType } = options.upload
? multipartBody(options.upload)
: {
body: options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body)),
contentType: 'application/json'
}
const parsed = new URL(url)
const client = parsed.protocol === 'https:' ? https : http
const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
@@ -3615,7 +3641,7 @@ function fetchJson(url, token, options: any = {}) {
{
method: options.method || 'GET',
headers: {
'Content-Type': 'application/json',
'Content-Type': contentType,
'X-Hermes-Session-Token': token,
...(body ? { 'Content-Length': String(body.length) } : {})
}
@@ -4576,14 +4602,13 @@ function buildApplicationMenu() {
submenu: [
IS_MAC
? {
accelerator: 'CommandOrControl+W',
click: () => {
if (previewShortcutActive) {
sendClosePreviewRequested()
} else {
mainWindow?.close()
}
},
// NO accelerator: on macOS a registered ⌘W is consumed by the OS
// menu before the web contents ever sees it (and registerAccelerator
// false is a no-op on mac — electron#18295). Leaving it off lets the
// `before-input-event` handler below intercept ⌘W and route it to the
// renderer's close-active-tab. Clicking the item still closes the tab
// (or window) via the same request.
click: () => sendClosePreviewRequested(),
label: 'Close'
}
: { role: 'quit' }
@@ -4689,9 +4714,12 @@ function installDevToolsShortcut(window) {
function installPreviewShortcut(window) {
window.webContents.on('before-input-event', (event, input) => {
const key = String(input.key || '').toLowerCase()
const isPreviewCloseShortcut = key === 'w' && (IS_MAC ? input.meta : input.control) && !input.alt && !input.shift
const isCloseTabShortcut = key === 'w' && (IS_MAC ? input.meta : input.control) && !input.alt && !input.shift
if (!isPreviewCloseShortcut || !previewShortcutActive) {
// Always claim ⌘W here (the File>Close item deliberately has no
// accelerator, so nothing else does). The renderer decides tab-vs-window
// — no `previewShortcutActive` gate, so it works for every closeable tab.
if (!isCloseTabShortcut) {
return
}
@@ -7870,6 +7898,12 @@ ipcMain.handle('hermes:api', async (_event, request) => {
// session so the cookie attaches automatically. Token/local modes keep using
// the static session-token header.
if (connection.authMode === 'oauth') {
// The OAuth path rides electron.net with JSON headers; multipart isn't
// wired there. Fail loudly rather than corrupting the upload.
if (request?.upload) {
throw new Error('File uploads are not supported against OAuth-gated remote backends yet.')
}
return fetchJsonViaOauthSession(url, {
method: request?.method,
body: request?.body,
@@ -7880,6 +7914,7 @@ ipcMain.handle('hermes:api', async (_event, request) => {
return fetchJson(url, connection.token, {
method: request?.method,
body: request?.body,
upload: request?.upload,
timeoutMs
})
})
@@ -8380,6 +8415,28 @@ ipcMain.handle('hermes:fs:reveal', async (_event, targetPath) => {
}
})
// Open a DIRECTORY in the OS file manager, creating it first if needed. Unlike
// `reveal` (which selects an existing item and silently no-ops on a missing
// path — the "Open plugins folder" Windows bug), this is for the plugins door,
// which often doesn't exist on first use. `shell.openPath` returns '' on
// success or an error string; both mkdir + openPath failures are surfaced.
ipcMain.handle('hermes:fs:openDir', async (_event, dirPath) => {
const dir = String(dirPath || '').trim()
if (!dir) {
return { ok: false, error: 'no path' }
}
try {
await fs.promises.mkdir(dir, { recursive: true })
const error = await shell.openPath(path.normalize(dir))
return error ? { ok: false, error } : { ok: true }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) }
}
})
// Rename a file/folder in place. The renderer passes the existing path + a new
// base name; the destination is resolved in the SAME parent dir so a rename can
// never move the item elsewhere or traverse out. Rejects on a name collision.
+1
View File
@@ -107,6 +107,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath),
revealPath: targetPath => ipcRenderer.invoke('hermes:fs:reveal', targetPath),
openDir: dirPath => ipcRenderer.invoke('hermes:fs:openDir', dirPath),
renamePath: (targetPath, newName) => ipcRenderer.invoke('hermes:fs:rename', targetPath, newName),
writeTextFile: (filePath, content) => ipcRenderer.invoke('hermes:fs:writeText', filePath, content),
trashPath: targetPath => ipcRenderer.invoke('hermes:fs:trash', targetPath),
+19
View File
@@ -104,6 +104,25 @@ export default [
react: { version: 'detect' }
}
},
{
// THE PLUGIN FENCE: plugins speak @hermes/plugin-sdk (+ react), never `@/…`
// internals — the same isolation a runtime-fetched published plugin gets,
// enforced on bundled ones so the SDK surface stays honest and sufficient.
files: ['src/plugins/**/*.{ts,tsx}'],
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['@/*', '../*', '@hermes/shared'],
message: 'Plugins import only @hermes/plugin-sdk (and react). Missing something? Add it to the SDK.'
}
]
}
]
}
},
{
files: ['**/*.js', '**/*.cjs', '**/*.mjs'],
ignores: ['**/node_modules/**', '**/dist/**'],
+18 -18
View File
@@ -1,48 +1,48 @@
import { useRef } from 'react'
import type { DragKind } from '@/app/chat/hooks/use-file-drop-zone'
import { Codicon } from '@/components/ui/codicon'
import { DROP_SHEET_BLUR_CLASS, DROP_SHEET_CLASS } from '@/components/ui/drop-affordance'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
const ICONS: Record<'files' | 'session', string> = {
files: 'cloud-upload',
session: 'comment-discussion'
}
/**
* Full-bleed affordance shown while files or a session are dragged over the chat
* area. Always `pointer-events-none` so the drop lands on the real element
* underneath and the drop-zone handler claims it — the overlay is purely visual.
* Copy adapts to whatever is being dragged; the last kind is held through the
* fade-out so the label doesn't blank.
* The label names the outcome (attach files / link this chat); the last kind is
* held through the fade-out so it doesn't blank.
*/
export function ChatDropOverlay({ kind }: { kind: DragKind }) {
const { t } = useI18n()
const lastKind = useRef<'files' | 'session'>('files')
const lastKind = useRef<DragKind>(kind)
if (kind) {
lastKind.current = kind
}
const resolvedKind = kind ?? lastKind.current
const icon = ICONS[resolvedKind]
const label = resolvedKind === 'files' ? t.composer.dropFiles : t.composer.dropSession
const shown = kind ?? lastKind.current
return (
<div
aria-hidden
className={cn(
'pointer-events-none absolute inset-0 z-40 flex items-center justify-center p-4 transition-opacity duration-150 ease-out',
'pointer-events-none absolute inset-0 z-40 flex items-center justify-center transition-opacity duration-150 ease-out',
kind ? 'opacity-100' : 'opacity-0'
)}
data-slot="chat-drop-overlay"
>
<div className="absolute inset-2 rounded-2xl border-2 border-dashed border-[color-mix(in_srgb,var(--dt-composer-ring)_55%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_55%,transparent)] backdrop-blur-[2px] [-webkit-backdrop-filter:blur(2px)]" />
<div className="relative flex items-center gap-2 rounded-full border border-[color-mix(in_srgb,var(--dt-composer-ring)_45%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_92%,transparent)] px-4 py-2 text-[0.8125rem] font-medium text-foreground shadow-composer">
<Codicon className="text-(--ui-accent)" name={icon} size="1rem" />
{label}
</div>
<div
className={cn(
DROP_SHEET_CLASS,
DROP_SHEET_BLUR_CLASS,
'absolute inset-2 border-[color-mix(in_srgb,var(--dt-composer-ring)_55%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_55%,transparent)]'
)}
/>
{shown && (
<span className="relative text-[11px] font-medium uppercase tracking-wide text-foreground">
{shown === 'session' ? t.composer.dropSession : t.composer.dropFiles}
</span>
)}
</div>
)
}
+29
View File
@@ -0,0 +1,29 @@
import { closeActiveTerminal } from '@/app/right-sidebar/terminal/terminals'
import { closeWorkspaceTab } from '@/components/pane-shell/tree/store'
import { isFocusWithin } from '@/lib/keybinds/combo'
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '@/store/preview'
/**
* ⌘W — close the tab of the context you're in, by precedence:
* 1. a focused terminal → its active terminal tab,
* 2. an open preview → its active preview tab (unchanged from pre-tiling),
* 3. the MAIN zone → its active tab (a session tile stacked into the workspace).
* 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.
*/
export function closeActiveTab(): boolean {
if (isFocusWithin('[data-terminal]')) {
closeActiveTerminal()
return true
}
if ($filePreviewTarget.get() || $previewTarget.get()) {
closeActiveRightRailTab()
return true
}
return closeWorkspaceTab()
}
@@ -6,6 +6,12 @@ import { setSessionPickerOpen } from '@/store/session'
export const COMPOSER_STACK_BREAKPOINT_PX = 320
// Above the stack breakpoint but still cramped: the model pill sheds its label
// for its chevron icon (freeing ~120px) so the controls stop crowding the input
// before the whole row has to stack. Progressive collapse: full pill → icon
// pill → stacked.
export const COMPOSER_COMPACT_PILL_PX = 440
// A single editor line is ~28px (--composer-input-min-height 1.625rem + 0.5rem
// vertical padding). Anything taller means the text wrapped to a second line,
// which is when the composer should expand to the stacked layout.
@@ -79,7 +85,5 @@ export function isPendingDraftPersistCurrent(
pending: PendingDraftPersist | null,
expected: PendingDraftPersist | null
): boolean {
return (
pending !== null && expected !== null && pending.scope === expected.scope && pending.text === expected.text
)
return pending !== null && expected !== null && pending.scope === expected.scope && pending.text === expected.text
}
@@ -18,6 +18,7 @@ import { useI18n } from '@/i18n'
import { Clipboard, FileText, FolderOpen, type IconComponent, ImageIcon, Link, MessageSquareText } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { useComposerAttachmentProviders } from './contrib'
import { GHOST_ICON_BTN } from './controls'
import type { ChatBarState } from './types'
@@ -39,6 +40,9 @@ export function ContextMenu({
// window (composer "+" anchor), so we promoted it to a real Dialog —
// easier to grow with search / descriptions, and no positioning math.
const [snippetsOpen, setSnippetsOpen] = useState(false)
// `composer.attachments` contributions — plugin/core-registered rows that
// extend this menu through the same registry as every other surface.
const attachmentProviders = useComposerAttachmentProviders()
return (
<>
@@ -90,6 +94,18 @@ export function ContextMenu({
{c.promptSnippets}
</ContextMenuItem>
{attachmentProviders.length > 0 && <DropdownMenuSeparator />}
{attachmentProviders.map(provider => (
<DropdownMenuItem
className="text-[length:var(--conversation-tool-font-size)] focus:bg-(--ui-bg-tertiary)"
key={provider.key}
onSelect={() => void provider.run({ insertText: onInsertText })}
>
<Codicon name={provider.icon ?? 'plug'} size="0.875rem" />
<span>{provider.label}</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<div className="px-2 py-1 text-[0.7rem] text-muted-foreground/80">
@@ -0,0 +1,54 @@
import { afterEach, describe, expect, it } from 'vitest'
import { registry } from '@/contrib/registry'
import { COMPOSER_AREAS, type ComposerMiddleware, runComposerMiddleware } from './contrib'
const disposers: Array<() => void> = []
function addMiddleware(id: string, handler: ComposerMiddleware['handler'], order?: number) {
disposers.push(
registry.register({ id, area: COMPOSER_AREAS.middleware, order, data: { handler } satisfies ComposerMiddleware })
)
}
afterEach(() => {
disposers.splice(0).forEach(d => d())
})
describe('runComposerMiddleware', () => {
it('passes the draft through untouched when nothing is registered', async () => {
const draft = { text: 'hello' }
expect(await runComposerMiddleware(draft)).toBe(draft)
})
it('chains rewrites in registry order', async () => {
addMiddleware('b', d => ({ ...d, text: `${d.text}b` }), 20)
addMiddleware('a', d => ({ ...d, text: `${d.text}a` }), 10)
expect(await runComposerMiddleware({ text: 'x' })).toEqual({ text: 'xab' })
})
it('cancels the send when a handler returns null', async () => {
addMiddleware('gate', () => null)
addMiddleware('later', d => ({ ...d, text: 'never' }), 99)
expect(await runComposerMiddleware({ text: 'x' })).toBeNull()
})
it('treats a throwing handler as pass-through', async () => {
addMiddleware('boom', () => {
throw new Error('broken plugin')
})
addMiddleware('after', d => ({ ...d, text: `${d.text}!` }), 99)
expect(await runComposerMiddleware({ text: 'x' })).toEqual({ text: 'x!' })
})
it('supports async handlers', async () => {
addMiddleware('async', async d => ({ ...d, text: d.text.toUpperCase() }))
expect(await runComposerMiddleware({ text: 'quiet' })).toEqual({ text: 'QUIET' })
})
})
@@ -0,0 +1,94 @@
/**
* Composer contribution surface — every seam of the composer is hook-into-able
* through the SAME registry schema as every other surface (statusbar, titlebar,
* panes, layouts):
*
* render areas (`render`): composer.top — banner strip above the input
* composer.bottom — row below the input grid
* composer.leading — inline after the "+" menu
* composer.actions — inline before the model pill
*
* data kinds (`data`): composer.middleware (ComposerMiddleware)
* composer.attachments (ComposerAttachmentProvider)
*
* Core keeps ownership of the transcript, input, and submit engine — these
* seams AUGMENT the composer, they never replace it. Middleware runs as an
* ordered async chain around the app's onSubmit: each handler may rewrite the
* draft, pass it through, or cancel the send by returning null.
*/
import { useContributions } from '@/contrib/react/use-contributions'
import { registry } from '@/contrib/registry'
import type { ComposerAttachment } from '@/store/composer'
export const COMPOSER_AREAS = {
top: 'composer.top',
bottom: 'composer.bottom',
leading: 'composer.leading',
actions: 'composer.actions',
middleware: 'composer.middleware',
attachments: 'composer.attachments'
} as const
export interface ComposerDraft {
text: string
attachments?: ComposerAttachment[]
}
/** Payload of a `composer.middleware` data contribution. */
export interface ComposerMiddleware {
/** Rewrite (return a draft), pass through (same draft), or cancel (null). */
handler: (draft: ComposerDraft) => ComposerDraft | null | Promise<ComposerDraft | null>
}
export interface ComposerAttachmentContext {
insertText: (text: string) => void
}
/** Payload of a `composer.attachments` data contribution — an entry in the
* composer's "+" attach menu. */
export interface ComposerAttachmentProvider {
label: string
/** Codicon name for the menu row. Defaults to `plug`. */
icon?: string
run: (ctx: ComposerAttachmentContext) => void | Promise<void>
}
/**
* Run the ordered middleware chain over a draft. Contributions execute in
* registry order (`order`, then registration order); the first `null` wins
* and cancels the send. A throwing handler is treated as pass-through so a
* broken plugin can't eat messages.
*/
export async function runComposerMiddleware(draft: ComposerDraft): Promise<ComposerDraft | null> {
let current = draft
for (const contribution of registry.getArea(COMPOSER_AREAS.middleware)) {
const middleware = contribution.data as ComposerMiddleware | undefined
if (!middleware?.handler) {
continue
}
try {
const next = await middleware.handler(current)
if (next === null) {
return null
}
current = next
} catch {
// Pass-through: a faulty middleware must never swallow the message.
}
}
return current
}
/** Attach-menu entries contributed by plugins/core, with stable render keys. */
export function useComposerAttachmentProviders(): Array<ComposerAttachmentProvider & { key: string }> {
return useContributions(COMPOSER_AREAS.attachments)
.map(c => ({ key: `${c.source ?? 'core'}:${c.id}`, ...(c.data as ComposerAttachmentProvider) }))
.filter(p => Boolean(p.label && p.run))
}
+14 -6
View File
@@ -13,7 +13,9 @@
import type { InlineRefInput } from './inline-refs'
import { RICH_INPUT_SLOT } from './rich-editor'
export type ComposerTarget = 'edit' | 'main'
/** Composer routing key. The main chat is `'main'`, the edit composer
* `'edit'`; scoped composers (session tiles) use `'tile:<id>'`. */
export type ComposerTarget = 'edit' | 'main' | (string & {})
export type ComposerInsertMode = 'block' | 'inline'
interface FocusDetail {
@@ -76,6 +78,10 @@ export const markActiveComposer = (target: ComposerTarget) => {
activeTarget = target
}
/** The composer that last held focus — the target `'active'` resolves to.
* Used by broadcast listeners (voice, Esc-to-stop) to act on exactly one. */
export const getActiveComposer = (): ComposerTarget => activeTarget
export const requestComposerFocus = (target: ComposerTarget | 'active' = 'active') =>
dispatch<FocusDetail>(FOCUS_EVENT, { target: resolve(target) })
@@ -129,12 +135,14 @@ export const requestComposerSubmit = (
export const onComposerSubmitRequest = (handler: (detail: SubmitDetail) => void) =>
subscribe<SubmitDetail>(SUBMIT_EVENT, handler)
/** Toggle the active composer's voice conversation — the `composer.voice`
* hotkey (Ctrl+B) reaching into the composer that owns the voice state. */
export const requestVoiceToggle = () => dispatch<{ at: number }>(VOICE_TOGGLE_EVENT, { at: Date.now() })
/** Toggle ONE composer's voice conversation — the `composer.voice` hotkey
* (Ctrl+B) reaches the composer that owns voice. Defaults to the active
* composer so N tiles don't all flip together. */
export const requestVoiceToggle = (target: ComposerTarget | 'active' = 'active') =>
dispatch<{ target: ComposerTarget }>(VOICE_TOGGLE_EVENT, { target: resolve(target) })
export const onComposerVoiceToggleRequest = (handler: () => void) =>
subscribe<{ at: number }>(VOICE_TOGGLE_EVENT, () => handler())
export const onComposerVoiceToggleRequest = (handler: (target: ComposerTarget) => void) =>
subscribe<{ target: ComposerTarget }>(VOICE_TOGGLE_EVENT, ({ target }) => handler(target))
/**
* Focus a composer input across React commit + browser focus restore.
@@ -1,8 +1,9 @@
import { type MutableRefObject, useCallback } from 'react'
import { clearComposerAttachments } from '@/store/composer'
import { listRepoBranches, requestStartWorkSession, startWorkInRepo, switchBranchInRepo } from '@/store/projects'
import { useComposerScope } from '../scope'
interface UseComposerBranchOptions {
clearDraft: () => void
cwd: null | string | undefined
@@ -17,6 +18,8 @@ interface UseComposerBranchOptions {
* projects store) is the only dependency; nothing about ChatBar's render.
*/
export function useComposerBranch({ clearDraft, cwd, draftRef }: UseComposerBranchOptions) {
const scope = useComposerScope()
// Hand a worktree off to the controller: open a fresh session anchored there,
// carrying the composer draft as its first turn. Clearing here means the draft
// travels to the new session instead of getting stashed under this one.
@@ -24,7 +27,7 @@ export function useComposerBranch({ clearDraft, cwd, draftRef }: UseComposerBran
(path: string) => {
const text = draftRef.current
clearDraft()
clearComposerAttachments()
scope.attachments.clear()
requestStartWorkSession(path, text)
},
[clearDraft, draftRef]
@@ -2,7 +2,7 @@ import { useAui, useAuiState, useComposerRuntime } from '@assistant-ui/react'
import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import { $composerAttachments, type ComposerAttachment, stashSessionDraft, takeSessionDraft } from '@/store/composer'
import { type ComposerAttachment, stashSessionDraft, takeSessionDraft } from '@/store/composer'
import { isBrowsingHistory } from '@/store/composer-input-history'
import {
@@ -21,6 +21,7 @@ import {
} from '../focus'
import { type InlineRefInput, insertInlineRefsIntoEditor } from '../inline-refs'
import { composerPlainText, placeCaretEnd, renderComposerContents } from '../rich-editor'
import { useComposerScope } from '../scope'
import type { ChatBarProps } from '../types'
interface UseComposerDraftArgs {
@@ -50,6 +51,8 @@ export function useComposerDraft({
}: UseComposerDraftArgs) {
const aui = useAui()
const composerRuntime = useComposerRuntime()
// Which composer this is on the focus bus + which attachment set it owns.
const { attachments: attachmentScope, target } = useComposerScope()
// Coarse edges only — these flip rarely (empty↔non-empty, the `?` help sigil,
// steerable-vs-slash), so typing within a line costs no render.
@@ -98,8 +101,8 @@ export function useComposerDraft({
const focusInput = useCallback(() => {
focusComposerInput(editorRef.current)
markActiveComposer('main')
}, [])
markActiveComposer(target)
}, [target])
const requestMainFocus = useCallback(() => {
setFocusRequestId(id => id + 1)
@@ -155,14 +158,14 @@ export function useComposerDraft({
return undefined
}
const offFocus = onComposerFocusRequest(target => {
if (target === 'main') {
const offFocus = onComposerFocusRequest(requested => {
if (requested === target) {
setFocusRequestId(id => id + 1)
}
})
const offInsert = onComposerInsertRequest(({ mode, target, text }) => {
if (target === 'main') {
const offInsert = onComposerInsertRequest(({ mode, target: requested, text }) => {
if (requested === target) {
appendExternalText(text, mode)
}
})
@@ -171,13 +174,13 @@ export function useComposerDraft({
offFocus()
offInsert()
}
}, [appendExternalText, inputDisabled])
}, [appendExternalText, inputDisabled, target])
const stashAt = (scope: string | null, text = draftRef.current, attachments = $composerAttachments.get()) =>
const stashAt = (scope: string | null, text = draftRef.current, attachments = attachmentScope.$attachments.get()) =>
stashSessionDraft(scope, text, attachments)
const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => {
$composerAttachments.set(cloneAttachments(attachments))
attachmentScope.$attachments.set(cloneAttachments(attachments))
paintDraft(text, false)
}
@@ -296,12 +299,12 @@ export function useComposerDraft({
insertInlineRefsRef.current = insertInlineRefs
useEffect(() => {
return onComposerInsertRefsRequest(({ refs, target }) => {
if (target === 'main') {
return onComposerInsertRefsRequest(({ refs, target: requested }) => {
if (requested === target) {
insertInlineRefsRef.current(refs)
}
})
}, [])
}, [target])
// Per-thread draft swap — the composer's only session coupling. Lifecycle
// never clears composer state; this effect alone stashes on leave, restores
@@ -2,10 +2,15 @@ import { useEffect, useRef } from 'react'
import { triggerHaptic } from '@/lib/haptics'
import { type ComposerTarget, getActiveComposer } from '../focus'
interface UseComposerEscCancelOptions {
awaitingInput: boolean
busy: boolean
onCancel: () => unknown
/** This composer's focus-bus key. With N composers mounted (main + tiles),
* only the active one's Esc cancels — otherwise every busy tile stops. */
target: ComposerTarget
}
/**
@@ -15,7 +20,7 @@ interface UseComposerEscCancelOptions {
* the window listener registered exactly once while still reading fresh
* busy/awaitingInput/onCancel each press.
*/
export function useComposerEscCancel({ awaitingInput, busy, onCancel }: UseComposerEscCancelOptions) {
export function useComposerEscCancel({ awaitingInput, busy, onCancel, target }: UseComposerEscCancelOptions) {
// Intentional only: we bail if (a) the composer/another field already handled
// Esc (defaultPrevented), (b) focus is in any input/textarea/contenteditable
// (you're typing, not stopping), or (c) a dialog/popover is open — Esc must
@@ -30,6 +35,12 @@ export function useComposerEscCancel({ awaitingInput, busy, onCancel }: UseCompo
return
}
// Only the focused composer cancels — otherwise every mounted busy tile
// stops at once (and the winner would be mount-order arbitrary).
if (getActiveComposer() !== target) {
return
}
const active = document.activeElement as HTMLElement | null
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) {
@@ -6,7 +6,7 @@ import { useResizeObserver } from '@/hooks/use-resize-observer'
import { $composerPoppedOut } from '@/store/composer-popout'
import { isSecondaryWindow } from '@/store/windows'
import { COMPOSER_SINGLE_LINE_MAX_PX, COMPOSER_STACK_BREAKPOINT_PX } from '../composer-utils'
import { COMPOSER_COMPACT_PILL_PX, COMPOSER_SINGLE_LINE_MAX_PX, COMPOSER_STACK_BREAKPOINT_PX } from '../composer-utils'
interface UseComposerMetricsArgs {
composerRef: RefObject<HTMLFormElement | null>
@@ -24,10 +24,13 @@ interface UseComposerMetricsArgs {
* Returns `stacked` (the only value the render needs).
*/
export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut }: UseComposerMetricsArgs): {
compactPill: boolean
stacked: boolean
} {
const [expanded, setExpanded] = useState(false)
const [tight, setTight] = useState(false)
// Wider than `tight`: the pill goes icon-only before the row has to stack.
const [compactPill, setCompactPill] = useState(false)
const narrow = useMediaQuery('(max-width: 30rem)')
// Edge signals, not the live text: these only re-render when emptiness / the
@@ -72,6 +75,7 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef,
const lastBucketedHeightRef = useRef(0)
const lastBucketedSurfaceHeightRef = useRef(0)
const lastTightRef = useRef<boolean | null>(null)
const lastCompactPillRef = useRef<boolean | null>(null)
const syncComposerMetrics = useCallback(() => {
const composer = composerRef.current
@@ -105,6 +109,13 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef,
lastTightRef.current = nextTight
setTight(nextTight)
}
const nextCompactPill = width < COMPOSER_COMPACT_PILL_PX
if (nextCompactPill !== lastCompactPillRef.current) {
lastCompactPillRef.current = nextCompactPill
setCompactPill(nextCompactPill)
}
}
// Expand once the input has actually wrapped past a single line. The
@@ -156,5 +167,7 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef,
}
}, [])
return { stacked: expanded || narrow || tight }
// Pill compacts on real width (tile/pane), OR when stacked for any reason
// (viewport-narrow / wrapped) so the controls row never over-runs.
return { compactPill: compactPill || narrow || tight, stacked: expanded || narrow || tight }
}
@@ -11,6 +11,8 @@ import {
} from '@/store/composer-popout'
import { isSecondaryWindow } from '@/store/windows'
import { useComposerScope } from '../scope'
import { useComposerPopoutGestures } from './use-popout-drag'
interface UseComposerPopoutOptions {
@@ -25,7 +27,10 @@ interface UseComposerPopoutOptions {
* window's composer out via the shared atom.
*/
export function useComposerPopout({ composerRef }: UseComposerPopoutOptions) {
const popoutAllowed = !isSecondaryWindow()
// The floating composer is a window-level singleton: only the main scope
// (not tiles) in a primary window may pop out.
const scope = useComposerScope()
const popoutAllowed = !isSecondaryWindow() && scope.popoutAllowed
const poppedOut = useStore($composerPoppedOut) && popoutAllowed
const popoutPosition = useStore($composerPopoutPosition)
@@ -3,7 +3,7 @@ import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { useSessionSlice } from '@/lib/use-session-slice'
import { clearComposerAttachments, type ComposerAttachment } from '@/store/composer'
import { type ComposerAttachment } from '@/store/composer'
import { resetBrowseState } from '@/store/composer-input-history'
import {
$queuedPromptsBySession,
@@ -19,6 +19,7 @@ import {
import { notify } from '@/store/notifications'
import { cloneAttachments, type QueueEditState } from '../composer-utils'
import { useComposerScope } from '../scope'
import type { ChatBarProps } from '../types'
interface UseComposerQueueArgs {
@@ -60,6 +61,7 @@ export function useComposerQueue({
sessionId
}: UseComposerQueueArgs) {
const { t } = useI18n()
const scope = useComposerScope()
// Per-session slice (edge): re-renders only when THIS session's queue changes,
// not on cross-session queue churn (the plain atom's map ref changes on every
@@ -173,11 +175,11 @@ export function useComposerQueue({
}
clearDraft()
clearComposerAttachments()
scope.attachments.clear()
triggerHaptic('selection')
return true
}, [activeQueueSessionKey, attachments, clearDraft, draftRef])
}, [activeQueueSessionKey, attachments, clearDraft, draftRef, scope.attachments])
// All queue drain paths share one lock + send-then-remove sequence.
// `pickEntry` lets each caller choose head, by-id, or skip-edited.
@@ -2,13 +2,14 @@ import { type RefObject, useEffect, useRef } from 'react'
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { clearComposerAttachments, clearSessionDraft, type ComposerAttachment } from '@/store/composer'
import { clearSessionDraft, type ComposerAttachment } from '@/store/composer'
import { resetBrowseState } from '@/store/composer-input-history'
import { enqueueQueuedPrompt, type QueuedPromptEntry } from '@/store/composer-queue'
import { cloneAttachments, type QueueEditState } from '../composer-utils'
import { onComposerSubmitRequest } from '../focus'
import { composerPlainText } from '../rich-editor'
import { useComposerScope } from '../scope'
import type { ChatBarProps } from '../types'
interface UseComposerSubmitArgs {
@@ -71,6 +72,8 @@ export function useComposerSubmit({
setComposerText,
stashAt
}: UseComposerSubmitArgs) {
const scope = useComposerScope()
// Shared send primitive: fire onSubmit, and if the gateway rejects (accepted
// === false) or throws, re-load + re-stash the draft so the words survive.
const dispatchSubmit = (text: string, attachments?: ComposerAttachment[]) => {
@@ -163,7 +166,7 @@ export function useComposerSubmit({
triggerHaptic('submit')
resetBrowseState(sessionId)
clearDraft()
clearComposerAttachments()
scope.attachments.clear()
dispatchSubmit(text, submittedAttachments)
}
@@ -8,6 +8,7 @@ import { notifyError } from '@/store/notifications'
import { $messages } from '@/store/session'
import { $autoSpeakReplies, setAutoSpeakReplies } from '@/store/voice-prefs'
import type { ComposerTarget } from '../focus'
import { onComposerVoiceToggleRequest } from '../focus'
import type { ChatBarProps } from '../types'
@@ -25,6 +26,9 @@ interface UseComposerVoiceArgs {
onSubmit: ChatBarProps['onSubmit']
onTranscribeAudio: ChatBarProps['onTranscribeAudio']
sessionId: string | null | undefined
/** This composer's focus-bus key — voice toggles targeting another
* composer (or the active one, when not us) are ignored. */
target: ComposerTarget
}
/**
@@ -42,7 +46,8 @@ export function useComposerVoice({
maxRecordingSeconds,
onSubmit,
onTranscribeAudio,
sessionId
sessionId,
target
}: UseComposerVoiceArgs) {
const { t } = useI18n()
const [voiceConversationActive, setVoiceConversationActive] = useState(false)
@@ -122,7 +127,10 @@ export function useComposerVoice({
}
}, [conversation, disabled, voiceConversationActive])
useEffect(() => onComposerVoiceToggleRequest(toggleVoiceConversation), [toggleVoiceConversation])
useEffect(
() => onComposerVoiceToggleRequest(toggled => toggled === target && toggleVoiceConversation()),
[target, toggleVoiceConversation]
)
// Explicit start/end for the on-screen conversation controls (the hotkey uses
// the gated toggle above).
+54 -23
View File
@@ -1,21 +1,21 @@
import { ComposerPrimitive } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { type ClipboardEvent, type FormEvent, type KeyboardEvent, useEffect, useRef } from 'react'
import { type ClipboardEvent, type FormEvent, type KeyboardEvent, useCallback, useEffect, useRef } from 'react'
import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-dock'
import { Button } from '@/components/ui/button'
import { Slot as ContribSlot } from '@/contrib/react/slot'
import { useI18n } from '@/i18n'
import { chatMessageText } from '@/lib/chat-messages'
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $composerAttachments } from '@/store/composer'
import { browseBackward, browseForward, deriveUserHistory, isBrowsingHistory } from '@/store/composer-input-history'
import { POPOUT_WIDTH_REM } from '@/store/composer-popout'
import { removeQueuedPrompt } from '@/store/composer-queue'
import { $activeSessionAwaitingInput } from '@/store/prompts'
import { toggleReview } from '@/store/review'
import { $gatewayState, $messages } from '@/store/session'
import { $gatewayState } from '@/store/session'
import { $threadScrolledUp } from '@/store/thread-scroll'
import { $autoSpeakReplies } from '@/store/voice-prefs'
import { useTheme } from '@/themes'
@@ -23,6 +23,7 @@ import { useTheme } from '@/themes'
import { AttachmentList } from './attachments'
import { COMPOSER_FADE_BACKGROUND, type QueueEditState, slashArgStage } from './composer-utils'
import { ContextMenu } from './context-menu'
import { COMPOSER_AREAS, runComposerMiddleware } from './contrib'
import { ComposerControls } from './controls'
import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from './drop-affordance'
import { markActiveComposer } from './focus'
@@ -51,6 +52,7 @@ import {
normalizeComposerEditorDom,
RICH_INPUT_SLOT
} from './rich-editor'
import { useComposerScope } from './scope'
import { ComposerStatusStack } from './status-stack'
import { CodingStatusRow } from './status-stack/coding-row'
import { extractClipboardImageBlobs } from './text-utils'
@@ -79,17 +81,36 @@ export function ChatBar({
onPickImages,
onRemoveAttachment,
onSteer,
onSubmit,
onSubmit: onSubmitProp,
onTranscribeAudio
}: ChatBarProps) {
const attachments = useStore($composerAttachments)
// Every send (typed, queued, voice) passes through the contributed
// middleware chain first — rewrite / pass-through / cancel. Empty chain =
// exact pass-through, so surfaces without contributions are byte-identical.
const onSubmit = useCallback<ChatBarProps['onSubmit']>(
async (value, options) => {
const draft = await runComposerMiddleware({ text: value, attachments: options?.attachments })
if (!draft) {
return false
}
return onSubmitProp(draft.text, { ...options, attachments: draft.attachments })
},
[onSubmitProp]
)
// Which live composer this instance IS (main | tile) — its attachment set,
// focus-bus key, and awaiting-input edge. Main scope = the legacy globals.
const scope = useComposerScope()
const attachments = useStore(scope.attachments.$attachments)
const scrolledUp = useStore($threadScrolledUp)
const autoSpeak = useStore($autoSpeakReplies)
// The turn is parked on the user (clarify / approval / sudo / secret). Esc must
// not interrupt it — there's nothing actively running to stop, and stopping
// would discard a question the user may want to come back to. The blocking
// prompt owns its own dismissal (Skip, Reject, dialog close).
const awaitingInput = useStore($activeSessionAwaitingInput)
const awaitingInput = useStore(scope.$awaitingInput)
const activeQueueSessionKey = queueSessionKey || sessionId || null
// Status items (subagents, background processes) are keyed by the RUNTIME
@@ -188,7 +209,7 @@ export function ChatBar({
const statusStackVisible = queuedPrompts.length > 0 || statusPresent
const { stacked } = useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut })
const { compactPill, stacked } = useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut })
const hasComposerPayload = hasText || attachments.length > 0
const canSubmit = busy || hasComposerPayload
const busyAction = busy && hasComposerPayload ? 'queue' : 'stop'
@@ -197,8 +218,6 @@ export function ChatBar({
// into a tool result) and never for a slash command (those execute inline).
const canSteer = busy && !!onSteer && attachments.length === 0 && isSteerableText
const showHelpHint = isHelpHint
// The submit engine — the orchestration seam where draft + queue meet. Owns
// the submit decision tree, the send-with-restore primitive, and steer.
const { steerDraft, submitDraft } = useComposerSubmit({
@@ -267,7 +286,7 @@ export function ChatBar({
normalizeComposerEditorDom(editor)
const nextDraft = composerPlainText(editor)
const nextDraft = sanitizeComposerInput(composerPlainText(editor))
if (nextDraft !== draftRef.current) {
draftRef.current = nextDraft
@@ -332,7 +351,7 @@ export function ChatBar({
// blank lines (common when selecting from terminals, code blocks, web pages)
// doesn't dump multiline padding into the composer. Internal newlines are
// preserved — only the edges are cleaned up.
const pastedText = event.clipboardData.getData('text').trim()
const pastedText = sanitizeComposerInput(event.clipboardData.getData('text').trim())
if (!pastedText) {
event.preventDefault()
@@ -506,11 +525,11 @@ export function ChatBar({
// $messages is read imperatively (not subscribed) so the composer
// doesn't re-render on every streaming delta flush.
const history = deriveUserHistory($messages.get(), chatMessageText)
const history = deriveUserHistory(scope.readMessages(), chatMessageText)
const entry = browseBackward(sessionId, currentDraft, history)
if (entry !== null) {
loadIntoComposer(entry, $composerAttachments.get())
loadIntoComposer(entry, scope.attachments.$attachments.get())
}
return
@@ -531,11 +550,11 @@ export function ChatBar({
event.preventDefault()
triggerKeyConsumedRef.current = true
const history = deriveUserHistory($messages.get(), chatMessageText)
const history = deriveUserHistory(scope.readMessages(), chatMessageText)
const result = browseForward(sessionId, history)
if (result !== null) {
loadIntoComposer(result.text, $composerAttachments.get())
loadIntoComposer(result.text, scope.attachments.$attachments.get())
}
}
@@ -643,7 +662,7 @@ export function ChatBar({
useComposerBranch({ clearDraft, cwd, draftRef })
// Global Esc-to-cancel when the chat (not the composer input) has focus.
useComposerEscCancel({ awaitingInput, busy, onCancel })
useComposerEscCancel({ awaitingInput, busy, onCancel, target: scope.target })
const {
conversation,
@@ -663,7 +682,8 @@ export function ChatBar({
maxRecordingSeconds,
onSubmit,
onTranscribeAudio,
sessionId
sessionId,
target: scope.target
})
const contextMenu = (
@@ -685,7 +705,7 @@ export function ChatBar({
busyAction={busyAction}
canSteer={canSteer}
canSubmit={canSubmit}
compactModelPill={poppedOut}
compactModelPill={poppedOut || compactPill}
conversation={{
active: voiceConversationActive,
level: conversation.level,
@@ -741,7 +761,7 @@ export function ChatBar({
}}
onDragOver={handleInputDragOver}
onDrop={handleInputDrop}
onFocus={() => markActiveComposer('main')}
onFocus={() => markActiveComposer(scope.target)}
onInput={handleEditorInput}
onKeyDown={handleEditorKeyDown}
onKeyUp={handleEditorKeyUp}
@@ -845,7 +865,7 @@ export function ChatBar({
: undefined
}
>
{showHelpHint && <HelpHint />}
{isHelpHint && <HelpHint />}
{trigger && !argStageEmpty && (
<ComposerTriggerPopover
activeIndex={triggerActive}
@@ -935,6 +955,10 @@ export function ChatBar({
)}
data-slot="composer-fade"
>
{/* 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 && (
@@ -970,10 +994,17 @@ export function ChatBar({
: '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 self-start [grid-area:menu]">{contextMenu}</div>
<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 [grid-area:controls]">{controls}</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>
</div>
</div>
@@ -1,4 +1,5 @@
import { formatRefValue } from '@/components/assistant-ui/directive-text'
import { translateNow } from '@/i18n'
import { contextPath } from '@/lib/chat-runtime'
import type { DroppedFile } from '../hooks/use-composer-actions'
@@ -8,44 +9,22 @@ import { composerPlainText, normalizeComposerEditorDom, placeCaretEnd, refChipEl
/** A chip to insert: a raw `@kind:value` string, or a typed value + display label. */
export type InlineRefInput = string | { kind: string; label?: string; value: string }
/** MIME for an in-app session drag (sidebar row → composer). */
export const HERMES_SESSION_MIME = 'application/x-hermes-session'
/** A dragged sidebar session — carried in-memory by the pointer drag session
* (session-drag.ts); sessions never ride native DnD. */
export interface SessionDragPayload {
id: string
profile: string
title: string
}
export function writeSessionDrag(transfer: DataTransfer, payload: SessionDragPayload) {
transfer.setData(HERMES_SESSION_MIME, JSON.stringify(payload))
transfer.effectAllowed = 'copy'
}
export function dragHasSession(transfer: DataTransfer | null) {
return Boolean(transfer) && Array.from(transfer!.types || []).includes(HERMES_SESSION_MIME)
}
export function readSessionDrag(transfer: DataTransfer | null): null | SessionDragPayload {
const raw = transfer?.getData(HERMES_SESSION_MIME)
if (!raw) {
return null
}
try {
const parsed = JSON.parse(raw) as Partial<SessionDragPayload>
return parsed.id ? { id: parsed.id, profile: parsed.profile || 'default', title: parsed.title || '' } : null
} catch {
return null
}
}
/** A session's friendly display label — its title, or a localized fallback. */
export const sessionLabel = ({ id, title }: SessionDragPayload) =>
title || translateNow('sidebar.row.untitledChat', id.slice(0, 8))
/** Build a `@session:<profile>/<id>` chip. Value carries the metadata the agent
* needs to resolve the link (session_search); label shows the friendly title. */
export function sessionInlineRef({ id, profile, title }: SessionDragPayload): InlineRefInput {
return { kind: 'session', label: title || `chat ${id.slice(0, 8)}`, value: `${profile || 'default'}/${id}` }
export function sessionInlineRef(payload: SessionDragPayload): InlineRefInput {
return { kind: 'session', label: sessionLabel(payload), value: `${payload.profile || 'default'}/${payload.id}` }
}
export function dragHasAttachments(transfer: DataTransfer | null, pathsMime: string) {
@@ -0,0 +1,47 @@
import type { ReadableAtom } from 'nanostores'
import { createContext, useContext } from 'react'
import type { ChatMessage } from '@/lib/chat-messages'
import { type ComposerAttachmentScope, mainComposerScope } from '@/store/composer'
import { $activeSessionAwaitingInput } from '@/store/prompts'
import { $messages } from '@/store/session'
import type { ComposerTarget } from './focus'
/**
* COMPOSER SCOPE — which live composer a ChatBar instance IS. The main chat's
* ChatBar runs in the default scope (module-level attachment atom, focus-bus
* target 'main', the active session's awaiting-input edge). A session tile
* mounts its ChatBar under its own scope, so N composers coexist: separate
* attachment chips, separate focus/insert routing, separate Esc semantics.
*
* Draft TEXT needs no scoping — it lives in each ChatBar's contentEditable +
* draftRef and stashes per session key (`stashSessionDraft`), which already
* differs per surface.
*/
export interface ComposerScope {
/** This scope's "turn parked on user input" edge — gates Esc-to-stop. */
$awaitingInput: ReadableAtom<boolean>
attachments: ComposerAttachmentScope
/** Only the main scope may pop out (the floating composer is a singleton). */
popoutAllowed: boolean
/** Imperative read of this scope's transcript (input-history browse) —
* never subscribed, so streaming stays out of the composer's renders. */
readMessages: () => ChatMessage[]
/** Focus-bus routing key (`'main'` | `'tile:<id>'`). */
target: ComposerTarget
}
export const MAIN_COMPOSER_SCOPE: ComposerScope = {
$awaitingInput: $activeSessionAwaitingInput,
attachments: mainComposerScope,
popoutAllowed: true,
readMessages: () => $messages.get(),
target: 'main'
}
const ComposerScopeContext = createContext<ComposerScope>(MAIN_COMPOSER_SCOPE)
export const ComposerScopeProvider = ComposerScopeContext.Provider
export const useComposerScope = (): ComposerScope => useContext(ComposerScopeContext)
@@ -255,22 +255,46 @@ export function partitionDroppedFiles(candidates: DroppedFile[]): {
return { osDrops, inAppRefs }
}
/** The composer these actions feed. Defaults to the main chat's scope;
* session tiles pass their own so picks/drops/pastes land in THEIR chips. */
interface ComposerActionsScope {
add: (attachment: ComposerAttachment) => void
remove: (id: string) => ComposerAttachment | null
target: string
}
const MAIN_ACTIONS_SCOPE: ComposerActionsScope = {
add: addComposerAttachment,
remove: removeComposerAttachment,
target: 'main'
}
interface ComposerActionsOptions {
activeSessionId: string | null
currentCwd: string
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
scope?: ComposerActionsScope
}
/** Add to the main composer and focus it. All sidebar/picker/drop attach paths funnel through here. */
const attachToMain = (attachment: ComposerAttachment) => {
addComposerAttachment(attachment)
requestComposerFocus('main')
}
export function useComposerActions({ activeSessionId, currentCwd, requestGateway }: ComposerActionsOptions) {
export function useComposerActions({
activeSessionId,
currentCwd,
requestGateway,
scope = MAIN_ACTIONS_SCOPE
}: ComposerActionsOptions) {
const { t } = useI18n()
const copy = t.desktop
/** Add to this scope's composer and focus it. All sidebar/picker/drop
* attach paths funnel through here. */
const attachToMain = useCallback(
(attachment: ComposerAttachment) => {
scope.add(attachment)
requestComposerFocus(scope.target)
},
[scope]
)
const addTextToDraft = useCallback((text: string) => {
requestComposerInsert(text, { mode: 'block' })
}, [])
@@ -302,7 +326,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
detail,
refText
})
}, [])
}, [attachToMain])
const pickContextPaths = useCallback(
async (kind: 'file' | 'folder') => {
@@ -329,7 +353,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
})
}
},
[currentCwd]
[attachToMain, currentCwd]
)
const insertContextPathInlineRef = useCallback(
@@ -344,12 +368,12 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
return false
}
requestComposerInsertRefs([ref])
requestComposerFocus('main')
requestComposerInsertRefs([ref], { target: scope.target })
requestComposerFocus(scope.target)
return true
},
[currentCwd]
[currentCwd, scope.target]
)
const attachContextFilePath = useCallback(
@@ -371,7 +395,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
return true
},
[currentCwd]
[attachToMain, currentCwd]
)
const attachImagePath = useCallback(
@@ -394,7 +418,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
const previewUrl = await attachmentPreviewDataUrl(filePath)
if (previewUrl) {
addComposerAttachment({ ...baseAttachment, previewUrl })
scope.add({ ...baseAttachment, previewUrl })
}
return true
@@ -404,7 +428,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
return true
}
},
[copy.imagePreviewFailed]
[attachToMain, copy.imagePreviewFailed, scope]
)
const attachImageBlob = useCallback(
@@ -509,7 +533,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
return true
},
[currentCwd]
[attachToMain, currentCwd]
)
const attachDroppedItems = useCallback(
@@ -599,7 +623,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
const removeAttachment = useCallback(
async (id: string) => {
const removed = removeComposerAttachment(id)
const removed = scope.remove(id)
if (
removed?.kind === 'image' &&
@@ -614,7 +638,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
}).catch(() => undefined)
}
},
[activeSessionId, requestGateway]
[activeSessionId, requestGateway, scope]
)
return {
@@ -1,53 +1,75 @@
import { type DragEvent as ReactDragEvent, useCallback, useRef, useState } from 'react'
import { type DragEvent as ReactDragEvent, useCallback, useEffect, useRef, useState } from 'react'
import {
dragHasAttachments,
dragHasSession,
readSessionDrag,
type SessionDragPayload
} from '@/app/chat/composer/inline-refs'
import { dragHasAttachments } from '@/app/chat/composer/inline-refs'
import { ESCAPE_PRIORITY, pushEscapeLayer } from '@/lib/escape-layers'
import { type DroppedFile, extractDroppedFiles, HERMES_PATHS_MIME } from './use-composer-actions'
/** `'session'` is set by callers from the pointer drag session's store —
* native drags only ever resolve to `'files'` here (sessions left native
* DnD; see session-drag.ts). */
export type DragKind = 'files' | 'session' | null
const dragKindOf = (event: ReactDragEvent): DragKind => {
if (dragHasSession(event.dataTransfer)) {
return 'session'
}
if (dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
return 'files'
}
return null
}
const dragKindOf = (event: ReactDragEvent): DragKind =>
dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME) ? 'files' : null
interface FileDropZoneOptions {
/** When false the zone ignores drags entirely. */
enabled?: boolean
onDropFiles: (files: DroppedFile[]) => void
onDropSession?: (session: SessionDragPayload) => void
}
/**
* "Drop anywhere in this region" affordance for files *and* in-app session
* links. An enter/leave depth counter keeps nested children from flickering the
* "Drop anywhere in this region" affordance for FILE drags — the one drag
* kind still on native DnD (Finder/OS drops and the project tree must be).
* An enter/leave depth counter keeps nested children from flickering the
* active state; `onDropCapture` clears it even when a nested target (the
* composer) handles the drop and stops propagation before our bubble-phase
* `onDrop` would fire.
*
* Spread `dropHandlers` onto the container; render an overlay off `dragKind`.
* Esc aborts an in-flight drag, matching the sidebar session drag.
*/
export function useFileDropZone({ enabled = true, onDropFiles, onDropSession }: FileDropZoneOptions) {
export function useFileDropZone({ enabled = true, onDropFiles }: FileDropZoneOptions) {
const [dragKind, setDragKind] = useState<DragKind>(null)
const depth = useRef(0)
const aborted = useRef(false)
const reset = useCallback(() => {
depth.current = 0
setDragKind(null)
}, [])
// Esc aborts a file drag — the same "never mind" a session drag gets. Native
// DnD can't be cancelled at the OS level, so we drop the overlay and arm a
// guard that swallows the trailing drop instead. Top escape layer + capture
// stop so it doesn't also fire a handler behind the drag (see drag-session).
useEffect(() => {
if (dragKind === null) {
return
}
const releaseLayer = pushEscapeLayer(ESCAPE_PRIORITY.drag)
const onKey = (event: KeyboardEvent) => {
if (event.key !== 'Escape') {
return
}
event.preventDefault()
event.stopPropagation()
aborted.current = true
reset()
}
window.addEventListener('keydown', onKey, true)
return () => {
window.removeEventListener('keydown', onKey, true)
releaseLayer()
}
}, [dragKind, reset])
const onDragEnter = useCallback(
(event: ReactDragEvent) => {
const kind = enabled ? dragKindOf(event) : null
@@ -57,6 +79,12 @@ export function useFileDropZone({ enabled = true, onDropFiles, onDropSession }:
}
event.preventDefault()
// A genuinely new drag (not a nested-child re-enter) re-arms after abort.
if (depth.current === 0) {
aborted.current = false
}
depth.current += 1
setDragKind(kind)
},
@@ -89,16 +117,17 @@ export function useFileDropZone({ enabled = true, onDropFiles, onDropSession }:
return
}
// Only an Esc abort swallows the drop — NOT `event.defaultPrevented`. The
// file tree's app-wide react-dnd HTML5Backend preventDefaults every native
// file drop in the capture phase, so that flag is always set here (every
// Finder drop would no-op). Genuine nested targets claim via stopPropagation
// and never reach this bubble handler anyway.
const claimed = aborted.current
event.preventDefault()
reset()
if (kind === 'session') {
const session = readSessionDrag(event.dataTransfer)
if (session) {
onDropSession?.(session)
}
if (claimed) {
return
}
@@ -108,7 +137,7 @@ export function useFileDropZone({ enabled = true, onDropFiles, onDropSession }:
onDropFiles(files)
}
},
[enabled, onDropFiles, onDropSession, reset]
[enabled, onDropFiles, reset]
)
return {
+84 -108
View File
@@ -1,32 +1,22 @@
import {
type AppendMessage,
AssistantRuntimeProvider,
ExportedMessageRepository,
type ThreadMessage
} from '@assistant-ui/react'
import { type AppendMessage, AssistantRuntimeProvider, type ThreadMessage } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import type * as React from 'react'
import { Suspense, useCallback, useMemo, useRef } from 'react'
import { Suspense, useCallback, useMemo } from 'react'
import { useLocation } from 'react-router-dom'
import { Thread } from '@/components/assistant-ui/thread'
import { Backdrop } from '@/components/Backdrop'
import { COMPOSER_HEART_CONFIG, HeartField } from '@/components/chat/vibe-hearts'
import { $sessionTileDragging, $sessionTileEdgeHover } from '@/components/pane-shell/tree/store'
import { PromptOverlays } from '@/components/prompt-overlays'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { ErrorState } from '@/components/ui/error-state'
import { TitleMenuTrigger } from '@/components/ui/title-menu-trigger'
import { getGlobalModelOptions, type HermesGateway } from '@/hermes'
import { useI18n } from '@/i18n'
import type { ChatMessage } from '@/lib/chat-messages'
import {
coalesceToolOnlyAssistants,
createToolMergeCache,
quickModelOptions,
sessionTitle,
toRuntimeMessage
} from '@/lib/chat-runtime'
import { quickModelOptions, sessionTitle } from '@/lib/chat-runtime'
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
import { cn } from '@/lib/utils'
import type { ComposerAttachment } from '@/store/composer'
@@ -35,23 +25,14 @@ import { $petActive } from '@/store/pet'
import { $petOverlayActive } from '@/store/pet-overlay'
import { $gatewaySwapTarget } from '@/store/profile'
import {
$activeSessionId,
$awaitingResponse,
$busy,
$contextSuggestions,
$currentCwd,
$currentModel,
$currentProvider,
$freshDraftReady,
$gatewayState,
$introPersonality,
$introSeed,
$lastVisibleMessageIsUser,
$messages,
$messagesEmpty,
$resumeExhaustedSessionId,
$selectedStoredSessionId,
$sessions,
sessionMatchesStoredId,
sessionPinId
} from '@/store/session'
import { isSecondaryWindow, isWatchWindow } from '@/store/windows'
@@ -63,12 +44,15 @@ import { titlebarHeaderBaseClass, titlebarHeaderShadowClass, titlebarHeaderTitle
import { ChatDropOverlay } from './chat-drop-overlay'
import { ChatSwapOverlay } from './chat-swap-overlay'
import { ChatBar, ChatBarFallback } from './composer'
import { requestComposerInsert, requestComposerInsertRefs } from './composer/focus'
import { droppedFileInlineRefs, type SessionDragPayload, sessionInlineRef } from './composer/inline-refs'
import { requestComposerInsert } from './composer/focus'
import { droppedFileInlineRefs } from './composer/inline-refs'
import { useComposerScope } from './composer/scope'
import type { ChatBarState } from './composer/types'
import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions'
import { useFileDropZone } from './hooks/use-file-drop-zone'
import { type DragKind, useFileDropZone } from './hooks/use-file-drop-zone'
import { useRuntimeMessageRepository } from './runtime-repository'
import { ScrollToBottomButton } from './scroll-to-bottom-button'
import { useSessionView } from './session-view'
import { SessionActionsMenu } from './sidebar/session-actions-menu'
import { threadLoadingState } from './thread-loading'
@@ -122,7 +106,7 @@ function ChatHeader({
const pinnedSessionIds = useStore($pinnedSessionIds)
const activeStoredSession =
sessions.find(session => session.id === selectedSessionId || session._lineage_root_id === selectedSessionId) || null
(selectedSessionId && sessions.find(session => sessionMatchesStoredId(session, selectedSessionId))) || null
const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New session'
@@ -160,14 +144,7 @@ function ChatHeader({
sideOffset={8}
title={title}
>
<Button
className="pointer-events-auto flex h-6 min-w-0 max-w-full gap-1 overflow-hidden border border-transparent bg-transparent px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-control-active-background) [-webkit-app-region:no-drag]"
type="button"
variant="ghost"
>
<h2 className="min-w-0 flex-1 truncate text-[0.75rem] font-medium leading-none">{title}</h2>
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="chevron-down" size="0.8125rem" />
</Button>
<TitleMenuTrigger>{title}</TitleMenuTrigger>
</SessionActionsMenu>
</div>
</header>
@@ -207,45 +184,9 @@ function ChatRuntimeBoundary({
onThreadMessagesChange,
suppressMessages
}: ChatRuntimeBoundaryProps) {
const storeMessages = useStore($messages)
const storeMessages = useStore(useSessionView().$messages)
const messages = suppressMessages ? NO_MESSAGES : storeMessages
const runtimeMessageCacheRef = useRef(new WeakMap<ChatMessage, ThreadMessage>())
const toolMergeCacheRef = useRef(createToolMergeCache())
const runtimeMessageRepository = useMemo(() => {
const items: { message: ThreadMessage; parentId: string | null }[] = []
const branchParentByGroup = new Map<string, string | null>()
let visibleParentId: string | null = null
let headId: string | null = null
for (const message of coalesceToolOnlyAssistants(messages, toolMergeCacheRef.current)) {
let parentId = visibleParentId
if (message.role === 'assistant' && message.branchGroupId) {
if (!branchParentByGroup.has(message.branchGroupId)) {
branchParentByGroup.set(message.branchGroupId, visibleParentId)
}
parentId = branchParentByGroup.get(message.branchGroupId) ?? null
}
const cachedMessage = runtimeMessageCacheRef.current.get(message)
const runtimeMessage = cachedMessage ?? toRuntimeMessage(message)
if (!cachedMessage) {
runtimeMessageCacheRef.current.set(message, runtimeMessage)
}
items.push({ message: runtimeMessage, parentId })
if (!message.hidden) {
visibleParentId = message.id
headId = message.id
}
}
return ExportedMessageRepository.fromBranchableArray(items, { headId })
}, [messages])
const runtimeMessageRepository = useRuntimeMessageRepository(messages)
const runtime = useIncrementalExternalStoreRuntime<ThreadMessage>({
messageRepository: runtimeMessageRepository,
@@ -293,13 +234,24 @@ export function ChatView({
}: ChatViewProps) {
const location = useLocation()
const { t } = useI18n()
const activeSessionId = useStore($activeSessionId)
const awaitingResponse = useStore($awaitingResponse)
const busy = useStore($busy)
// The view this surface renders: the primary route-driven session (global
// atoms) or a tile's session slice — same component either way.
const view = useSessionView()
const composerScope = useComposerScope()
const isPrimary = view.kind === 'primary'
const activeSessionId = useStore(view.$runtimeId)
const storedId = useStore(view.$storedId)
// Dock anchor for a session drop onto this surface: the workspace pane for the
// primary, this tile's pane id for a tile. Read by the session-drop bridge.
const sessionAnchor = isPrimary ? 'workspace' : `session-tile:${storedId ?? ''}`
const awaitingResponse = useStore(view.$awaitingResponse)
const busy = useStore(view.$busy)
const contextSuggestions = useStore($contextSuggestions)
const currentCwd = useStore($currentCwd)
const currentModel = useStore($currentModel)
const currentProvider = useStore($currentProvider)
// Per-session (SessionView) reads — a tile IS its session, so these come
// from the view slice, not the global atoms (which track the primary only).
const currentCwd = useStore(view.$cwd)
const currentModel = useStore(view.$model)
const currentProvider = useStore(view.$provider)
// A pet anywhere (in-window or popped out) owns the hearts; composer only when none.
const petActive = useStore($petActive)
const petOverlayActive = useStore($petOverlayActive)
@@ -310,16 +262,18 @@ export function ChatView({
const gatewayOpen = gatewayState === 'open'
const introPersonality = useStore($introPersonality)
const introSeed = useStore($introSeed)
// PERF: ChatView must not subscribe to $messages — the atom is replaced on
// every streaming delta flush (~30×/s) and a subscription here re-renders
// the entire chat shell (header, chat bar, thread wrapper) per token. The
// runtime that DOES need the messages lives in ChatRuntimeBoundary below;
// this component only needs streaming-stable derivations.
const messagesEmpty = useStore($messagesEmpty)
const lastVisibleIsUser = useStore($lastVisibleMessageIsUser)
const selectedSessionId = useStore($selectedStoredSessionId)
// PERF: ChatView must not subscribe to the view's $messages — the atom is
// replaced on every streaming delta flush (~30×/s) and a subscription here
// re-renders the entire chat shell (header, chat bar, thread wrapper) per
// token. The runtime that DOES need the messages lives in
// ChatRuntimeBoundary below; this component only needs streaming-stable
// derivations.
const messagesEmpty = useStore(view.$messagesEmpty)
const lastVisibleIsUser = useStore(view.$lastVisibleIsUser)
const selectedSessionId = useStore(view.$storedId)
const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId)
const routedSessionId = routeSessionId(location.pathname)
// A tile IS its session — no route involved, never "mismatched".
const routedSessionId = isPrimary ? routeSessionId(location.pathname) : selectedSessionId
const isRoutedSessionView = Boolean(routedSessionId)
// The URL points at a session the store hasn't loaded yet (sidebar / cmd-K /
@@ -331,6 +285,7 @@ export function ChatView({
// The compact new-session pop-out skips the wordmark/tagline intro — it's a
// scratch window, not the full-height empty state.
const showIntro =
isPrimary &&
!isSecondaryWindow() &&
freshDraftReady &&
!isRoutedSessionView &&
@@ -349,7 +304,7 @@ export function ChatView({
// Suppress the loader and show an explicit error + manual Retry instead of
// spinning forever. Gated on the route matching so a stale latch from another
// session can't blank the current one.
const resumeExhausted = isRoutedSessionView && resumeExhaustedSessionId === routedSessionId
const resumeExhausted = isPrimary && isRoutedSessionView && resumeExhaustedSessionId === routedSessionId
const loadingSession =
!resumeExhausted && isRoutedSessionView && (routeSessionMismatch || (messagesEmpty && !activeSessionId))
@@ -420,23 +375,33 @@ export function ChatView({
const refs = droppedFileInlineRefs(inAppRefs, currentCwd)
if (refs.length) {
requestComposerInsert(refs.join(' '), { mode: 'inline', target: 'main' })
requestComposerInsert(refs.join(' '), { mode: 'inline', target: composerScope.target })
}
if (osDrops.length) {
void onAttachDroppedItems(osDrops)
}
},
[currentCwd, onAttachDroppedItems]
[composerScope.target, currentCwd, onAttachDroppedItems]
)
// Dropping a sidebar session inserts an @session link the agent can resolve
// via session_search (carries the source profile, so cross-profile works).
const onDropSession = useCallback((session: SessionDragPayload) => {
requestComposerInsertRefs([sessionInlineRef(session)], { target: 'main' })
}, [])
// Session drags are POINTER drags (session-drag.ts) — never native DnD.
// The drop zone below only handles files; session drops commit through the
// drag session itself, which routes a center/link drop to this surface's
// composer via `data-composer-target`.
const { dragKind, dropHandlers } = useFileDropZone({ enabled: showChatBar, onDropFiles })
const { dragKind, dropHandlers } = useFileDropZone({ enabled: showChatBar, onDropFiles, onDropSession })
// While a session drag targets one of this surface's EDGES or a tab strip,
// the zone overlay/caret owns the visual — the link overlay stands down.
// It shows for the whole drag on every chat surface otherwise (the drag
// session's global sentinel, not a per-surface hover chain).
// COMPUTED booleans, never the raw `$dropHint`: the hint churns on every
// pointer-crossing of every drag (pane drags included), and a re-render
// here is the WHOLE surface — thread, composer, header — per mounted tile.
const sessionDragging = useStore($sessionTileDragging)
const sessionEdgeHover = useStore($sessionTileEdgeHover)
const overlayKind: DragKind = dragKind === 'files' ? 'files' : sessionDragging && !sessionEdgeHover ? 'session' : null
return (
<div
@@ -444,17 +409,26 @@ export function ChatView({
'relative isolate flex h-full min-w-0 flex-col overflow-hidden bg-(--ui-chat-surface-background)',
className
)}
data-composer-target={composerScope.target}
data-session-anchor={sessionAnchor}
>
<Backdrop />
<ChatHeader
activeSessionId={activeSessionId}
isRoutedSessionView={isRoutedSessionView}
onDeleteSelectedSession={onDeleteSelectedSession}
onToggleSelectedPin={onToggleSelectedPin}
selectedSessionId={selectedSessionId}
/>
{/* Tiles get their chrome from the layout zone (chip strip); the modal
prompt overlays stay active-session-scoped in the primary surface. */}
{isPrimary && (
<ChatHeader
activeSessionId={activeSessionId}
isRoutedSessionView={isRoutedSessionView}
onDeleteSelectedSession={onDeleteSelectedSession}
onToggleSelectedPin={onToggleSelectedPin}
selectedSessionId={selectedSessionId}
/>
)}
<PromptOverlays />
{/* Mounted for the primary AND every tile, each scoped to its own session
so a tiled/background session's blocking prompt surfaces instead of
stalling to timeout. */}
<PromptOverlays sessionId={activeSessionId} />
<ChatRuntimeBoundary
busy={busy}
@@ -510,7 +484,9 @@ export function ChatView({
}}
/>
)}
<ChatDropOverlay kind={dragKind} />
{/* A session drag hovering an EDGE hands the visual to the zone
target; the link overlay shows only for the center region. */}
<ChatDropOverlay kind={overlayKind} />
<ChatSwapOverlay profile={gatewaySwapTarget} />
</div>
{/* Composer renders OUTSIDE the contain:[layout paint] wrapper above:
+121
View File
@@ -0,0 +1,121 @@
/**
* Mirror a reactive list of "tiles" into layout-tree pane contributions:
* register a pane per tile, refresh its title in place, and dispose panes whose
* tile is gone. This is the shared bookkeeping — a keyed registry, a wanted-set
* diff, a one-time pane closer — behind BOTH session tiles and route (page)
* tiles; each supplies only what differs (key, title, render, close, edge).
*/
import type { ReadableAtom } from 'nanostores'
import type { ReactElement, ReactNode, PointerEvent as ReactPointerEvent } from 'react'
import type { DoubleTapContext } from '@/components/pane-shell/tree/renderer/drag-session'
import { registerPaneCloser, removeTreePane, treePanesWithPrefix } from '@/components/pane-shell/tree/store'
import { registry } from '@/contrib/registry'
import type { TileDock } from '@/store/session-states'
export interface PaneMirror<T> {
/** Reactive source list. */
source: ReadableAtom<T[]>
/** Extra atoms whose changes should re-sync (e.g. titles living elsewhere). */
also?: ReadableAtom<unknown>[]
/** Stable key + pane-id seed for a tile. */
key: (tile: T) => string
/** Pane-id namespace — the id is `${prefix}:${key}`. */
prefix: string
/** Dock on adoption (default right; `center` = stack into anchor's zone). */
dir?: (tile: T) => TileDock | undefined
/** Pane to dock against (default `workspace`) — a drop's target zone. */
anchor?: (tile: T) => string | undefined
/** Center docks: the strip slot (stack before this pane id). */
before?: (tile: T) => null | string | undefined
minWidth: string
title: (key: string) => string
render: (key: string) => ReactNode
/** Wrap the tile's TAB (domain context menu — session verbs). */
tabWrap?: (key: string, tab: ReactElement) => ReactNode
/** Override the tile's TAB drag (session drop language: stack/split/link).
* Returns whether it took the drag (see PaneChrome.tabDrag). */
tabDrag?: (
key: string,
event: ReactPointerEvent<HTMLElement>,
onTap: () => void,
double?: DoubleTapContext
) => boolean
/** Wired as the pane's closer (tab Close). */
close: (key: string) => void
}
/** Build a `watch*` fn: syncs once, then re-syncs on every source/also change.
* Module-level state lives in the returned closure, so call it once per app. */
export function paneMirror<T>(cfg: PaneMirror<T>): () => void {
const registered = new Map<string, { dispose: () => void; title: string }>()
const paneId = (key: string) => `${cfg.prefix}:${key}`
const sync = () => {
const tiles = cfg.source.get()
const wanted = new Set(tiles.map(cfg.key))
for (const tile of tiles) {
const key = cfg.key(tile)
const title = cfg.title(key)
const current = registered.get(key)
// register() replaces same-id in place — safe for live title refreshes.
if (current && current.title === title) {
continue
}
const dispose = registry.register({
id: paneId(key),
area: 'panes',
title,
data: {
dock: {
before: cfg.before?.(tile),
pane: cfg.anchor?.(tile) ?? 'workspace',
pos: cfg.dir?.(tile) ?? 'right'
},
minWidth: cfg.minWidth,
placement: 'main',
tabDrag: cfg.tabDrag
? (event: ReactPointerEvent<HTMLElement>, onTap: () => void, double?: DoubleTapContext) =>
cfg.tabDrag!(key, event, onTap, double)
: undefined, // returns boolean (handled) — see PaneChrome.tabDrag
tabWrap: cfg.tabWrap ? (tab: ReactElement) => cfg.tabWrap!(key, tab) : undefined
},
render: () => cfg.render(key)
})
registered.set(key, { dispose, title })
if (!current) {
registerPaneCloser(paneId(key), () => cfg.close(key))
}
}
for (const [key, entry] of registered) {
if (!wanted.has(key)) {
entry.dispose()
registered.delete(key)
removeTreePane(paneId(key))
}
}
// Prune tree panes the SHARED tree persisted for a tile we never registered
// this session and that isn't wanted now — a profile switch reloads with the
// other profile's tile panes still stacked in. (`registered` is empty after a
// reload, so the loop above can't catch these.)
for (const id of treePanesWithPrefix(`${cfg.prefix}:`)) {
if (!wanted.has(id.slice(cfg.prefix.length + 1))) {
removeTreePane(id)
}
}
}
return () => {
sync()
cfg.source.listen(sync)
cfg.also?.forEach(atom => atom.listen(sync))
}
}
@@ -1 +1 @@
export { ChatPreviewRail, PREVIEW_RAIL_MAX_WIDTH, PREVIEW_RAIL_MIN_WIDTH, PREVIEW_RAIL_PANE_WIDTH } from './preview'
export { ChatPreviewRail, PREVIEW_RAIL_MAX_WIDTH, PREVIEW_RAIL_MIN_WIDTH } from './preview'
@@ -498,8 +498,10 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
event.preventDefault()
event.stopPropagation()
// Insert into and focus the SAME composer — 'active' — so a tile that owns
// focus keeps it instead of the ref landing in a tile but main stealing focus.
requestComposerInsertRefs([ref])
requestComposerFocus('main')
requestComposerFocus('active')
}
window.addEventListener('keydown', onKeyDown, { capture: true })
@@ -10,6 +10,7 @@ import {
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import { PANE_TAB_STRIP_LINE, PaneTab, PaneTabLabel } from '@/components/ui/pane-tab'
import { Tip } from '@/components/ui/tooltip'
import { translateNow, useI18n } from '@/i18n'
import { formatCombo } from '@/lib/keybinds/combo'
@@ -38,14 +39,6 @@ import { PreviewPane } from './preview-pane'
export const PREVIEW_RAIL_MIN_WIDTH = '18rem'
export const PREVIEW_RAIL_MAX_WIDTH = '38rem'
const INTRINSIC = `clamp(${PREVIEW_RAIL_MIN_WIDTH}, 36vw, 32rem)`
// Track for <Pane id="preview">. Folds the intrinsic clamp with a min-floor
// against --chat-min-width so the chat surface never gets squeezed below it.
// Subtracts the project browser width so preview yields rather than crushing
// the chat when both right-side panes are open.
export const PREVIEW_RAIL_PANE_WIDTH = `min(${INTRINSIC}, max(0rem, calc(100vw - var(--pane-chat-sidebar-width) - var(--pane-file-browser-width, 0rem) - var(--chat-min-width))))`
interface ChatPreviewRailProps {
onRestartServer?: (url: string, context?: string) => Promise<string>
setTitlebarToolGroup?: SetTitlebarToolGroup
@@ -110,7 +103,12 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
// titlebar-height so it opens below the band. 0px elsewhere → unchanged.
style={{ paddingTop: 'var(--right-rail-top-inset, 0px)' }}
>
<div className="group/rail-tabs flex h-(--titlebar-height) shrink-0 border-b border-(--ui-stroke-tertiary) bg-(--ui-sidebar-surface-background)">
<div
className={cn(
'group/rail-tabs flex h-(--titlebar-height) shrink-0 bg-(--ui-sidebar-surface-background)',
PANE_TAB_STRIP_LINE
)}
>
<div
className="flex min-w-0 flex-1 overflow-x-auto overflow-y-hidden overscroll-x-contain [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
role="tablist"
@@ -124,67 +122,20 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
return (
<ContextMenu key={tab.id}>
<ContextMenuTrigger asChild>
<div
className={cn(
'group/tab relative flex h-full min-w-0 max-w-48 shrink-0 items-center text-[0.6875rem] font-medium [-webkit-app-region:no-drag] last:border-r last:border-(--ui-stroke-quaternary)',
active
? 'bg-(--ui-editor-surface-background) text-foreground [--tab-bg:var(--ui-editor-surface-background)]'
: 'border-r border-(--ui-stroke-quaternary) text-(--ui-text-tertiary) [--tab-bg:var(--ui-sidebar-surface-background)] hover:bg-(--chrome-action-hover) hover:text-foreground'
)}
// Middle-click closes the tab, matching browser/IDE muscle
// memory. `onMouseDown` swallows the middle-button press so
// Chromium doesn't switch into autoscroll mode.
onAuxClick={event => {
if (event.button !== 1) {
return
}
event.preventDefault()
closeRightRailTab(tab.id)
}}
onMouseDown={event => {
if (event.button === 1) {
event.preventDefault()
}
}}
>
{active && (
<span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-(--ui-stroke-primary)" />
)}
<PaneTab active={active} dirty={dirty} onClose={() => closeRightRailTab(tab.id)}>
<Tip label={tab.target.path || tab.target.url || tab.label}>
<button
<PaneTabLabel
aria-selected={active}
className="flex h-full min-w-0 max-w-full items-center overflow-hidden pl-3 pr-2 text-left outline-none"
as="button"
className="normal-case tracking-normal"
onClick={() => selectRightRailTab(tab.id)}
role="tab"
type="button"
>
<span className="block min-w-0 truncate">{tab.label}</span>
</button>
{tab.label}
</PaneTabLabel>
</Tip>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 right-0 w-9 bg-[linear-gradient(to_right,transparent,var(--tab-bg)_55%)] opacity-0 transition-opacity group-hover/tab:opacity-100 group-focus-within/tab:opacity-100"
/>
{dirty && (
<span
aria-hidden="true"
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center opacity-100 transition-opacity group-hover/tab:opacity-0 group-focus-within/tab:opacity-0"
>
{/* Amber (our warn color); a tab-bg ring + soft drop keeps it
legible where it overlaps the filename. */}
<span className="size-2 rounded-full bg-amber-500 shadow-[0_0_0_2px_var(--tab-bg),0_1px_2px_rgba(0,0,0,0.45)] dark:bg-amber-400" />
</span>
)}
<button
aria-label={t.preview.closeTab(tab.label)}
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center rounded-sm text-(--ui-text-tertiary) opacity-0 transition-[background-color,color,opacity] hover:bg-(--ui-bg-secondary) hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/tab:pointer-events-auto group-hover/tab:opacity-100 group-focus-within/tab:pointer-events-auto group-focus-within/tab:opacity-100"
onClick={() => closeRightRailTab(tab.id)}
type="button"
>
<Codicon name="close" size="0.75rem" />
</button>
</div>
</PaneTab>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={() => closeRightRailTab(tab.id)}>
+90
View File
@@ -0,0 +1,90 @@
/**
* ROUTE (PAGE) TILES — a full-page view rendered as a layout-tree pane BESIDE
* the main thread, the page analog of session tiles. Built-in pages
* (Capabilities / Messaging / Artifacts) render their view; plugin pages render
* their `ROUTES_AREA` contribution. Lifecycle mirrors session tiles:
* `openRouteTile(path)` -> `watchRouteTiles` registers a pane docked beside
* main -> tree adoption lands it on the chosen edge; closing removes it.
*/
import { lazy, type ReactNode, Suspense } from 'react'
import { ContribBoundary } from '@/contrib/react/boundary'
import { useContributions } from '@/contrib/react/use-contributions'
import { $routeTiles, closeRouteTile, type RouteTile } from '@/store/route-tiles'
import { ARTIFACTS_ROUTE, contributedRoutes, MESSAGING_ROUTE, ROUTES_AREA, SKILLS_ROUTE } from '../routes'
import { paneMirror } from './pane-mirror'
const SkillsView = lazy(async () => ({ default: (await import('../skills')).SkillsView }))
const MessagingView = lazy(async () => ({ default: (await import('../messaging')).MessagingView }))
const ArtifactsView = lazy(async () => ({ default: (await import('../artifacts')).ArtifactsView }))
// Built-in page views + their pane titles, keyed by route.
const BUILTIN_PAGES: Record<string, { render: () => ReactNode; title: string }> = {
[ARTIFACTS_ROUTE]: { render: () => <ArtifactsView />, title: 'Artifacts' },
[MESSAGING_ROUTE]: { render: () => <MessagingView />, title: 'Messaging' },
[SKILLS_ROUTE]: { render: () => <SkillsView />, title: 'Capabilities' }
}
/** Humanize a route path into a tab title: `/my-atlas` → `My Atlas`. */
const humanizePath = (path: string): string =>
path
.replace(/^\/+/, '')
.split(/[/-]/)
.filter(Boolean)
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ') || path
/** Title for a route tile: the built-in name, the contribution's own `title`,
* else a humanized path — never the internal `${source}:${id}` key. */
function routeTitle(path: string): string {
if (BUILTIN_PAGES[path]) {
return BUILTIN_PAGES[path].title
}
return contributedRoutes().find(r => r.path === path)?.title ?? humanizePath(path)
}
function RouteTilePane({ path }: { path: string }) {
const builtin = BUILTIN_PAGES[path]
// Subscribe so a plugin page tile appears the moment its route registers.
useContributions(ROUTES_AREA)
const contrib = builtin ? null : contributedRoutes().find(r => r.path === path)
if (builtin) {
return (
<ContribBoundary id={path}>
<Suspense fallback={null}>{builtin.render()}</Suspense>
</ContribBoundary>
)
}
if (contrib) {
return <ContribBoundary id={path}>{contrib.render()}</ContribBoundary>
}
return (
<div className="grid h-full place-items-center font-mono text-[11px] text-(--ui-text-quaternary)">
no page at {path}
</div>
)
}
// ---------------------------------------------------------------------------
// Route tile -> pane contribution sync (call once from the app root).
// ---------------------------------------------------------------------------
/** Keep pane contributions mirroring `$routeTiles`. Call once from the root. */
export const watchRouteTiles = paneMirror<RouteTile>({
source: $routeTiles,
key: t => t.path,
prefix: 'route-tile',
dir: t => t.dir,
minWidth: '22rem',
title: routeTitle,
render: path => <RouteTilePane path={path} />,
close: closeRouteTile
})
@@ -0,0 +1,51 @@
import { ExportedMessageRepository, type ThreadMessage } from '@assistant-ui/react'
import { useMemo, useRef } from 'react'
import type { ChatMessage } from '@/lib/chat-messages'
import { coalesceToolOnlyAssistants, createToolMergeCache, toRuntimeMessage } from '@/lib/chat-runtime'
/**
* ChatMessage[] -> assistant-ui message repository, with a WeakMap identity
* cache so unchanged messages convert once (and a tool-merge cache that folds
* tool-only assistant turns into their neighbour). Shared by the main chat's
* runtime boundary and session tiles — one transcript pipeline, N surfaces.
*/
export function useRuntimeMessageRepository(messages: ChatMessage[]): ExportedMessageRepository {
const cacheRef = useRef(new WeakMap<ChatMessage, ThreadMessage>())
const toolMergeCacheRef = useRef(createToolMergeCache())
return useMemo(() => {
const items: { message: ThreadMessage; parentId: string | null }[] = []
const branchParentByGroup = new Map<string, string | null>()
let visibleParentId: string | null = null
let headId: string | null = null
for (const message of coalesceToolOnlyAssistants(messages, toolMergeCacheRef.current)) {
let parentId = visibleParentId
if (message.role === 'assistant' && message.branchGroupId) {
if (!branchParentByGroup.has(message.branchGroupId)) {
branchParentByGroup.set(message.branchGroupId, visibleParentId)
}
parentId = branchParentByGroup.get(message.branchGroupId) ?? null
}
const cachedMessage = cacheRef.current.get(message)
const runtimeMessage = cachedMessage ?? toRuntimeMessage(message)
if (!cachedMessage) {
cacheRef.current.set(message, runtimeMessage)
}
items.push({ message: runtimeMessage, parentId })
if (!message.hidden) {
visibleParentId = message.id
headId = message.id
}
}
return ExportedMessageRepository.fromBranchableArray(items, { headId })
}, [messages])
}
+193
View File
@@ -0,0 +1,193 @@
/**
* Sidebar session drag — the session RESOLVER over the shared pointer drag
* session (pane-shell drag-session.ts). Same machinery as a pane drag
* (threshold, rAF moves, snapshots, Esc-as-top-layer with synchronous
* teardown), session-specific targeting:
*
* - a chat zone's TAB STRIP → stack: open the session as a tab at the
* divider's slot (the strip caret shows it);
* - a chat zone's EDGE band → split: open the session as a tile docked on
* that edge (the zone sheet morphs to the half);
* - a chat zone's CENTER / the composer → link: insert an `@session` chip
* into that surface's composer (ChatDropOverlay owns the visual);
* - anything else (sidebar, terminal, gutters) → deny.
*
* Zones that don't host a chat surface are NOT targets — the overlay never
* lights them, so a release there must not commit either (one truth).
*
* This replaced the native-HTML5 drag + SessionTileDropBridge: riding the
* native DnD layer meant macOS's cancel snap-back animation, a `dragend`
* held hostage until that animation finished, an Esc the page never even
* saw, and window-level armor against react-dnd/dnd-kit. A pointer session
* has none of those failure modes. Native DnD remains only at the true OS
* boundary (Finder file drops). Known trade: a session can no longer be
* dragged into a separate BrowserWindow (native DnD was the only transport
* that crossed windows).
*/
import type { PointerEvent as ReactPointerEvent } from 'react'
import { findGroup } from '@/components/pane-shell/tree/model'
import {
type DoubleTapContext,
rectContains,
slotBefore,
snapshotStrips,
snapshotZones,
startDragSession,
type StripSnapshot,
subZonePosition
} from '@/components/pane-shell/tree/renderer/drag-session'
import {
$layoutTree,
$treeDragging,
type DropHint,
revealTreePane,
SESSION_TILE_DRAG
} from '@/components/pane-shell/tree/store'
import type { EngineZone, ZoneRect } from '@/components/pane-shell/tree/zones-engine'
import { openSessionTile, type TileDock } from '@/store/session-states'
import { requestComposerInsertRefs } from './composer/focus'
import { type SessionDragPayload, sessionInlineRef, sessionLabel } from './composer/inline-refs'
/** A chat surface's drag-start geometry: the anchor pane id it advertises
* (`data-session-anchor`) and the composer a link drop routes to
* (`data-composer-target`). */
interface SurfaceSnapshot {
anchor: string
composerTarget: string
rect: ZoneRect
}
const snapRect = (el: HTMLElement): ZoneRect => {
const r = el.getBoundingClientRect()
return { left: r.left, top: r.top, right: r.right, bottom: r.bottom }
}
function snapshotSurfaces(): SurfaceSnapshot[] {
return [...document.querySelectorAll<HTMLElement>('[data-session-anchor]')].map(el => ({
anchor: el.dataset.sessionAnchor || 'workspace',
composerTarget: el.dataset.composerTarget || 'main',
rect: snapRect(el)
}))
}
/** A session may land in a zone only if it hosts a chat surface — never the
* sidebar/terminal zones. Returns the pane a stack anchors to. */
function chatZonePane(groupId: string): null | string {
const tree = $layoutTree.get()
const panes = tree ? (findGroup(tree, groupId)?.panes ?? []) : []
return panes.find(p => p === 'workspace' || p.startsWith('session-tile:')) ?? null
}
/**
* Begin dragging a session — a sidebar row OR a tile's own tab (same drop
* language either way: stack, split, or composer link). Sub-threshold releases
* stay ordinary clicks, so `opts.onTap` (activate the tile) and `opts.double`
* (hide the tab bar) ride the tab's gestures; Esc aborts instantly. A stack/
* split commits through `openSessionTile`, which OPENS a new tile from a sidebar
* row and MOVES the existing one when its tab is the drag source.
*/
export function startSessionDrag(
payload: SessionDragPayload,
e: ReactPointerEvent<HTMLElement>,
opts?: { double?: DoubleTapContext; onTap?: () => void }
) {
let zones: EngineZone[] = []
let strips: StripSnapshot[] = []
let surfaces: SurfaceSnapshot[] = []
let composers: ZoneRect[] = []
let zoneHost = new Map<string, null | string>()
// Commit intent, updated per resolved move (the machinery flushes the final
// move before commit, so these always match the released-at position).
let split: { anchor: string; before?: null | string; pos: TileDock } | null = null
let link: null | string = null
// The drag SOURCE (sidebar row or tile tab). Captured synchronously — React
// clears `currentTarget` after the pointerdown handler returns, but this runs
// inside it. Dimmed while lifted so the source reads as "picked up" — the
// same in-place feedback pane-tab drags use, replacing the old cursor chip.
const source = e.currentTarget
const restoreOpacity = source?.style.opacity ?? ''
startDragSession(e, {
double: opts?.double,
ghost: { label: sessionLabel(payload) },
onTap: opts?.onTap,
onEngage() {
zones = snapshotZones()
strips = snapshotStrips()
surfaces = snapshotSurfaces()
composers = [...document.querySelectorAll<HTMLElement>('[data-slot="composer-root"]')].map(snapRect)
zoneHost = new Map(zones.map(zone => [zone.id, chatZonePane(zone.id)]))
source?.style.setProperty('opacity', '0.45')
// The same sentinel the zone overlay + chat surfaces key off — the
// whole drop language (sheets, pills, caret, link overlay) lights up.
$treeDragging.set(SESSION_TILE_DRAG)
},
onEnd() {
if (source) {
source.style.opacity = restoreOpacity
}
},
resolveMove(x, y): DropHint | null {
const zone = zones.find(z => rectContains(z.rect, x, y))
const host = zone ? zoneHost.get(zone.id) : null
if (!zone || !host) {
split = null
link = null
return null
}
// The zone's TAB STRIP stacks the session at the divider's slot.
const strip = strips.find(s => s.groupId === zone.id && rectContains(s.rect, x, y))
if (strip) {
// Exclude the tile's OWN tab from the slots so re-dropping it in its
// home strip reorders cleanly (a no-op for a sidebar-row drag).
const stack = slotBefore(strip.slots, x, `session-tile:${payload.id}`)
split = { anchor: host, before: stack.before, pos: 'center' }
link = null
return { kind: 'group', groupId: zone.id, groupIds: [zone.id], pos: 'center', stack }
}
// The composer (and everything in it) is always the link/attach drop;
// elsewhere the shared radial targeting decides center vs edge.
const pos = composers.some(rect => rectContains(rect, x, y)) ? 'center' : subZonePosition(zones, zone.id, x, y)
const surface = surfaces.find(s => rectContains(s.rect, x, y))
if (pos === 'center') {
split = null
link = surface?.composerTarget ?? 'main'
} else {
split = { anchor: surface?.anchor ?? 'workspace', pos }
link = null
}
return { kind: 'group', groupId: zone.id, groupIds: [zone.id], pos }
},
onCommit() {
if (split) {
openSessionTile(payload.id, split.pos, split.anchor, split.before)
// A tile for this session may already exist (openSessionTile is
// idempotent — e.g. persisted from an earlier run): a drop must never
// feel dead, so front/unhide/un-dismiss it either way.
revealTreePane(`session-tile:${payload.id}`)
} else if (link) {
// The "link to chat" drop: an @session chip in that surface's composer.
requestComposerInsertRefs([sessionInlineRef(payload)], { target: link })
}
}
})
}
@@ -0,0 +1,359 @@
/**
* Prompt actions for a SESSION TILE — the same verbs the primary chat wires
* (submit incl. slash, cancel, steer, edit, reload, restore, branch-hide
* sync), targeted at the tile's session instead of the active one. State
* writes go through the delegate's `updateSession` (the wiring cache), so
* the cache, the primary view, and every tile mirror stay one truth; view
* concerns (busy pill, transcript) reach the tile via its `$sessionStates`
* slice — never the global `$busy`/`$messages`.
*/
import type { AppendMessage, ThreadMessage } from '@assistant-ui/react'
import { useCallback, useMemo, useRef } from 'react'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import type { ClientSessionState } from '@/app/types'
import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes'
import { useI18n } from '@/i18n'
import { textPart } from '@/lib/chat-messages'
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { clearClarifyRequest } from '@/store/clarify'
import type { ComposerAttachment } from '@/store/composer'
import { resetSessionBackground } from '@/store/composer-status'
import { notifyError } from '@/store/notifications'
import { clearPreviewArtifacts } from '@/store/preview-status'
import { clearAllPrompts } from '@/store/prompts'
import { $connection } from '@/store/session'
import { $sessionStates, sessionTileDelegate } from '@/store/session-states'
import { clearSessionSubagents } from '@/store/subagents'
import { clearSessionTodos } from '@/store/todos'
import { uploadComposerAttachment } from '../session/hooks/use-prompt-actions'
import {
applyBranchVisibility,
applyReloadOptimistic,
applyRewindOptimistic,
finalizeInterruptedMessages,
planEdit,
planReload,
planRestore,
runRewindSubmit
} from '../session/hooks/use-prompt-actions/rewind'
import { useSubmitPrompt } from '../session/hooks/use-prompt-actions/submit'
import { type SubmitTextOptions } from '../session/hooks/use-prompt-actions/utils'
import type { ComposerScope } from './composer/scope'
interface SessionTileActionsArgs {
runtimeId: string
scope: ComposerScope
storedSessionId: string
}
export function useSessionTileActions({ runtimeId, scope, storedSessionId }: SessionTileActionsArgs) {
const { t } = useI18n()
const copy = t.desktop
const { requestGateway } = useGatewayRequest()
const runtimeIdRef = useRef(runtimeId)
runtimeIdRef.current = runtimeId
const storedIdRef = useRef(storedSessionId)
storedIdRef.current = storedSessionId
// Tile busy tracks the SESSION state, never the global $busy — and it must
// read LIVE. A render-time snapshot goes stale (this hook's host doesn't
// re-render on busy edges), and a stale `true` silently blocks every
// subsequent submit ("tile only sends one message"). The setter is a no-op:
// session state owns busy; submit's optimistic writes flow through
// updateSession.
const busyRef = useMemo(
() =>
({
get current() {
return $sessionStates.get()[runtimeIdRef.current]?.busy ?? false
},
set current(_value: boolean) {
// Owned by session state.
}
}) as { current: boolean },
[]
)
const update = useCallback(
(updater: (state: ClientSessionState) => ClientSessionState) =>
sessionTileDelegate()?.updateSession(runtimeIdRef.current, updater),
[]
)
const readState = useCallback(() => $sessionStates.get()[runtimeIdRef.current], [])
const readMessages = useCallback(() => readState()?.messages ?? [], [readState])
// Tile-side attachment staging: same upload rules as the primary submit
// (skip synced/pathless, byte-upload files+images), against the tile scope.
const syncAttachmentsForSubmit = useCallback(
async (
sessionId: string,
attachments: ComposerAttachment[],
options: { updateComposerAttachments?: boolean } = {}
): Promise<ComposerAttachment[]> => {
const remote = $connection.get()?.mode === 'remote'
const synced: ComposerAttachment[] = []
for (const attachment of attachments) {
if (!attachment.path || attachment.attachedSessionId === sessionId) {
synced.push(attachment)
continue
}
if (attachment.kind === 'image' || attachment.kind === 'file') {
const next = await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId })
if (options.updateComposerAttachments ?? true) {
scope.attachments.update(next)
}
synced.push(next)
continue
}
synced.push(attachment)
}
return synced
},
[requestGateway, scope.attachments]
)
// The REAL submit pipeline with tile seams: session always exists, and the
// scope's writers replace the global view/attachment writes.
const submitPromptText = useSubmitPrompt({
activeSessionId: runtimeId,
activeSessionIdRef: runtimeIdRef,
busyRef,
copy,
createBackendSessionForSend: async () => runtimeIdRef.current,
// A tile IS its session — no route to abandon, so the create-abort guard's
// token is a stable constant (the guard never trips for a tile).
getRouteToken: () => runtimeId,
requestGateway,
selectedStoredSessionIdRef: storedIdRef,
syncAttachmentsForSubmit,
updateSessionState: (sessionId, updater) => sessionTileDelegate()!.updateSession(sessionId, updater),
scope: {
clearAttachments: scope.attachments.clear,
readAttachments: () => scope.attachments.$attachments.get(),
// Busy/messages flow through updateSession -> the tile's state slice;
// the primary view atoms must never see a tile turn.
setAwaitingResponse: () => undefined,
setBusy: () => undefined,
setMessages: () => undefined
}
})
const submitText = useCallback(
async (rawText: string, options?: SubmitTextOptions) => {
const visibleText = rawText.trim()
const attachments = options?.attachments ?? scope.attachments.$attachments.get()
if (!attachments.length && SLASH_COMMAND_RE.test(visibleText)) {
triggerHaptic('selection')
await sessionTileDelegate()?.executeSlash(visibleText, runtimeIdRef.current)
return true
}
return await submitPromptText(rawText, options)
},
[scope.attachments.$attachments, submitPromptText]
)
const appendSystemNote = useCallback(
(text: string) => {
update(state => ({
...state,
messages: [
...state.messages,
{ id: `system-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, role: 'system', parts: [textPart(text)] }
]
}))
},
[update]
)
const cancelRun = useCallback(async () => {
const sessionId = runtimeIdRef.current
update(state => ({
...state,
messages: finalizeInterruptedMessages(state.messages, state.streamId),
busy: false,
awaitingResponse: false,
streamId: null,
pendingBranchGroup: null,
needsInput: false,
interrupted: true
}))
clearSessionTodos(sessionId)
clearSessionSubagents(sessionId)
resetSessionBackground(sessionId)
clearAllPrompts(sessionId)
clearClarifyRequest(undefined, sessionId)
try {
await requestGateway('session.interrupt', { session_id: sessionId })
} catch (err) {
notifyError(err, copy.stopFailed)
}
}, [copy.stopFailed, requestGateway, update])
const steerPrompt = useCallback(
async (rawText: string): Promise<boolean> => {
const text = rawText.trim()
if (!text) {
return false
}
try {
const result = await requestGateway<{ status?: string }>('session.steer', {
session_id: runtimeIdRef.current,
text
})
if (result?.status === 'queued') {
triggerHaptic('submit')
appendSystemNote(`steer:${text}`)
return true
}
} catch {
// Swallow — the caller queues the text so nothing is lost.
}
return false
},
[appendSystemNote, requestGateway]
)
// Rewind primitive (interrupt-first for live turns, busy-retry) — shared with
// the primary chat so the two can't diverge.
const submitRewind = useCallback(
(text: string, truncateOrdinal: number | undefined, interruptFirst: boolean) =>
runRewindSubmit(requestGateway, runtimeIdRef.current, text, truncateOrdinal, interruptFirst),
[requestGateway]
)
const reloadFromMessage = useCallback(
async (parentId: string | null) => {
const state = readState()
if (!state || state.busy) {
return
}
const plan = planReload(state.messages, parentId)
if (!plan) {
return
}
update(current => applyReloadOptimistic(current, plan))
try {
await requestGateway(
'prompt.submit',
{ session_id: runtimeIdRef.current, text: plan.text, truncate_before_user_ordinal: plan.truncateOrdinal },
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS
)
} catch (err) {
update(current => ({ ...current, busy: false, awaitingResponse: false }))
notifyError(err, copy.regenerateFailed)
}
},
[copy.regenerateFailed, readState, requestGateway, update]
)
const restoreToMessage = useCallback(
async (messageId: string, target?: { text?: string; userOrdinal?: number | null }) => {
const sessionId = runtimeIdRef.current
const messages = readMessages()
const plan = planRestore(messages, messageId, target)
clearSessionTodos(sessionId)
resetSessionBackground(sessionId)
clearPreviewArtifacts(sessionId)
const wasBusy = readState()?.busy ?? false
update(state => applyRewindOptimistic(state, plan.sourceIndex))
try {
await submitRewind(plan.text, plan.truncateOrdinal, wasBusy)
} catch (err) {
update(state => ({ ...state, busy: false, awaitingResponse: false, messages }))
throw err
}
},
[readMessages, readState, submitRewind, update]
)
const editMessage = useCallback(
async (edited: AppendMessage) => {
const messages = readMessages()
const plan = planEdit(messages, edited)
if (!plan) {
return
}
const sessionId = runtimeIdRef.current
clearSessionTodos(sessionId)
resetSessionBackground(sessionId)
clearPreviewArtifacts(sessionId)
const wasBusy = readState()?.busy ?? false
update(state => applyRewindOptimistic(state, plan.sourceIndex, plan.editedMessage))
try {
await submitRewind(plan.text, plan.truncateOrdinal, wasBusy)
} catch (err) {
update(state => ({ ...state, busy: false, awaitingResponse: false, messages }))
notifyError(err, copy.editFailed)
}
},
[copy.editFailed, readMessages, readState, submitRewind, update]
)
// Branch-visibility sync (assistant-ui hides non-active branches).
const handleThreadMessagesChange = useCallback(
(nextMessages: readonly ThreadMessage[]) => update(state => applyBranchVisibility(state, nextMessages)),
[update]
)
const dismissError = useCallback(
(messageId: string) => {
update(state => ({ ...state, messages: state.messages.filter(m => m.id !== messageId) }))
},
[update]
)
return useMemo(
() => ({
cancelRun,
dismissError,
editMessage,
handleThreadMessagesChange,
reloadFromMessage,
restoreToMessage,
steerPrompt,
submitText
}),
[cancelRun, dismissError, editMessage, handleThreadMessagesChange, reloadFromMessage, restoreToMessage, steerPrompt, submitText]
)
}
+436
View File
@@ -0,0 +1,436 @@
/**
* SESSION TILES — a stored session rendered as a layout-tree pane BESIDE the
* main thread (multi-session tiling). A tile IS the real chat surface: the
* same ChatView/ChatBar/Thread tree the primary session renders, mounted
* under a tile `SessionView` (its session's slice of `$sessionStates`) and a
* tile `ComposerScope` (own attachment chips, own focus-bus key). Actions
* (submit/slash/steer/edit/reload/restore/stop) come from
* `useSessionTileActions`, all writing through the wiring cache.
*
* Lifecycle: `openSessionTile(storedId)` -> `watchSessionTiles` registers a
* pane contribution docked right of the main zone -> tree adoption lands it
* -> the pane mounts and asks the delegate for a live runtime id. Closing
* the pane (tab Close) removes the tile + its zone; tiles persist across
* restarts and re-resume on boot.
*/
import { useStore } from '@nanostores/react'
import { atom, computed } from 'nanostores'
import { useEffect, useMemo, useRef } from 'react'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { blobToDataUrl } from '@/app/session/hooks/use-prompt-actions/utils'
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 { Button } from '@/components/ui/button'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { transcribeAudio } from '@/hermes'
import { useI18n } from '@/i18n'
import type { ChatMessage } from '@/lib/chat-messages'
import { sessionTitle } from '@/lib/chat-runtime'
import { createComposerAttachmentScope } from '@/store/composer'
import { $pinnedSessionIds, pinSession, unpinSession } from '@/store/layout'
import { sessionAwaitingInput } from '@/store/prompts'
import {
$gatewayState,
$selectedStoredSessionId,
$sessions,
sessionMatchesStoredId,
sessionPinId
} from '@/store/session'
import {
$sessionStates,
$sessionTiles,
closeSessionTile,
discardSessionTile,
patchSessionTile,
type SessionTile,
sessionTileDelegate
} from '@/store/session-states'
import type { SessionDragPayload } from './composer/inline-refs'
import { type ComposerScope, ComposerScopeProvider } from './composer/scope'
import { useComposerActions } from './hooks/use-composer-actions'
import { paneMirror } from './pane-mirror'
import { startSessionDrag } from './session-drag'
import { useSessionTileActions } from './session-tile-actions'
import { type SessionView, SessionViewProvider } from './session-view'
import { SessionContextMenu } from './sidebar/session-actions-menu'
import { lastVisibleMessageIsUser } from './thread-loading'
import { ChatView } from '.'
const NO_MESSAGES: ChatMessage[] = []
/** The tile's SessionView: the same atom shape the primary chat renders
* from, computed from this session's slice of `$sessionStates`. */
function buildTileView(storedSessionId: string): SessionView {
const $runtimeId = computed(
$sessionTiles,
tiles => tiles.find(t => t.storedSessionId === storedSessionId)?.runtimeId ?? null
)
const $state = computed([$runtimeId, $sessionStates], (runtimeId, states) =>
runtimeId ? states[runtimeId] : undefined
)
const $messages = computed($state, state => state?.messages ?? NO_MESSAGES)
return {
kind: 'tile',
$awaitingResponse: computed($state, state => Boolean(state?.awaitingResponse)),
$busy: computed($state, state => Boolean(state?.busy)),
$cwd: computed($state, state => state?.cwd ?? ''),
$lastVisibleIsUser: computed($messages, lastVisibleMessageIsUser),
$messages,
$messagesEmpty: computed($messages, messages => messages.length === 0),
$model: computed($state, state => state?.model ?? ''),
$provider: computed($state, state => state?.provider ?? ''),
$runtimeId,
// Constant for the tile's lifetime — a plain atom, not a computed.
$storedId: atom(storedSessionId)
}
}
function TileChat({
runtimeId,
storedSessionId,
view
}: {
runtimeId: string
storedSessionId: string
view: SessionView
}) {
const { gatewayRef, requestGateway } = useGatewayRequest()
const cwd = useStore(view.$cwd)
// One attachment set + focus key per tile, stable for the tile's lifetime.
const attachments = useRef(createComposerAttachmentScope()).current
const scope = useMemo<ComposerScope>(
() => ({
$awaitingInput: sessionAwaitingInput(runtimeId),
attachments,
popoutAllowed: false,
readMessages: () => view.$messages.get(),
target: `tile:${storedSessionId}`
}),
[attachments, runtimeId, storedSessionId, view.$messages]
)
const actions = useSessionTileActions({ runtimeId, scope, storedSessionId })
// The same attach/pick/paste/drop pipeline the primary composer uses,
// pointed at this tile's chips + session.
const composer = useComposerActions({
activeSessionId: runtimeId,
currentCwd: cwd,
requestGateway,
scope: { add: attachments.add, remove: attachments.remove, target: scope.target }
})
return (
<SessionViewProvider value={view}>
<ComposerScopeProvider value={scope}>
<ChatView
gateway={gatewayRef.current}
onAddContextRef={composer.addContextRefAttachment}
onAddUrl={url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)}
onAttachDroppedItems={composer.attachDroppedItems}
onAttachImageBlob={composer.attachImageBlob}
onBranchInNewChat={() => undefined}
onCancel={actions.cancelRun}
onDeleteSelectedSession={() => undefined}
onDismissError={actions.dismissError}
onEdit={actions.editMessage}
onPasteClipboardImage={opts => composer.pasteClipboardImage(opts)}
onPickFiles={() => void composer.pickContextPaths('file')}
onPickFolders={() => void composer.pickContextPaths('folder')}
onPickImages={() => void composer.pickImages()}
onReload={actions.reloadFromMessage}
onRemoveAttachment={id => void composer.removeAttachment(id)}
onRestoreToMessage={actions.restoreToMessage}
onRetryResume={() => patchSessionTile(storedSessionId, { error: undefined })}
onSteer={actions.steerPrompt}
onSubmit={actions.submitText}
onThreadMessagesChange={actions.handleThreadMessagesChange}
onToggleSelectedPin={() => undefined}
onTranscribeAudio={async audio => (await transcribeAudio(await blobToDataUrl(audio), audio.type)).transcript}
/>
</ComposerScopeProvider>
</SessionViewProvider>
)
}
export function SessionTilePane({ storedSessionId }: { storedSessionId: string }) {
const tiles = useStore($sessionTiles)
const tile = tiles.find(t => t.storedSessionId === storedSessionId)
const runtimeId = tile?.runtimeId ?? null
const gatewayOpen = useStore($gatewayState) === 'open'
const resumingRef = useRef(false)
const view = useMemo(() => buildTileView(storedSessionId), [storedSessionId])
// Same gating as the primary's route resume (use-route-resume): never fire
// session.resume before the gateway is OPEN. Persisted tiles mount at boot
// while it's still connecting — an ungated resume rejected there and
// latched every restored tile into the error card.
useEffect(() => {
if (!gatewayOpen || runtimeId || tile?.error || resumingRef.current) {
return
}
const delegate = sessionTileDelegate()
if (!delegate) {
return
}
resumingRef.current = true
delegate
.resumeTile(storedSessionId)
.then(id => patchSessionTile(storedSessionId, { error: undefined, runtimeId: id }))
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
// A gone session (404 / "Session not found") is terminal — a stale or
// cross-profile persisted tile. Discard it instead of latching an error
// that re-retries on every reconnect (the "Session not found" spam).
if (/session not found|\b404\b/i.test(message)) {
discardSessionTile(storedSessionId)
} else {
patchSessionTile(storedSessionId, { error: message })
}
})
.finally(() => {
resumingRef.current = false
})
}, [gatewayOpen, runtimeId, storedSessionId, tile?.error])
// The gateway (re)opening invalidates any latched error — it likely came
// from a not-yet-open gateway or the previous connection. Clearing it
// retriggers the resume effect: one bounded auto-retry per (re)connect,
// mirroring the primary path's became-open resync.
useEffect(() => {
if (gatewayOpen && tile?.error) {
patchSessionTile(storedSessionId, { error: undefined })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gatewayOpen, storedSessionId])
if (tile?.error) {
return (
<div className="grid h-full place-items-center p-4">
<div className="max-w-[24rem] space-y-2 text-center font-mono text-[11px]">
<div className="text-(--ui-danger,#f87171)">Couldn't open this session</div>
<div className="break-words text-(--ui-text-quaternary)">{tile.error}</div>
<Button onClick={() => patchSessionTile(storedSessionId, { error: undefined })} size="sm" variant="outline">
Retry
</Button>
</div>
</div>
)
}
if (!runtimeId) {
// The SAME session loader the primary thread shows (Thread's
// loading === 'session' branch) — one loading language everywhere.
return (
<div className="relative h-full">
<CenteredThreadSpinner />
</div>
)
}
return <TileChat runtimeId={runtimeId} storedSessionId={storedSessionId} view={view} />
}
// ---------------------------------------------------------------------------
// Tile -> pane contribution sync (call once from the app root).
// ---------------------------------------------------------------------------
function tileTitle(storedSessionId: string): string {
const stored = $sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId))
return stored ? sessionTitle(stored) : 'Session'
}
/** The `@session` link payload for a tile tab drag — id + owning profile + title. */
function tileDragPayload(storedSessionId: string): SessionDragPayload {
const stored = $sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId))
return { id: storedSessionId, profile: stored?.profile ?? '', title: tileTitle(storedSessionId) }
}
// ---------------------------------------------------------------------------
// Close confirmation — a BUSY tab (streaming, or blocked on clarify/approval
// input) doesn't close silently.
// ---------------------------------------------------------------------------
/** Stored id awaiting close confirmation (null = no dialog). */
const $confirmCloseTile = atom<null | string>(null)
/** The tile closer, gated: a quiet session closes immediately; a busy or
* input-blocked one asks first. One state read — the tile's runtime slice. */
export function requestCloseSessionTile(storedSessionId: string): void {
const runtimeId = $sessionTiles.get().find(t => t.storedSessionId === storedSessionId)?.runtimeId
const state = runtimeId ? $sessionStates.get()[runtimeId] : undefined
if (state?.busy || state?.awaitingResponse || state?.needsInput) {
$confirmCloseTile.set(storedSessionId)
} else {
closeSessionTile(storedSessionId)
}
}
/** Mounted once at the shell root: the "Close running tab?" confirmation. */
export function SessionTileCloseConfirm() {
const { t } = useI18n()
const storedSessionId = useStore($confirmCloseTile)
return (
<ConfirmDialog
confirmLabel={t.zones.closeRunningConfirm}
description={t.zones.closeRunningBody}
destructive
onClose={() => $confirmCloseTile.set(null)}
onConfirm={() => {
if (storedSessionId) {
closeSessionTile(storedSessionId)
}
}}
open={storedSessionId !== null}
title={t.zones.closeRunningTitle}
/>
)
}
/** Layout reset → every session tile collapses into the MAIN zone as a tab
* after the workspace (the primary session stays the first tab), the "smart"
* reset: N scattered tiles become one tab bar over the chat instead of
* re-docking to their old edges.
*
* Runs BEFORE generic adoption (see registerLayoutResetHandler) — the tiles
* aren't in the fresh tree yet, so each `moveTreePane` ADDS the tile into the
* workspace group as a tab (append). The main group id is re-read each pass
* because appending returns a new tree. */
export function stackSessionTilesIntoMain(): void {
for (const tile of $sessionTiles.get()) {
const tree = $layoutTree.get()
const mainGroup = tree ? findGroupOfPane(tree, 'workspace')?.id : null
if (mainGroup) {
moveTreePane(`session-tile:${tile.storedSessionId}`, { groupId: mainGroup, pos: 'center' })
}
}
}
/** A session TAB's context menu: the full session verb set (pin, copy id, new
* window, branch, rename, archive, delete) — the SAME menu a sidebar row
* gets, targeted through the tile delegate (whose verbs are generic over
* stored ids, primary included). The wrapper stops the contextmenu from also
* opening the zone strip's menu. Shared by tile tabs AND the main tab. */
export function SessionTabMenu({
children,
onClose,
onHideTabBar,
storedSessionId,
tabPaneId
}: {
children: React.ReactElement
/** Close this tab (tiles; the main tab passes nothing). */
onClose?: () => void
/** Hide the zone's tab bar (main tab only — the sticky bar's off switch). */
onHideTabBar?: () => void
storedSessionId: string
/** Layout-tree pane id — powers the Close-others/right/all verbs. */
tabPaneId: string
}) {
const sessions = useStore($sessions)
const pinnedSessionIds = useStore($pinnedSessionIds)
const stored = sessions.find(s => sessionMatchesStoredId(s, storedSessionId))
const pinId = stored ? sessionPinId(stored) : storedSessionId
const pinned = pinnedSessionIds.includes(pinId)
return (
<span className="contents" onContextMenu={event => event.stopPropagation()}>
<SessionContextMenu
onArchive={() => void sessionTileDelegate()?.archiveSession(storedSessionId)}
onBranch={() => void sessionTileDelegate()?.branchSession(storedSessionId)}
onClose={onClose}
onDelete={() => void sessionTileDelegate()?.deleteSession(storedSessionId)}
onHideTabBar={onHideTabBar}
onPin={() => (pinned ? unpinSession(pinId) : pinSession(pinId))}
pinned={pinned}
profile={stored?.profile}
sessionId={storedSessionId}
surface="tab"
tabPaneId={tabPaneId}
title={tileTitle(storedSessionId)}
>
{children}
</SessionContextMenu>
</span>
)
}
/** 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. */
export function WorkspaceTabMenu({ children }: { children: React.ReactElement }) {
const selected = useStore($selectedStoredSessionId)
const hideTabBar = () => {
const tree = $layoutTree.get()
const group = tree ? findGroupOfPane(tree, 'workspace') : null
if (group) {
setTreeGroupHeaderHidden(group.id, true)
}
}
if (!selected) {
return children
}
return (
<SessionTabMenu onHideTabBar={hideTabBar} storedSessionId={selected} tabPaneId="workspace">
{children}
</SessionTabMenu>
)
}
/** Keep pane contributions mirroring `$sessionTiles` (+ titles from
* `$sessions`). Tiles dock against main on the chosen edge, flex width. */
export const watchSessionTiles = paneMirror<SessionTile>({
source: $sessionTiles,
also: [$sessions],
key: t => t.storedSessionId,
prefix: 'session-tile',
dir: t => t.dir,
anchor: t => t.anchor,
before: t => t.before,
minWidth: '20rem',
title: tileTitle,
render: storedSessionId => <SessionTilePane storedSessionId={storedSessionId} />,
tabWrap: (storedSessionId, tab) => (
<SessionTabMenu
onClose={() => requestCloseSessionTile(storedSessionId)}
storedSessionId={storedSessionId}
tabPaneId={`session-tile:${storedSessionId}`}
>
{tab}
</SessionTabMenu>
),
// A tile's tab drags like a sidebar row — stack / split / drop-to-link — with
// its tap (activate) + double-tap (hide bar) preserved. Always takes the drag.
tabDrag: (storedSessionId, event, onTap, double) => {
startSessionDrag(tileDragPayload(storedSessionId), event, { double, onTap })
return true
},
close: requestCloseSessionTile
})
@@ -0,0 +1,61 @@
import type { ReadableAtom } from 'nanostores'
import { createContext, useContext } from 'react'
import type { ChatMessage } from '@/lib/chat-messages'
import {
$activeSessionId,
$awaitingResponse,
$busy,
$currentCwd,
$currentModel,
$currentProvider,
$lastVisibleMessageIsUser,
$messages,
$messagesEmpty,
$selectedStoredSessionId
} from '@/store/session'
/**
* SESSION VIEW — the store surface a ChatView renders from. The PRIMARY view
* is the app's classic global atoms (route-driven active session, untouched
* fast path). A session TILE provides the same shape computed from its
* session's slice of `$sessionStates`, so the identical ChatView tree renders
* either — one chat surface, N sessions on screen.
*
* Everything is atoms (not values) so subscription granularity survives:
* ChatView subscribes only to the coarse edges; `$messages` stays boundary-
* only exactly like the primary view's perf contract.
*/
export interface SessionView {
kind: 'primary' | 'tile'
$runtimeId: ReadableAtom<string | null>
$storedId: ReadableAtom<string | null>
$messages: ReadableAtom<ChatMessage[]>
$busy: ReadableAtom<boolean>
$awaitingResponse: ReadableAtom<boolean>
$messagesEmpty: ReadableAtom<boolean>
$lastVisibleIsUser: ReadableAtom<boolean>
$cwd: ReadableAtom<string>
$model: ReadableAtom<string>
$provider: ReadableAtom<string>
}
export const PRIMARY_SESSION_VIEW: SessionView = {
kind: 'primary',
$awaitingResponse,
$busy,
$cwd: $currentCwd,
$lastVisibleIsUser: $lastVisibleMessageIsUser,
$messages,
$messagesEmpty,
$model: $currentModel,
$provider: $currentProvider,
$runtimeId: $activeSessionId,
$storedId: $selectedStoredSessionId
}
const SessionViewContext = createContext<SessionView>(PRIMARY_SESSION_VIEW)
export const SessionViewProvider = SessionViewContext.Provider
export const useSessionView = (): SessionView => useContext(SessionViewContext)
+127 -70
View File
@@ -3,10 +3,12 @@ import { sortableKeyboardCoordinates } from '@dnd-kit/sortable'
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useLocation } from 'react-router-dom'
import { PlatformAvatar } from '@/app/messaging/platform-icon'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from '@/components/ui/context-menu'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { KbdGroup } from '@/components/ui/kbd'
import { SearchField } from '@/components/ui/search-field'
@@ -19,6 +21,7 @@ import {
SidebarMenuButton,
SidebarMenuItem
} from '@/components/ui/sidebar'
import { useContributions } from '@/contrib/react/use-contributions'
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
import { useI18n } from '@/i18n'
import { comboTokens } from '@/lib/keybinds/combo'
@@ -34,8 +37,6 @@ import {
$sidebarAgentsGrouped,
$sidebarCronOpen,
$sidebarMessagingOpenIds,
$sidebarOpen,
$sidebarOverlayMounted,
$sidebarPinsOpen,
$sidebarProjectOrderIds,
$sidebarRecentsOpen,
@@ -78,6 +79,7 @@ import {
refreshWorktrees,
scanAndRecordRepos
} from '@/store/projects'
import { openRouteTile } from '@/store/route-tiles'
import {
$cronSessions,
$currentCwd,
@@ -85,7 +87,6 @@ import {
$messagingPlatformTotals,
$messagingSessions,
$messagingTruncated,
$selectedStoredSessionId,
$sessionProfileTotals,
$sessions,
$sessionsLoading,
@@ -94,8 +95,16 @@ import {
sessionPinId,
setCurrentCwd
} from '@/store/session'
import { $focusedStoredSessionId, type SplitDir } from '@/store/session-states'
import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '../../routes'
import {
type AppView,
ARTIFACTS_ROUTE,
MESSAGING_ROUTE,
SIDEBAR_NAV_AREA,
type SidebarNavContribution,
SKILLS_ROUTE
} from '../../routes'
import type { SidebarNavItem } from '../../types'
import { countLabel } from './chrome'
@@ -121,6 +130,7 @@ import {
} from './projects'
import { SidebarBlankState, SidebarPinnedEmptyState, SidebarSessionSkeletons } from './section-states'
import { SidebarSessionsSection, VIRTUALIZE_THRESHOLD } from './sessions-section'
import { CONTEXT_SPLIT_KIT, SplitSubmenu } from './split-submenu'
// Non-session groups (messaging platforms) stay compact: show a few rows up
// front, reveal more in larger steps on demand. Keeps a busy platform from
@@ -209,6 +219,8 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
onArchiveSession: (sessionId: string) => void
onBranchSession: (sessionId: string) => void
onNewSessionInWorkspace: (path: null | string) => void
/** Create a brand-new session and open it as a tile on `dir`. */
onNewSessionSplit: (dir: SplitDir) => void
onManageCronJob: (jobId: string) => void
onTriggerCronJob: (jobId: string) => void
}
@@ -224,22 +236,49 @@ export function ChatSidebar({
onArchiveSession,
onBranchSession,
onNewSessionInWorkspace,
onNewSessionSplit,
onManageCronJob,
onTriggerCronJob
}: ChatSidebarProps) {
const { t } = useI18n()
const s = t.sidebar
const sidebarOpen = useStore($sidebarOpen)
// Collapsed-but-overlay-mounted → render the full sidebar, not just the nav rail.
const overlayMounted = useStore($sidebarOverlayMounted)
const contentVisible = sidebarOpen || overlayMounted
const { pathname } = useLocation()
// Contributed nav rows (plugins pairing a page with a sidebar entry) render
// below the built-ins with the same chrome; active = at their route.
const navContributions = useContributions(SIDEBAR_NAV_AREA)
const contributedNav = useMemo<SidebarNavItem[]>(
() =>
navContributions.flatMap(c => {
const data = c.data as Partial<SidebarNavContribution> | undefined
if (!data?.path?.startsWith('/') || !data.label) {
return []
}
const codicon = data.codicon || 'plug'
return [
{
id: c.id,
label: data.label,
icon: (props: { className?: string }) => <Codicon name={codicon} {...props} />,
route: data.path
}
]
}),
[navContributions]
)
const panesFlipped = useStore($panesFlipped)
const agentsGrouped = useStore($sidebarAgentsGrouped)
const pinnedSessionIds = useStore($pinnedSessionIds)
const pinsOpen = useStore($sidebarPinsOpen)
const agentsOpen = useStore($sidebarRecentsOpen)
const cronOpen = useStore($sidebarCronOpen)
const selectedSessionId = useStore($selectedStoredSessionId)
// The sidebar highlight tracks the FOCUSED session — the interacted tile's
// tab, else the main selection — so it stays 1:1 with whatever tab is active.
const selectedSessionId = useStore($focusedStoredSessionId)
const sessions = useStore($sessions)
const cronSessions = useStore($cronSessions)
const cronJobs = useStore($cronJobs)
@@ -1031,15 +1070,12 @@ export function ChatSidebar({
return (
<Sidebar
className={cn(
// Visibility is the layout tree's job (a hidden zone is display:none;
// the narrow overlay renders the live instance) — the sidebar always
// paints itself fully.
'relative h-full min-w-0 overflow-hidden border-t-0 border-b-0 text-foreground transition-none',
panesFlipped ? 'border-l border-r-0' : 'border-r border-l-0',
sidebarOpen
? 'border-(--sidebar-edge-border) bg-(--ui-sidebar-surface-background) opacity-100'
: 'pointer-events-none border-transparent bg-transparent opacity-0',
// While floated by PaneShell's hover-reveal, force visible + interactive
// — on hover (group-hover/reveal) or when keyboard-pinned (data-forced).
'in-data-[pane-hover-reveal=open]:pointer-events-auto in-data-[pane-hover-reveal=open]:border-(--sidebar-edge-border) in-data-[pane-hover-reveal=open]:bg-(--ui-sidebar-surface-background) in-data-[pane-hover-reveal=open]:opacity-100',
'group-hover/reveal:pointer-events-auto group-hover/reveal:border-(--sidebar-edge-border) group-hover/reveal:bg-(--ui-sidebar-surface-background) group-hover/reveal:opacity-100'
'border-(--sidebar-edge-border) bg-(--ui-sidebar-surface-background) opacity-100'
)}
collapsible="none"
>
@@ -1047,62 +1083,85 @@ export function ChatSidebar({
<SidebarGroup className="shrink-0 p-0 pb-2 pt-[calc(var(--titlebar-height)+0.375rem)]">
<SidebarGroupContent>
<SidebarMenu className="gap-px">
{SIDEBAR_NAV.map(item => {
{[...SIDEBAR_NAV, ...contributedNav].map(item => {
const isInteractive = Boolean(item.action) || Boolean(item.route)
const active =
(item.id === 'skills' && currentView === 'skills') ||
(item.id === 'messaging' && currentView === 'messaging') ||
(item.id === 'artifacts' && currentView === 'artifacts')
(item.id === 'artifacts' && currentView === 'artifacts') ||
// Contributed rows light up at their own route.
(Boolean(item.route) && pathname === item.route)
const isNewSession = item.id === 'new-session'
const button = (
<SidebarMenuButton
aria-disabled={!isInteractive}
className={cn(
// no-drag: these rows sit directly under the titlebar's
// [-webkit-app-region:drag] strips (app-shell.tsx), with only
// 6px of clearance. Drag regions win hit-testing over DOM
// (pointer-events can't override), and on Linux/WSLg the
// resolved region has been observed to swallow clicks on the
// top rows. Same carve-out as USER_BUBBLE_BASE_CLASS in
// thread.tsx.
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out [-webkit-app-region:no-drag] hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none',
active &&
'border-(--ui-stroke-tertiary) bg-(--ui-control-active-background) text-foreground shadow-none hover:border-(--ui-stroke-tertiary)!',
!isInteractive &&
'cursor-default hover:border-transparent hover:bg-transparent hover:text-inherit'
)}
onClick={() => {
// A plain new session lands in whatever profile the live
// gateway is on (= the active switcher context). null →
// no swap. The switcher header is the single place to
// change which profile that is.
if (isNewSession) {
$newChatProfile.set(null)
}
onNavigate(item)
}}
tooltip={s.nav[item.id] ?? item.label}
type="button"
>
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
<span className="min-w-0 flex-1 truncate">{s.nav[item.id] ?? item.label}</span>
{isNewSession && (
<KbdGroup
className={cn('ml-auto opacity-55', newSessionKbdFlash && 'opacity-100!')}
keys={[...NEW_SESSION_KBD]}
size="sm"
/>
)}
</SidebarMenuButton>
)
// New session + route-backed pages can open in a split —
// right-click for the directional "Open in split" submenu.
return (
<SidebarMenuItem key={item.id}>
<SidebarMenuButton
aria-disabled={!isInteractive}
className={cn(
// no-drag: these rows sit directly under the titlebar's
// [-webkit-app-region:drag] strips (app-shell.tsx), with only
// 6px of clearance. Drag regions win hit-testing over DOM
// (pointer-events can't override), and on Linux/WSLg the
// resolved region has been observed to swallow clicks on the
// top rows. Same carve-out as USER_BUBBLE_BASE_CLASS in
// thread.tsx.
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out [-webkit-app-region:no-drag] hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none',
active &&
'border-(--ui-stroke-tertiary) bg-(--ui-control-active-background) text-foreground shadow-none hover:border-(--ui-stroke-tertiary)!',
!isInteractive &&
'cursor-default hover:border-transparent hover:bg-transparent hover:text-inherit'
)}
onClick={() => {
// A plain new session lands in whatever profile the live
// gateway is on (= the active switcher context). null →
// no swap. The switcher header is the single place to
// change which profile that is.
if (isNewSession) {
$newChatProfile.set(null)
}
onNavigate(item)
}}
tooltip={s.nav[item.id] ?? item.label}
type="button"
>
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
{contentVisible && (
<>
<span className="min-w-0 flex-1 truncate">{s.nav[item.id] ?? item.label}</span>
{isNewSession && (
<KbdGroup
className={cn('ml-auto opacity-55', newSessionKbdFlash && 'opacity-100!')}
keys={[...NEW_SESSION_KBD]}
size="sm"
/>
)}
</>
)}
</SidebarMenuButton>
{isNewSession || item.route ? (
<ContextMenu>
<ContextMenuTrigger asChild>{button}</ContextMenuTrigger>
<ContextMenuContent aria-label={s.nav[item.id] ?? item.label}>
<SplitSubmenu
kit={CONTEXT_SPLIT_KIT}
label={s.row.openInSplit}
onSplit={dir => {
if (isNewSession) {
onNewSessionSplit(dir)
} else if (item.route) {
openRouteTile(item.route, dir)
}
}}
/>
</ContextMenuContent>
</ContextMenu>
) : (
button
)}
</SidebarMenuItem>
)
})}
@@ -1110,7 +1169,7 @@ export function ChatSidebar({
</SidebarGroupContent>
</SidebarGroup>
{contentVisible && showSessionSections && (
{showSessionSections && (
<div className="shrink-0 px-2 pb-1 pt-1">
<SearchField
aria-label={s.searchAria}
@@ -1122,7 +1181,7 @@ export function ChatSidebar({
</div>
)}
{contentVisible && showSessionSections && (
{showSessionSections && (
<div className={cn('flex min-h-0 flex-1 flex-col pb-1.75', SCROLL_Y)}>
{trimmedQuery && (
<SidebarSessionsSection
@@ -1392,13 +1451,11 @@ export function ChatSidebar({
</div>
)}
{contentVisible && !showSessionSections && <SidebarBlankState onNewProject={openProjectCreate} />}
{!showSessionSections && <SidebarBlankState onNewProject={openProjectCreate} />}
{contentVisible && (
<div className="shrink-0 px-0.5 pb-1 pt-0.5">
<ProfileRail />
</div>
)}
<div className="shrink-0 px-0.5 pb-1 pt-0.5">
<ProfileRail />
</div>
</SidebarContent>
<ProjectDialog />
</Sidebar>
@@ -35,6 +35,12 @@ import { getProfileSoul, updateProfileSoul } from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { PROFILE_SWATCHES, profileColorSoft, resolveProfileColor } from '@/lib/profile-color'
import {
REORDER_DRAG_TRANSITION_CSS,
REORDER_RAIL_TRANSITION,
reorderCommitHaptic,
reorderStepHaptic
} from '@/lib/reorder'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import {
@@ -67,12 +73,11 @@ const RAIL_GAP = 4 // px — matches gap-1 between squares.
// select. Drag-reorder and long-press-recolor live only on the squares path.
const PROFILE_DROPDOWN_THRESHOLD = 13
// easeOutBack — a little overshoot so squares spring into their new slot rather
// than sliding in flat. Neighbors reflow on RAIL_TRANSITION; the dragged square
// glides between snapped cells on the snappier DRAG_TRANSITION.
const SPRING = 'cubic-bezier(0.34, 1.56, 0.64, 1)'
const RAIL_TRANSITION = { duration: 300, easing: SPRING }
const DRAG_TRANSITION = `transform 200ms ${SPRING}`
// Neighbors reflow on RAIL_TRANSITION; the dragged square glides between
// snapped cells on the snappier DRAG_TRANSITION. Both come from the SHARED
// reorder primitive (lib/reorder.ts) so every reorder strip feels identical.
const RAIL_TRANSITION = REORDER_RAIL_TRANSITION
const DRAG_TRANSITION = REORDER_DRAG_TRANSITION_CSS
// The rail is a single horizontal strip of fixed cells. Pin drags to the x-axis
// (no cross-axis scrollbar), snap to whole cells so a square steps slot-to-slot
@@ -174,7 +179,7 @@ export function ProfileRail() {
if (id && id !== lastOverRef.current) {
lastOverRef.current = id
triggerHaptic('selection')
reorderStepHaptic()
}
}
@@ -191,7 +196,7 @@ export function ProfileRail() {
if (from >= 0 && to >= 0) {
setProfileOrder(arrayMove(ids, from, to))
triggerHaptic('success')
reorderCommitHaptic()
}
}
@@ -16,6 +16,9 @@ const activeGateway = vi.fn<() => { request: typeof request } | null>(() => ({ r
vi.mock('@/hermes', () => ({
renameSession: (...args: unknown[]) => renameSession(...(args as [])),
// profile.ts calls this at import (its $activeGatewayProfile subscribe fires
// immediately), pulled in transitively via session-states.
setApiRequestProfile: () => {},
HermesGateway: class {}
}))
@@ -1,9 +1,17 @@
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useEffect, useRef, useState } from 'react'
import { closeAllTreeTabs, closeOtherTreeTabs, closeTreeTabsToRight, treeTabCloseTargets } from '@/components/pane-shell/tree/store'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import { CopyButton } from '@/components/ui/copy-button'
import {
Dialog,
@@ -13,7 +21,13 @@ import {
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { renameSession } from '@/hermes'
import { useI18n } from '@/i18n'
@@ -22,6 +36,7 @@ import { exportSession } from '@/lib/session-export'
import { activeGateway } from '@/store/gateway'
import { notify, notifyError } from '@/store/notifications'
import { $activeSessionId, $selectedStoredSessionId, setSessions } from '@/store/session'
import { $sessionTiles, openSessionTile } from '@/store/session-states'
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
import type { SessionTitleResponse } from '../../types'
@@ -80,10 +95,31 @@ interface SessionActions {
onBranch?: () => void
onArchive?: () => void
onDelete?: () => void
/** Close this surface (a tile tab) — omitted where nothing closes (sidebar
* rows, the main tab). */
onClose?: () => void
/** TAB surfaces: the session is already a tab, so "Open in new tab" is
* nonsense there — sidebar rows/dropdowns keep it. */
surface?: 'row' | 'tab'
/** The tab's layout-tree pane id (`session-tile:<id>` or `workspace`) — enables
* the Close-others / to-the-right / all tab verbs. Tab surfaces only. */
tabPaneId?: string
/** The MAIN tab's escape hatch: hide the zone's tab bar (it sticky-shows
* once a tab is ever gained; this is the explicit off switch). */
onHideTabBar?: () => void
}
type MenuItem = typeof DropdownMenuItem | typeof ContextMenuItem
/** A menu flavour (dropdown / context) — item + separator components. */
interface MenuKit {
Item: MenuItem
Separator: typeof DropdownMenuSeparator | typeof ContextMenuSeparator
}
const DROPDOWN_KIT: MenuKit = { Item: DropdownMenuItem, Separator: DropdownMenuSeparator }
const CONTEXT_KIT: MenuKit = { Item: ContextMenuItem, Separator: ContextMenuSeparator }
interface ItemSpec {
className?: string
disabled: boolean
@@ -101,26 +137,45 @@ function useSessionActions({
onPin,
onBranch,
onArchive,
onDelete
onDelete,
onClose,
onHideTabBar,
surface = 'row',
tabPaneId
}: SessionActions) {
const { t } = useI18n()
const r = t.sidebar.row
const [renameOpen, setRenameOpen] = useState(false)
const tiles = useStore($sessionTiles)
const selectedStoredSessionId = useStore($selectedStoredSessionId)
const pinItem: ItemSpec = {
disabled: !onPin,
icon: 'pin',
label: pinned ? r.unpin : r.pin,
onSelect: () => {
triggerHaptic('selection')
onPin?.()
}
}
// Already showing as a tab somewhere (a tile, or loaded in main — main IS
// a tab): offering "Open in new tab" again is noise.
const alreadyTabbed = sessionId === selectedStoredSessionId || tiles.some(tile => tile.storedSessionId === sessionId)
const items: ItemSpec[] = [
const spec = (partial: Omit<ItemSpec, 'onSelect'> & { onSelect: () => void }): ItemSpec => partial
// OPEN — where else this session can go. A tab surface IS a tab already,
// so it only offers the window hop (and its own Close, below).
const openItems: ItemSpec[] = [
...(surface === 'row' && !alreadyTabbed
? [
spec({
disabled: !sessionId,
icon: 'browser',
label: r.openInNewTab,
onSelect: () => {
triggerHaptic('selection')
// Stack into the MAIN zone as a tab (center dock; the strip
// sticky-shows on gain) — the door to the tab bar.
openSessionTile(sessionId, 'center')
}
})
]
: []),
...(canOpenSessionWindow()
? [
{
spec({
disabled: !sessionId,
icon: 'link-external',
label: r.newWindow,
@@ -128,28 +183,14 @@ function useSessionActions({
triggerHaptic('selection')
void openSessionInNewWindow(sessionId)
}
}
})
]
: []),
{
disabled: !sessionId,
icon: 'cloud-download',
label: r.export,
onSelect: () => {
triggerHaptic('selection')
void exportSession(sessionId, { profile, title })
}
},
{
disabled: !onBranch,
icon: 'git-branch',
label: r.branchFrom,
onSelect: () => {
triggerHaptic('selection')
onBranch?.()
}
},
{
: [])
]
// IDENTITY — name/mark/reference the session.
const identityItems: ItemSpec[] = [
spec({
disabled: !sessionId,
icon: 'edit',
label: r.rename,
@@ -157,8 +198,96 @@ function useSessionActions({
triggerHaptic('selection')
setRenameOpen(true)
}
},
{
}),
spec({
disabled: !onPin,
icon: 'pin',
label: pinned ? r.unpin : r.pin,
onSelect: () => {
triggerHaptic('selection')
onPin?.()
}
})
]
// WORK — derive/extract from the session.
const workItems: ItemSpec[] = [
spec({
disabled: !onBranch,
icon: 'git-branch',
label: r.branchFrom,
onSelect: () => {
triggerHaptic('selection')
onBranch?.()
}
}),
spec({
disabled: !sessionId,
icon: 'cloud-download',
label: r.export,
onSelect: () => {
triggerHaptic('selection')
void exportSession(sessionId, { profile, title })
}
})
]
// TAB — close verbs that act on the strip (tabs only; a row isn't a tab).
const closeTargets = surface === 'tab' && tabPaneId ? treeTabCloseTargets(tabPaneId) : null
const tabCloseItems: ItemSpec[] =
surface === 'tab'
? [
...(onClose
? [
spec({
disabled: false,
icon: 'close',
label: t.common.close,
onSelect: () => {
triggerHaptic('selection')
onClose()
}
})
]
: []),
...(tabPaneId
? [
spec({
disabled: !closeTargets?.others,
icon: 'close-all',
label: t.zones.closeOthers,
onSelect: () => {
triggerHaptic('selection')
closeOtherTreeTabs(tabPaneId)
}
}),
spec({
disabled: !closeTargets?.right,
icon: 'arrow-right',
label: t.zones.closeToRight,
onSelect: () => {
triggerHaptic('selection')
closeTreeTabsToRight(tabPaneId)
}
}),
spec({
disabled: !closeTargets?.all,
icon: 'clear-all',
label: t.zones.closeAll,
onSelect: () => {
triggerHaptic('selection')
closeAllTreeTabs(tabPaneId)
}
})
]
: [])
]
: []
// DANGER — put it away / destroy it (delete stays last, destructive-red).
const dangerItems: ItemSpec[] = [
spec({
disabled: !onArchive,
icon: 'archive',
label: r.archive,
@@ -166,7 +295,7 @@ function useSessionActions({
triggerHaptic('selection')
onArchive?.()
}
},
}),
{
className: 'text-destructive focus:text-destructive',
disabled: !onDelete,
@@ -187,11 +316,13 @@ function useSessionActions({
</Item>
)
const renderItems = (Item: MenuItem) => (
const renderItems = (kit: MenuKit) => (
<>
{renderMenuItem(Item, pinItem)}
{openItems.map(item => renderMenuItem(kit.Item, item))}
{openItems.length > 0 && <kit.Separator />}
{identityItems.map(item => renderMenuItem(kit.Item, item))}
<CopyButton
appearance={Item === DropdownMenuItem ? 'menu-item' : 'context-menu-item'}
appearance={kit.Item === DropdownMenuItem ? 'menu-item' : 'context-menu-item'}
disabled={!sessionId}
errorMessage={r.copyIdFailed}
iconClassName="size-3.5 text-current"
@@ -200,7 +331,30 @@ function useSessionActions({
onCopyError={err => notifyError(err, r.copyIdFailed)}
text={sessionId}
/>
{items.map(spec => renderMenuItem(Item, spec))}
<kit.Separator />
{workItems.map(item => renderMenuItem(kit.Item, item))}
{tabCloseItems.length > 0 && (
<>
<kit.Separator />
{tabCloseItems.map(item => renderMenuItem(kit.Item, item))}
</>
)}
<kit.Separator />
{dangerItems.map(item => renderMenuItem(kit.Item, item))}
{onHideTabBar && (
<>
<kit.Separator />
{renderMenuItem(kit.Item, {
disabled: false,
icon: 'eye-closed',
label: r.hideTabBar,
onSelect: () => {
triggerHaptic('selection')
onHideTabBar()
}
})}
</>
)}
</>
)
@@ -225,10 +379,11 @@ interface SessionActionsMenuProps
export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, ...actions }: SessionActionsMenuProps) {
const { t } = useI18n()
const { renameDialog, renderItems } = useSessionActions(actions)
const [open, setOpen] = useState(false)
return (
<>
<DropdownMenu>
<DropdownMenu onOpenChange={setOpen} open={open}>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
@@ -236,7 +391,7 @@ export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, ..
className="w-40"
sideOffset={sideOffset}
>
{renderItems(DropdownMenuItem)}
{renderItems(DROPDOWN_KIT)}
</DropdownMenuContent>
</DropdownMenu>
{renameDialog}
@@ -257,7 +412,7 @@ export function SessionContextMenu({ children, ...actions }: SessionContextMenuP
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent aria-label={t.sidebar.row.actionsFor(actions.title)} className="w-40">
{renderItems(ContextMenuItem)}
{renderItems(CONTEXT_KIT)}
</ContextMenuContent>
</ContextMenu>
{renameDialog}
+139 -72
View File
@@ -1,7 +1,7 @@
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { writeSessionDrag } from '@/app/chat/composer/inline-refs'
import { startSessionDrag } from '@/app/chat/session-drag'
import { PlatformAvatar } from '@/app/messaging/platform-icon'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
@@ -13,7 +13,9 @@ import { triggerHaptic } from '@/lib/haptics'
import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
import { coarseElapsed } from '@/lib/time'
import { cn } from '@/lib/utils'
import { $attentionSessionIds } from '@/store/session'
import { $backgroundRunningSessionIds } from '@/store/composer-status'
import { $attentionSessionIds, $unreadFinishedSessionIds } from '@/store/session'
import { openSessionTile } from '@/store/session-states'
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
import { SidebarRowBody, SidebarRowGrab, SidebarRowLabel, SidebarRowLead, SidebarRowShell } from './chrome'
@@ -74,10 +76,26 @@ export function SidebarSessionRow({
// Telegram thread continued here still reads as Telegram.
const handoffSource = handoffOriginSource(session.handoff_state, session.handoff_platform)
const handoffLabel = handoffSource ? (sessionSourceLabel(handoffSource) ?? handoffSource) : null
// Subscribe per-row (the leaf) instead of drilling a set through the list —
// the atom is tiny and rarely non-empty. True when a clarify prompt in this
// session is waiting on the user.
// True when a clarify prompt in this session is waiting on the user.
const needsInput = useStore($attentionSessionIds).includes(session.id)
// True when the session's most recent turn finished in the background (while
// the user was viewing a different session) and hasn't been opened since.
const isUnread = useStore($unreadFinishedSessionIds).includes(session.id)
// True when a terminal(background=true) process is alive in this session.
const hasBackground = useStore($backgroundRunningSessionIds).includes(session.id)
// Resolve the dot's display state once — the four signals are mutually
// exclusive by priority, so threading them as booleans through wrappers just
// to collapse them at the leaf is backwards.
const dotState: SessionDotState = needsInput
? 'needs-input'
: isWorking
? 'working'
: hasBackground
? 'background'
: isUnread
? 'unread'
: 'idle'
return (
<SessionContextMenu
@@ -92,7 +110,7 @@ export function SidebarSessionRow({
>
<SidebarRowShell
actions={
<div className="relative z-2 grid w-[1.375rem] place-items-center">
<div className="relative z-2 grid w-[1.375rem] place-items-center" data-row-actions>
{!isWorking && (
<span className="pointer-events-none absolute right-6 top-1/2 min-w-6 -translate-y-1/2 text-right text-[0.625rem] leading-none text-(--ui-text-tertiary) opacity-0 transition-opacity group-hover:opacity-100">
{age}
@@ -130,21 +148,19 @@ export function SidebarSessionRow({
className
)}
data-working={isWorking ? 'true' : undefined}
draggable
onDragStart={event => {
// Reorder drags belong to dnd-kit (the grab handle) — cancel the
// native drag so the two DnD systems don't fight.
if ((event.target as HTMLElement).closest('[data-reorder-handle]')) {
event.preventDefault()
onPointerDown={event => {
// Reorder drags belong to dnd-kit (the grab handle); the ⋯ actions
// cluster keeps its own gestures. Everything else on the row —
// including the row-body BUTTON, the natural grab surface — is a
// session drag source: a POINTER drag on the shared drag session
// (never native HTML5 DnD: no macOS snap-back, Esc aborts
// instantly). Sub-threshold releases stay ordinary clicks, so
// resume / pin / open-in-window are untouched.
if ((event.target as HTMLElement).closest('[data-reorder-handle], [data-row-actions]')) {
return
}
writeSessionDrag(event.dataTransfer, {
id: session.id,
profile: session.profile || 'default',
title
})
startSessionDrag({ id: session.id, profile: session.profile || 'default', title }, event)
}}
ref={ref}
style={style}
@@ -153,7 +169,40 @@ export function SidebarSessionRow({
{isWorking && !needsInput && <span aria-hidden="true" className="arc-border" />}
<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')
openSessionTile(session.id, 'center')
}
}}
onClick={event => {
const mod = event.metaKey || event.ctrlKey
// ⇧⌘-click → pop into its own window (needs standalone windows).
if (mod && event.shiftKey && canOpenSessionWindow()) {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
void openSessionInNewWindow(session.id)
return
}
// ⌘/⌃-click → open in a new tab (stack into main).
if (mod) {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
openSessionTile(session.id, 'center')
return
}
// ⇧-click → pin.
if (event.shiftKey) {
event.preventDefault()
event.stopPropagation()
@@ -163,21 +212,9 @@ export function SidebarSessionRow({
return
}
// ⌘-click (mac) / ⌃-click (win/linux) pops the chat into its own
// window — the universal "open in a new window" gesture. Archive
// lives in the row's ⋯ and right-click menus. Falls through to a
// normal resume when standalone windows aren't available (web embed).
if ((event.metaKey || event.ctrlKey) && canOpenSessionWindow()) {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
void openSessionInNewWindow(session.id)
return
}
onResume()
}}
onMouseDown={event => event.button === 1 && event.preventDefault()}
>
{reorderable ? (
<SidebarRowGrab
@@ -189,13 +226,12 @@ export function SidebarSessionRow({
<SessionRowLeadDot
branchStem={branchStem}
className="transition-opacity group-hover/handle:opacity-0 group-focus-within/handle:opacity-0"
isWorking={isWorking}
needsInput={needsInput}
dotState={dotState}
/>
</SidebarRowGrab>
) : (
<SidebarRowLead className={needsInput ? 'overflow-visible' : 'overflow-hidden'}>
<SessionRowLeadDot branchStem={branchStem} isWorking={isWorking} needsInput={needsInput} />
<SessionRowLeadDot branchStem={branchStem} dotState={dotState} />
</SidebarRowLead>
)}
{handoffSource && handoffLabel ? (
@@ -216,15 +252,18 @@ export function SidebarSessionRow({
)
}
/** The session's display state for the sidebar lead dot. The call site
* resolves this from the four underlying signals (needs-input, working,
* background, unread) so the dot component itself is a pure lookup. */
type SessionDotState = 'background' | 'idle' | 'needs-input' | 'unread' | 'working'
function SessionRowLeadDot({
branchStem,
isWorking,
needsInput = false,
dotState = 'idle',
className
}: {
branchStem?: string
isWorking: boolean
needsInput?: boolean
dotState?: SessionDotState
className?: string
}) {
return (
@@ -234,49 +273,77 @@ function SessionRowLeadDot({
{branchStem}
</span>
) : null}
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
<SidebarRowDot dotState={dotState} />
</span>
)
}
function SidebarRowDot({
isWorking,
needsInput = false,
className
}: {
isWorking: boolean
needsInput?: boolean
className?: string
}) {
// A pure lookup table: each state maps to its className, aria-label, and
// title. No priority resolution here — the call site already picked one.
// Label/title are resolved from sidebar.row translations, keyed by name.
type DotVariant = {
ariaLabel?: (r: Translations['sidebar']['row']) => string
className: string
role?: 'status'
title?: (r: Translations['sidebar']['row']) => string
}
// Shared base for every active dot; idle is smaller and uses its own class.
const DOT_BASE = 'relative size-1.5 rounded-full'
// Pseudo-element ping ring that scales outward and fades — shared scaffold for
// the two pulsing dots. The `before:bg-*` color is written inline per variant
// (NOT interpolated here): Tailwind only generates utilities it can see as
// complete static strings, so a `before:bg-${color}` template never emits.
const PING = "before:absolute before:inset-0 before:animate-ping before:rounded-full before:content-['']"
const DOT_VARIANTS: Record<SessionDotState, DotVariant> = {
// Amber steady — a clarify/approval is blocking the turn. Steady (not
// pulsing) reads as "your turn", distinct from the accent pulse of a turn.
'needs-input': {
ariaLabel: r => r.needsInput,
className: `${DOT_BASE} quest-glow bg-amber-500`,
role: 'status',
title: r => r.waitingForAnswer
},
// Accent pulse — the LLM turn is actively running.
working: {
ariaLabel: r => r.sessionRunning,
className: `${DOT_BASE} bg-(--ui-accent) shadow-[0_0_0.625rem_color-mix(in_srgb,var(--ui-accent)_55%,transparent)] ${PING} before:bg-(--ui-accent) before:opacity-70`,
role: 'status'
},
// Pulsing gray — a terminal(background=true) process is alive while the LLM
// is idle. Gray (not accent) reads as "something chugging along".
background: {
ariaLabel: r => r.backgroundRunning,
className: `${DOT_BASE} bg-muted-foreground/50 ${PING} before:bg-muted-foreground/50 before:opacity-50`,
role: 'status',
title: r => r.backgroundRunning
},
// Steady green — a background session's turn completed and the user hasn't
// opened it since. "Something new here, go look."
unread: {
ariaLabel: r => r.finishedUnread,
className: `${DOT_BASE} bg-emerald-500`,
role: 'status',
title: r => r.finishedUnread
},
idle: {
className: 'size-1 rounded-full bg-(--ui-text-quaternary) opacity-80'
}
}
function SidebarRowDot({ dotState, className }: { dotState: SessionDotState; className?: string }) {
const { t } = useI18n()
const r = t.sidebar.row
// "Needs input" wins over "working": a clarify-blocked session is technically
// still running, but the actionable state is that it's waiting on the user.
// Amber + steady (no ping) reads as "your turn", distinct from the accent
// pulse of an active turn.
if (needsInput) {
return (
<span
aria-label={r.needsInput}
className={cn('quest-glow relative size-1.5 rounded-full bg-amber-500', className)}
role="status"
title={r.waitingForAnswer}
/>
)
}
const variant = DOT_VARIANTS[dotState]
return (
<span
aria-label={isWorking ? r.sessionRunning : undefined}
className={cn(
'rounded-full',
isWorking
? "relative size-1.5 bg-(--ui-accent) shadow-[0_0_0.625rem_color-mix(in_srgb,var(--ui-accent)_55%,transparent)] before:absolute before:inset-0 before:animate-ping before:rounded-full before:bg-(--ui-accent) before:opacity-70 before:content-['']"
: 'size-1 bg-(--ui-text-quaternary) opacity-80',
className
)}
role={isWorking ? 'status' : undefined}
aria-label={variant.ariaLabel?.(r)}
className={cn(variant.className, className)}
role={variant.role}
title={variant.title?.(r)}
/>
)
}
@@ -0,0 +1,92 @@
import { Codicon } from '@/components/ui/codicon'
import {
ContextMenuItem,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger
} from '@/components/ui/context-menu'
import {
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger
} from '@/components/ui/dropdown-menu'
import { triggerHaptic } from '@/lib/haptics'
import type { SplitDir } from '@/store/session-states'
/** The leaf + submenu components for one menu flavour, so the split submenu
* renders in either the `…` dropdown or a right-click context menu. */
export interface SplitMenuKit {
Item: typeof DropdownMenuItem | typeof ContextMenuItem
Sub: typeof DropdownMenuSub | typeof ContextMenuSub
SubContent: typeof DropdownMenuSubContent | typeof ContextMenuSubContent
SubTrigger: typeof DropdownMenuSubTrigger | typeof ContextMenuSubTrigger
}
export const DROPDOWN_SPLIT_KIT: SplitMenuKit = {
Item: DropdownMenuItem,
Sub: DropdownMenuSub,
SubContent: DropdownMenuSubContent,
SubTrigger: DropdownMenuSubTrigger
}
export const CONTEXT_SPLIT_KIT: SplitMenuKit = {
Item: ContextMenuItem,
Sub: ContextMenuSub,
SubContent: ContextMenuSubContent,
SubTrigger: ContextMenuSubTrigger
}
// Ordered so the default (right) sits first, one hop away.
const SPLIT_DIRS: { dir: SplitDir; icon: string; label: string }[] = [
{ dir: 'right', icon: 'arrow-right', label: 'Right' },
{ dir: 'bottom', icon: 'arrow-down', label: 'Down' },
{ dir: 'left', icon: 'arrow-left', label: 'Left' },
{ dir: 'top', icon: 'arrow-up', label: 'Up' }
]
interface SplitSubmenuProps {
kit: SplitMenuKit
label: string
onSplit: (dir: SplitDir) => void
disabled?: boolean
/** Dismiss the owning menu after the row's default (right) split — the
* dropdown is controlled and can; a context menu can't, so it's a no-op. */
close?: () => void
}
/**
* "Open in split ▸": clicking the row splits right (the common case), and the
* submenu picks any edge. Shared by session rows and page nav rows.
*/
export function SplitSubmenu({ close, disabled, kit, label, onSplit }: SplitSubmenuProps) {
const { Item, Sub, SubContent, SubTrigger } = kit
const split = (dir: SplitDir) => {
triggerHaptic('selection')
onSplit(dir)
}
return (
<Sub>
<SubTrigger
disabled={disabled}
onClick={() => {
split('right')
close?.()
}}
>
<Codicon name="split-horizontal" size="0.875rem" />
<span>{label}</span>
</SubTrigger>
<SubContent>
{SPLIT_DIRS.map(({ dir, icon, label: dirLabel }) => (
<Item key={dir} onSelect={() => split(dir)}>
<Codicon name={icon} size="0.875rem" />
<span>{dirLabel}</span>
</Item>
))}
</SubContent>
</Sub>
)
}
@@ -0,0 +1,28 @@
/**
* Command-palette contribution surface — `palette` data contributions become
* rows in the ⌘K root list, same schema as every other area. Contributions
* with an `action` id render that action's live keybind as their hotkey hint.
*/
import { useContributions } from '@/contrib/react/use-contributions'
import type { IconComponent } from '@/lib/icons'
export const PALETTE_AREA = 'palette'
/** Payload of a `palette` data contribution. */
export interface PaletteContribution {
id: string
label: string
/** Keybind action id — its live combo renders as the hotkey hint. */
action?: string
icon?: IconComponent
keywords?: string[]
run: () => void
}
/** Contributed palette rows, with stable render keys. */
export function usePaletteContributions(): Array<PaletteContribution & { key: string }> {
return useContributions(PALETTE_AREA)
.map(c => ({ key: `${c.source ?? 'core'}:${c.id}`, ...(c.data as PaletteContribution) }))
.filter(item => Boolean(item.label && item.run))
}
+26 -4
View File
@@ -80,6 +80,7 @@ import { FIELD_LABELS, SECTIONS } from '../settings/constants'
import { fieldCopyForSchemaKey } from '../settings/field-copy'
import { prettyName } from '../settings/helpers'
import { usePaletteContributions } from './contrib'
import { MarketplaceThemePage } from './marketplace-theme-page'
import { PetInlineToggle, PetPalettePage } from './pet-palette-page'
@@ -117,6 +118,7 @@ interface PalettePage {
}
interface SessionEntry {
git_branch?: null | string
id: string
preview?: string
title: string
@@ -211,6 +213,7 @@ const SESSION_ID_RE = /^\d{8}_\d{6}_[a-f0-9]{6}$/
type SessionRow = Awaited<ReturnType<typeof listAllProfileSessions>>['sessions'][number]
const toSessionEntry = (session: SessionRow): SessionEntry => ({
git_branch: session.git_branch ?? null,
id: session.id,
preview: session.preview ?? undefined,
title: sessionTitle(session)
@@ -368,6 +371,8 @@ export function CommandPalette() {
[t.settings.fieldLabels]
)
const contributedItems = usePaletteContributions()
const baseGroups = useMemo<PaletteGroup[]>(() => {
const settingsTab = (tab: string) => `${SETTINGS_ROUTE}?tab=${tab}`
const cc = t.commandCenter
@@ -559,9 +564,26 @@ 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
}))
}
]
: [])
]
}, [go, settingsSectionLabel, t, worktrees])
}, [contributedItems, go, settingsSectionLabel, t, worktrees])
// The long, granular lists (settings fields, API keys, MCP servers, archived
// chats) only surface once the user types — otherwise they'd bury the
@@ -664,7 +686,7 @@ export function CommandPalette() {
items: sessions.map(session => ({
icon: MessageCircle,
id: `session-${session.id}`,
keywords: ['chat', 'session', ...(session.preview ? [session.preview] : [])],
keywords: ['chat', 'session', ...(session.preview ? [session.preview] : []), ...(session.git_branch ? [session.git_branch] : [])],
label: session.title,
run: go(sessionRoute(session.id))
}))
@@ -702,7 +724,7 @@ export function CommandPalette() {
items: archivedSessions.map(session => ({
icon: Archive,
id: `archived-${session.id}`,
keywords: ['archived', 'chat', 'session', ...(session.preview ? [session.preview] : [])],
keywords: ['archived', 'chat', 'session', ...(session.preview ? [session.preview] : []), ...(session.git_branch ? [session.git_branch] : [])],
label: session.title,
run: go(`${SETTINGS_ROUTE}?tab=sessions&session=${encodeURIComponent(session.id)}`)
}))
+36
View File
@@ -0,0 +1,36 @@
import { createContext, memo, useContext } from 'react'
import { DecodeText } from '@/components/ui/decode-text'
import { StatusbarControls } from '../shell/statusbar-controls'
import type { WiringApi } from './types'
/** The controller publishes its wired surfaces here; every registered pane
* / chrome slot reads one back through `WiredPane`. */
export const ContribWiringContext = createContext<WiringApi | null>(null)
/** Render a wired surface inside a registered pane / chrome slot.
*
* Memoized on `part` (its only prop): a zone re-rendering for reasons that
* don't touch the wiring — a drag hint sweeping the tree, a sash resize, an
* edit-mode toggle — re-renders the group chrome but NOT this component, so
* the (expensive) pane body it reads from context is untouched. When the
* wiring's `api` genuinely changes, the context read re-renders it as normal. */
export const WiredPane = memo(function WiredPane({ part }: { part: keyof WiringApi }) {
const api = useContext(ContribWiringContext)
if (!api) {
if (part === 'statusbar') {
return <StatusbarControls items={[]} leftItems={[]} />
}
return (
<div className="grid h-full place-items-center">
<DecodeText className="text-(--ui-text-quaternary)" cursor prefix={1} text="HERMES" />
</div>
)
}
return <>{api[part]}</>
})
+673
View File
@@ -0,0 +1,673 @@
import { useStore } from '@nanostores/react'
import { computed } from 'nanostores'
import type { CSSProperties, ReactElement, PointerEvent as ReactPointerEvent } from 'react'
import { PREVIEW_RAIL_MAX_WIDTH, PREVIEW_RAIL_MIN_WIDTH } from '@/app/chat/right-rail'
import { PALETTE_AREA, type PaletteContribution } from '@/app/command-palette/contrib'
import { type StatusbarItem } from '@/app/shell/statusbar-controls'
import { toggleLayoutEditMode } from '@/components/pane-shell/edit-mode'
import { allPaneIds, group, split } from '@/components/pane-shell/tree/model'
import { LayoutTreeRoot } from '@/components/pane-shell/tree/renderer'
import type { DoubleTapContext } from '@/components/pane-shell/tree/renderer/drag-session'
import {
$layoutTree,
bindTreeSideVisibility,
declareDefaultTree,
dismissTreePane,
dockPaneBeside,
markCollapsePane,
mirrorLayoutTree,
paneRootSide,
registerLayoutResetHandler,
registerPaneCloser,
registerPaneOpener,
resetLayoutTree,
revealTreePane,
setPaneCollapsed,
setTreePaneHidden,
watchContributedPanes
} from '@/components/pane-shell/tree/store'
import { SidebarProvider } from '@/components/ui/sidebar'
import { discoverBundledPlugins } from '@/contrib/plugins'
import { Slot } from '@/contrib/react/slot'
import { registry } from '@/contrib/registry'
import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
import { sessionTitle as storedSessionTitle } from '@/lib/chat-runtime'
import { LayoutDashboard } from '@/lib/icons'
import { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions'
import { Codecs, persistentAtom } from '@/lib/persisted'
import { toggleKeybindPanel } from '@/store/keybinds'
import {
$fileBrowserOpen,
$panesFlipped,
$sidebarOpen,
FILE_BROWSER_DEFAULT_WIDTH,
FILE_BROWSER_MAX_WIDTH,
FILE_BROWSER_MIN_WIDTH,
setFileBrowserOpen,
setSidebarOpen,
SIDEBAR_DEFAULT_WIDTH,
SIDEBAR_MAX_WIDTH
} from '@/store/layout'
import { $filePreviewTarget, $previewTarget, closeRightRail } from '@/store/preview'
import { $reviewOpen, closeReview, REVIEW_PANE_ID } from '@/store/review'
import { $currentCwd, $selectedStoredSessionId, $sessions, sessionMatchesStoredId } from '@/store/session'
import type { SessionDragPayload } from '../chat/composer/inline-refs'
import { watchRouteTiles } from '../chat/route-tile'
import { startSessionDrag } from '../chat/session-drag'
import {
SessionTileCloseConfirm,
stackSessionTilesIntoMain,
watchSessionTiles,
WorkspaceTabMenu
} from '../chat/session-tile'
import { $terminalTakeover, setTerminalTakeover } from '../right-sidebar/store'
import { $workspaceIsPage } from '../routes'
import { FilesPane, LogsPane, PreviewRailPane, ReviewPaneContent } from './panes'
import { ContribWiring, WiredPane } from './wiring'
/**
* Stripped-down app root (bb/contrib-areas) on the layout TREE model, mounting
* the REAL app surfaces. The title bar and status bar sit OUTSIDE the grid
* (fixed chrome) but are fully composable: title bar renders `titleBar.left/
* right` slots; the status bar consumes `statusBar.left/right` DATA
* contributions (payload = StatusbarItem). Core registers its items through
* the same calls a plugin would use.
*/
// ---------------------------------------------------------------------------
// Pane contributions. `data.placement` = semantic role for grid presets;
// `data.minWidth/maxWidth/minHeight/maxHeight` = the SAME clamps the app's
// `Pane` props declare — the layout tree sizes zones by weight (percentage)
// but a zone never shrinks/grows past its active pane's clamp.
// Headers are contextual (tree-side): a pane alone in a zone shows no
// header/tab by default; stacked panes show chips. Double-click a zone
// toggles its header either way.
// ---------------------------------------------------------------------------
// ONE render identity for the workspace pane — syncWorkspaceTitle re-registers
// the contribution (new title) and a fresh closure would remount the chat.
const renderWorkspacePane = () => <WiredPane part="chatRoutes" />
// The main tab carries the same session context menu as tile tabs (targets
// the loaded primary session; no menu on a fresh draft).
const wrapWorkspaceTab = (tab: ReactElement) => <WorkspaceTabMenu>{tab}</WorkspaceTabMenu>
/** The `@session` payload for the workspace tab — the loaded primary session,
* or null on a fresh draft / full-page view (nothing to link). */
const workspaceDragPayload = (): SessionDragPayload | null => {
const selected = $selectedStoredSessionId.get()
if (!selected || $workspaceIsPage.get()) {
return null
}
const stored = $sessions.get().find(s => sessionMatchesStoredId(s, selected))
return { id: selected, profile: stored?.profile ?? '', title: stored ? storedSessionTitle(stored) : '' }
}
// The main tab drags like a session tile — drop it on a composer to link the
// chat, on a zone/edge to stack/split. Defers (`false`) to the generic pane
// move when there's no loaded session to carry.
const workspaceTabDrag = (event: ReactPointerEvent<HTMLElement>, onTap: () => void, double?: DoubleTapContext) => {
const payload = workspaceDragPayload()
if (!payload) {
return false
}
startSessionDrag(payload, event, { double, onTap })
return true
}
registry.registerMany([
{
id: 'sessions',
area: 'panes',
title: 'sessions',
// Collapsible: leaves the grid on narrow viewports (edge overlay instead).
// dock: where a RE-ADOPTED pane lands (healed from a stale dismissal) —
// its default-ish spot beside main, not a random same-placement stack.
data: {
placement: 'left',
collapsible: true,
dock: { pane: 'workspace', pos: 'left' },
revealAliases: ['chat-sidebar'],
width: `${SIDEBAR_DEFAULT_WIDTH}px`,
minWidth: `${SIDEBAR_DEFAULT_WIDTH}px`,
maxWidth: `${SIDEBAR_MAX_WIDTH}px`
},
render: () => <WiredPane part="sidebar" />
},
{
id: 'workspace',
area: 'panes',
// Live-retitled to the loaded session by syncWorkspaceTitle below.
title: 'New session',
data: {
placement: 'main',
minWidth: '22vw',
tabDrag: workspaceTabDrag,
tabWrap: wrapWorkspaceTab,
uncloseable: true
},
render: renderWorkspacePane
},
{
id: 'terminal',
area: 'panes',
title: 'terminal',
// revealOnPreset: choosing a layout that places the terminal (e.g.
// "Terminal deck") turns takeover on so the zone actually shows, instead of
// staying collapsed behind the ⌃` toggle. height sizes the fixed track (a
// single-pane zone declaring a height is a fixed track — the preset weight
// is moot): a short deck, not a third of the window.
data: { placement: 'bottom', height: '20vh', minHeight: '7.5rem', maxHeight: '80vh', revealOnPreset: true },
render: () => <WiredPane part="terminal" />
},
{
id: 'files',
area: 'panes',
title: 'files',
// dock: re-adoption target after a stale dismissal (see sessions).
data: {
placement: 'right',
collapsible: true,
dock: { pane: 'workspace', pos: 'right' },
revealAliases: ['file-browser'],
width: FILE_BROWSER_DEFAULT_WIDTH,
minWidth: FILE_BROWSER_MIN_WIDTH,
maxWidth: FILE_BROWSER_MAX_WIDTH
},
render: () => <FilesPane />
},
{
id: 'preview',
area: 'panes',
title: 'preview',
// The rail brings its OWN tab strip (per-target tabs with close buttons).
// Exists only while something is previewed — visibility is bound to the
// preview targets below, like every other self-managed surface. dock:
// adoption seed only — dockPaneBeside re-docks it next to files on every
// reveal anyway (position-aware).
data: {
placement: 'right',
dock: { pane: 'files', pos: 'left' },
width: 'clamp(18rem, 36vw, 32rem)',
minWidth: PREVIEW_RAIL_MIN_WIDTH,
maxWidth: PREVIEW_RAIL_MAX_WIDTH
},
render: () => <PreviewRailPane />
},
{
id: 'review',
area: 'panes',
title: 'review',
// The second right sidebar: hidden until ⌘G ($reviewOpen) — bound below
// like the other chrome toggles; its zone collapses while hidden.
data: {
placement: 'right',
collapsible: true,
revealAliases: [REVIEW_PANE_ID],
width: FILE_BROWSER_DEFAULT_WIDTH,
minWidth: FILE_BROWSER_MIN_WIDTH,
maxWidth: FILE_BROWSER_MAX_WIDTH
},
render: () => <ReviewPaneContent />
},
{
// Optional chrome — in NO default layout. Adoption stacks it with the
// terminal; $logsOpen (default off, ⌘K "Toggle logs") reveals it.
id: 'logs',
area: 'panes',
title: 'logs',
// revealOnPreset: the Quad layout places logs, so applying it turns the
// logs pane on (like a ⌘K "Toggle logs") instead of leaving it collapsed.
data: { placement: 'bottom', height: '20vh', minHeight: '7.5rem', maxHeight: '80vh', revealOnPreset: true },
render: () => <LogsPane />
}
])
// ---------------------------------------------------------------------------
// Chrome contributions. The title bar and status bar are fixed chrome outside
// the grid, composable through these areas. Everything real lives in the real
// components (TitlebarControls / useStatusbarItems). Sample PLUGIN
// contributions don't live here — they're their own files under `src/plugins/`,
// auto-discovered by discoverBundledPlugins() below.
// ---------------------------------------------------------------------------
registry.registerMany([
// Titlebar center stays empty on purpose: session title lives in tabs +
// sidebar; place/cwd lives in the sidebar project tree. Center is drag
// chrome (plugins can still contribute to titleBar.center if needed).
// Layout edit mode registers through the SAME declarative surfaces plugins
// use: a rebindable keybind (collision-checked in the panel) + a ⌘K row
// whose hotkey hint tracks the live binding.
{
id: 'layout.editMode',
area: KEYBINDS_AREA,
data: {
id: 'layout.editMode',
label: 'Toggle layout edit mode',
defaults: ['mod+shift+\\'],
run: toggleLayoutEditMode
} satisfies KeybindContribution
},
{
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
},
// The agent's write -> see loop: rescan <hermes home>/desktop-plugins
// without relaunching (same-id reloads dispose the previous incarnation).
{
id: 'plugins.reload',
area: PALETTE_AREA,
data: {
id: 'plugins.reload',
label: 'Reload desktop plugins',
keywords: ['plugins', 'reload', 'refresh', 'desktop'],
run: () => void discoverRuntimePlugins()
} satisfies PaletteContribution
},
{
id: 'layout.reset',
area: PALETTE_AREA,
data: {
id: 'layout.reset',
label: 'Reset layout',
icon: LayoutDashboard,
keywords: ['layout', 'reset', 'default', 'panes'],
run: resetLayoutTree
} satisfies PaletteContribution
},
// The keybind panel's non-titlebar door (the keyboard icon is gone).
{
id: 'keybinds.panel',
area: PALETTE_AREA,
data: {
id: 'keybinds.panel',
label: 'Keyboard shortcuts',
keywords: ['keybinds', 'shortcuts', 'hotkeys', 'keyboard'],
run: toggleKeybindPanel
} satisfies PaletteContribution
}
])
// ---------------------------------------------------------------------------
// Layout presets — CHAT (main) always dominates.
// ---------------------------------------------------------------------------
// The REAL default: sessions left, chat main, and the right sidebars in
// column order main | … | review | preview | file-browser (files outermost,
// preview DIRECTLY left of the file tree). Each is its OWN zone — main
// parity: a file double-click slides the preview open as its own pane beside
// the tree, never as a tab stacked into the files sidebar. Preview/review
// zones collapse to nothing while their pane is hidden (no target / ⌘G off).
// This static spot is just the seed — dockPaneBeside keeps preview adjacent
// to files WHEREVER files moves (see the target listeners below).
const DEFAULT_TREE = split(
'row',
[
group(['sessions'], { id: 'grp-sessions' }),
group(['workspace'], { id: 'grp-main' }),
split(
'column',
[
split(
'row',
[
group(['review'], { id: 'grp-review' }),
group(['preview'], { id: 'grp-preview' }),
group(['files'], { id: 'grp-files' })
],
[1, 1, 1.2],
'spl-rail'
),
group(['terminal'], { id: 'grp-terminal' })
],
[1.6, 1],
'spl-right'
)
],
[1, 3.4, 1.25],
'spl-root'
)
const FOCUS_TREE = split(
'row',
[group(['sessions']), group(['workspace', 'files', 'preview', 'review', 'terminal'])],
[1, 4.6]
)
const TERMINAL_TREE = split(
'column',
[
split('row', [group(['sessions']), group(['workspace']), group(['files', 'preview', 'review'])], [1, 3.2, 1.2]),
group(['terminal'])
],
[3, 1]
)
const QUAD_TREE = split(
'column',
[
split('row', [group(['sessions', 'files']), group(['workspace'])], [1, 3]),
split('row', [group(['terminal']), group(['preview', 'review', 'logs'])], [1.4, 1])
],
[3, 1]
)
registry.registerMany([
{ id: 'default', area: 'layouts', title: 'Default', order: 0, data: DEFAULT_TREE },
{ id: 'focus', area: 'layouts', title: 'Focus', order: 10, data: FOCUS_TREE },
{ id: 'terminal-deck', area: 'layouts', title: 'Terminal deck', order: 20, data: TERMINAL_TREE },
{ id: 'quad', area: 'layouts', title: 'Quad', order: 30, data: QUAD_TREE }
])
declareDefaultTree(DEFAULT_TREE)
// Bundled plugins load AFTER core, so a same-id contribution from a plugin
// deliberately overrides the core default (last writer wins). Third-party
// runtime plugins will flow through the same discovery seam.
discoverBundledPlugins()
// Plugin panes join the tree by their `placement` hint the moment they
// register — incl. runtime plugins arriving seconds after boot.
watchContributedPanes()
// Session + route (page) tiles: persisted splits register panes docked beside
// main.
watchSessionTiles()
watchRouteTiles()
// The main tab reads as its SESSION (the loaded title, "New session" on a
// fresh draft) — a stack of main + tiles is then just a row of session names.
// register() replaces same-id in place; the render fn is the shared constant
// above, so the pane content never remounts.
const syncWorkspaceTitle = () => {
const selected = $selectedStoredSessionId.get()
const stored = selected ? $sessions.get().find(s => sessionMatchesStoredId(s, selected)) : null
registry.register({
id: 'workspace',
area: 'panes',
title: stored ? storedSessionTitle(stored) : 'New session',
data: {
// Pages aren't tab-able: the main zone's bar stands down while one shows.
headerVeto: $workspaceIsPage.get(),
placement: 'main',
minWidth: '22vw',
tabDrag: workspaceTabDrag,
tabWrap: wrapWorkspaceTab,
uncloseable: true
},
render: renderWorkspacePane
})
}
$selectedStoredSessionId.listen(syncWorkspaceTitle)
$sessions.listen(syncWorkspaceTitle)
$workspaceIsPage.listen(syncWorkspaceTitle)
// Layout reset collapses every session tile into main as a tab (after the
// workspace) instead of re-scattering them — pre-placed before adoption.
registerLayoutResetHandler(stackSessionTilesIntoMain)
// ---------------------------------------------------------------------------
// Titlebar chrome toggles -> tree. The TitlebarControls buttons keep their
// store semantics ($sidebarOpen / $fileBrowserOpen / $panesFlipped); the tree
// reacts — a hidden pane's zone collapses (content stays mounted), the flip
// toggle mirrors the root row.
// ---------------------------------------------------------------------------
function bindPaneVisibility(
paneId: string,
$open: { get(): boolean; listen(fn: (open: boolean) => void): void },
close?: () => void,
open?: () => void
) {
setTreePaneHidden(paneId, !$open.get())
$open.listen(isOpen => setTreePaneHidden(paneId, !isOpen))
// The tab menu's Close routes through the owning store (never dismissal),
// so the pane's toggle buttons stay truthful.
if (close) {
registerPaneCloser(paneId, close)
}
// The opener is the mirror: preset application (revealOnPreset) shows the
// pane through the same store, so the toggle stays truthful.
if (open) {
registerPaneOpener(paneId, open)
}
}
// TOOL PANELS (terminal, logs): like bindPaneVisibility but the toggle COLLAPSES
// the zone to a persistent rail (tab stays) instead of hiding it — the
// IntelliJ/VS-Code tool-window model. Restore routes back through `open` (rail
// click / chevron) so ⌃`/the button stay truthful; the tab's ✕ removes it.
function bindPaneCollapse(
paneId: string,
$open: { get(): boolean; listen(fn: (open: boolean) => void): void },
close: () => void,
open: () => void
) {
markCollapsePane(paneId)
setPaneCollapsed(paneId, !$open.get())
$open.listen(isOpen => setPaneCollapsed(paneId, !isOpen))
registerPaneCloser(paneId, close)
registerPaneOpener(paneId, open)
}
// SIDES have one source of truth: the TREE. The legacy $panesFlipped flag is
// DERIVED from where the sessions zone actually sits (TitlebarControls maps
// its left/right buttons through it), so dragging sessions across — or
// applying a mirrored preset — remaps the buttons automatically. The flip
// action (⌘\ / titlebar) mirrors the tree only when they disagree.
const sessionsOnRight = () => {
const tree = $layoutTree.get()
if (!tree) {
return null
}
const order = allPaneIds(tree)
const sessions = order.indexOf('sessions')
const main = order.indexOf('workspace')
return sessions >= 0 && main >= 0 ? sessions > main : null
}
$layoutTree.subscribe(() => {
const flipped = sessionsOnRight()
if (flipped !== null && flipped !== $panesFlipped.get()) {
$panesFlipped.set(flipped)
}
})
$panesFlipped.listen(flipped => {
const current = sessionsOnRight()
if (current !== null && current !== flipped) {
mirrorLayoutTree()
}
})
// POSITIONAL side toggles (titlebar buttons, ⌘B / ⌘J): $sidebarOpen ≙ the
// LEFT side of the main zone, $fileBrowserOpen ≙ the RIGHT — everything on
// that side hides together, whatever panes have been rearranged there.
bindTreeSideVisibility('left', $sidebarOpen, setSidebarOpen)
bindTreeSideVisibility('right', $fileBrowserOpen, setFileBrowserOpen)
// Workspace-scoped surfaces: the file tree and git diff only mean something
// inside a project. A detached chat (no cwd) hides them — their zones
// collapse and the chat absorbs the width; picking a project brings them
// back. The terminal is NOT workspace-gated: unlike the old shell (where it
// rode the rail's row and vanished with it), its zone stands on its own.
const $hasWorkspace = computed($currentCwd, cwd => Boolean(cwd.trim()))
bindPaneVisibility('files', $hasWorkspace)
// ⌘G — the review sidebar appears/disappears (and comes to the front).
bindPaneVisibility(
'review',
computed([$reviewOpen, $hasWorkspace], (open, workspace) => open && workspace),
closeReview
)
// ⌃` / statusbar toggle — the terminal COLLAPSES to a rail (tab stays), not
// hides; PTYs stay alive while collapsed (see PersistentTerminal).
bindPaneCollapse(
'terminal',
$terminalTakeover,
() => setTerminalTakeover(false),
() => setTerminalTakeover(true)
)
// Preview EXISTS only while something is previewed (old-shell semantics:
// closing the last preview tab closes the pane; a new target opens + fronts
// it). Same visibility binding as every other self-managed surface, driven
// by the live targets instead of a toggle.
const $previewVisible = computed([$previewTarget, $filePreviewTarget], (target, fileTarget) =>
Boolean(target || fileTarget)
)
bindPaneVisibility('preview', $previewVisible, closeRightRail)
// Logs are optional chrome: off by default, toggled from ⌘K, persisted.
const $logsOpen = persistentAtom('hermes.desktop.logsOpen', false, Codecs.bool)
bindPaneCollapse(
'logs',
$logsOpen,
() => $logsOpen.set(false),
() => $logsOpen.set(true)
)
registry.register({
id: 'logs.toggle',
area: PALETTE_AREA,
data: {
id: 'logs.toggle',
label: 'Toggle logs',
keywords: ['logs', 'agent log', 'tail', 'debug'],
run: () => $logsOpen.set(!$logsOpen.get())
} satisfies PaletteContribution
})
// Sessions/files Close = collapse their SIDE (⌘B/⌘J truthful, titlebar button
// flips back) — but only while the pane actually lives in that root side
// column. Dragged next to main, a side collapse can't hide it (the collapse
// skips main-bearing children), so Close falls back to dismissal there —
// otherwise ⌘W/Close silently no-op.
registerPaneCloser('sessions', () =>
paneRootSide('sessions') === 'left' ? setSidebarOpen(false) : dismissTreePane('sessions')
)
registerPaneCloser('files', () =>
paneRootSide('files') === 'right' ? setFileBrowserOpen(false) : dismissTreePane('files')
)
// A preview target lands NEXT TO the file tree — position-aware: wherever
// files currently lives (default rail, ⌘\-flipped, dragged into a stack), the
// preview zone docks directly beside it. A user who drags the preview pane
// somewhere pins it there instead (until a preset/reset). Then reveal: open
// the side, unhide, front — a NEW target while already visible still fronts.
const revealPreview = () => {
dockPaneBeside('preview', 'files')
revealTreePane('preview')
}
$previewTarget.listen(target => target && revealPreview())
$filePreviewTarget.listen(target => target && revealPreview())
// ---------------------------------------------------------------------------
export function ContribController() {
const sidebarOpen = useStore($sidebarOpen)
return (
<SidebarProvider
className="h-screen min-h-0 flex-col bg-background"
onOpenChange={setSidebarOpen}
open={sidebarOpen}
style={{ '--sidebar-width': '100%' } as CSSProperties}
>
<ContribWiring>
<div
className="flex h-screen min-h-0 w-screen flex-col bg-(--ui-bg-chrome) text-(--ui-text-primary)"
style={{ '--titlebar-height': '0px' } as CSSProperties}
>
{/* Title bar: fixed chrome outside the grid, composable via slots.
Layout contract (no contribution can break it):
- a full-bar DRAG BASE underneath (pointer-events-none, like
AppShell's drag strips) — everywhere without content drags
the window;
- each slot region is width-fit, no-drag, pointer-events-auto,
so every contribution is clickable by construction;
- LEFT/RIGHT slots align to the MAIN PANE's geometry via the
tree-published --workspace-left/right vars (pure CSS, no rect
threading), clamped to clear the REAL TitlebarControls
clusters (fixed, z-70); center is truly window-centered. */}
<div className="relative flex h-[34px] shrink-0 items-center border-b border-(--ui-stroke-tertiary) text-xs">
{/* Drag strips, AppShell-style: cut to AVOID the fixed control
clusters instead of overlapping them — Electron's no-drag
carve-out of fixed/transformed elements is unreliable, so a
full-bar drag base kills their clicks. In-flow slot content
still carves via its own no-drag wrapper (the same pattern as
the app's session-title button). */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-0 w-(--titlebar-controls-left,14px) [-webkit-app-region:drag]"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-[calc(var(--titlebar-controls-left,14px)+(var(--titlebar-control-size,1.25rem)*2)+0.75rem)] right-[calc(var(--titlebar-tools-right,0.75rem)+var(--titlebar-tools-width,5.5rem)+0.75rem)] [-webkit-app-region:drag]"
/>
<div
className="pointer-events-auto absolute z-10 flex w-max items-center gap-2 [-webkit-app-region:no-drag]"
style={{
left: 'max(calc(var(--workspace-left, 0px) + 0.5rem), calc(var(--titlebar-controls-left, 14px) + 2 * var(--titlebar-control-size, 1.25rem) + 1rem))'
}}
>
<Slot area="titleBar.left" />
</div>
<div className="pointer-events-auto absolute left-1/2 top-1/2 z-10 flex w-max -translate-x-1/2 -translate-y-1/2 items-center gap-2 [-webkit-app-region:no-drag]">
<Slot area="titleBar.center" />
</div>
<div
className="pointer-events-auto absolute z-10 flex w-max items-center gap-2 [-webkit-app-region:no-drag]"
style={{
right:
'max(calc(var(--workspace-right, 0px) + 0.5rem), calc(var(--titlebar-tools-right, 0.75rem) + 4 * (var(--titlebar-control-size, 1.25rem) + 0.25rem) + 0.5rem))'
}}
>
<Slot area="titleBar.right" />
</div>
</div>
<LayoutTreeRoot />
{/* "Close running tab?" — the busy/input-blocked tile close gate. */}
<SessionTileCloseConfirm />
{/* The REAL statusbar (model pill, command center, agents, …) with
statusBar.left/right contributions merged in. */}
<WiredPane part="statusbar" />
</div>
</ContribWiring>
</SidebarProvider>
)
}
// Referenced type kept for plugin authors' reference (payload shape of
// statusBar.* contributions).
export type { StatusbarItem }
@@ -0,0 +1,133 @@
import { useEffect } from 'react'
import { refreshActiveProfile } from '@/store/profile'
import { $activeSessionId, $currentCwd, setCurrentCwd } from '@/store/session'
import type { GatewayRequester } from '../types'
// Cron sessions are written by a background scheduler tick, messaging turns by
// the background gateway (Telegram, WeChat, Discord, …) — neither signals the
// desktop websocket, so poll the bounded lists while the app is visible.
const CRON_POLL_INTERVAL_MS = 30_000
const MESSAGING_POLL_INTERVAL_MS = 10_000
const ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS = 5_000
interface BackgroundSyncParams {
activeIsMessaging: boolean
activeSessionId: null | string
freshDraftReady: boolean
gatewayState: string
refreshActiveMessagingTranscript: () => Promise<unknown> | unknown
refreshCronJobs: () => Promise<unknown> | unknown
refreshCurrentModel: (force?: boolean) => Promise<unknown> | unknown
refreshHermesConfig: () => Promise<unknown> | unknown
refreshMessagingSessions: () => Promise<unknown> | unknown
refreshSessions: () => Promise<unknown> | unknown
requestGateway: GatewayRequester
}
/** Poll a callback while the tab is visible, on `intervalMs`; re-checks on tab
* re-focus. Returns nothing — meant to live inside an effect. */
function visiblePoll(intervalMs: number, tick: () => void): () => void {
const run = () => {
if (document.visibilityState === 'visible') {
tick()
}
}
const intervalId = window.setInterval(run, intervalMs)
document.addEventListener('visibilitychange', run)
return () => {
window.clearInterval(intervalId)
document.removeEventListener('visibilitychange', run)
}
}
/**
* Keeps app data live while the gateway is open: an on-connect reseed (model /
* profile / sessions + relative-cwd resolution), the cron / messaging /
* open-transcript visibility polls, and the fresh-draft model/config reseed.
* All the "the desktop websocket won't tell us, so poll" logic in one place.
*/
export function useBackgroundSync({
activeIsMessaging,
activeSessionId,
freshDraftReady,
gatewayState,
refreshActiveMessagingTranscript,
refreshCronJobs,
refreshCurrentModel,
refreshHermesConfig,
refreshMessagingSessions,
refreshSessions,
requestGateway
}: BackgroundSyncParams): void {
useEffect(() => {
if (gatewayState !== 'open') {
return
}
void refreshCurrentModel()
void refreshActiveProfile()
void refreshSessions()
// A RELATIVE workspace cwd (config `terminal.cwd: .`) renders as "." in the
// file tree header — resolve it to the backend's absolute path once.
// Session runtime info still overrides later, and never while a session is
// active.
const cwd = $currentCwd.get().trim()
if (!$activeSessionId.get() && cwd && !/^(\/|[A-Za-z]:[\\/])/.test(cwd)) {
void requestGateway<{ cwd?: string }>('config.get', { key: 'project', cwd })
.then(info => {
if (info.cwd && !$activeSessionId.get()) {
setCurrentCwd(info.cwd)
}
})
.catch(() => undefined)
}
}, [gatewayState, refreshCurrentModel, refreshSessions, requestGateway])
// Keep the cron-jobs section live without a user action (scheduler ticks in
// the background); re-check on tab re-focus too.
useEffect(() => {
if (gatewayState !== 'open') {
return
}
return visiblePoll(CRON_POLL_INTERVAL_MS, () => void refreshCronJobs())
}, [gatewayState, refreshCronJobs])
// Keep the messaging-platform session lists live (inbound turns are written
// by the gateway, not the desktop websocket).
useEffect(() => {
if (gatewayState !== 'open') {
return
}
return visiblePoll(MESSAGING_POLL_INTERVAL_MS, () => void refreshMessagingSessions())
}, [gatewayState, refreshMessagingSessions])
// Only the open messaging transcript needs its own poll — local chats are
// live over the websocket already.
useEffect(() => {
if (gatewayState !== 'open' || !activeIsMessaging) {
return
}
const dispose = visiblePoll(ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS, () => void refreshActiveMessagingTranscript())
void refreshActiveMessagingTranscript()
return dispose
}, [activeIsMessaging, gatewayState, refreshActiveMessagingTranscript])
// A fresh new-session draft (gateway open, no active session) re-pulls the
// model + config so the composer pill reflects the profile default.
useEffect(() => {
if (gatewayState === 'open' && !activeSessionId && freshDraftReady) {
void refreshCurrentModel()
void refreshHermesConfig()
}
}, [activeSessionId, freshDraftReady, gatewayState, refreshCurrentModel, refreshHermesConfig])
}
@@ -0,0 +1,176 @@
import { useEffect, useRef } from 'react'
import { closeActiveTab } from '@/app/chat/close-tab'
import { storedSessionIdForNotification } from '@/lib/session-ids'
import { respondToApprovalAction } from '@/store/native-notifications'
import {
getRememberedRoute,
getRememberedSessionId,
setRememberedRoute,
setRememberedSessionId
} from '@/store/session'
import { onSessionsChanged } from '@/store/session-sync'
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '@/store/updates'
import { isSecondaryWindow } from '@/store/windows'
import { requestComposerFocus, requestComposerInsert } from '../../chat/composer/focus'
import { appViewForPath, isOverlayView, NEW_CHAT_ROUTE, sessionRoute } from '../../routes'
interface DesktopIntegrationsParams {
chatOpen: boolean
hasPreview: boolean
locationPathname: string
navigate: (to: string, options?: { replace?: boolean }) => void
refreshSessions: () => Promise<unknown> | unknown
resumeExhaustedSessionId: null | string
routedSessionId: null | string
runtimeIdByStoredSessionId: { readonly current: Map<string, string> }
}
/**
* All the Electron-main / OS / cross-window integrations the shell listens for:
* update polling, the ⌘W close shortcut, deep links, native-notification
* navigation, preview-shortcut enablement, remembered-session restore, and
* cross-window session-list sync. Kept out of the wiring controller so the
* "talks to the desktop shell" surface reads as one unit.
*/
export function useDesktopIntegrations({
locationPathname,
navigate,
refreshSessions,
resumeExhaustedSessionId,
routedSessionId,
runtimeIdByStoredSessionId
}: DesktopIntegrationsParams): void {
// Update polling — populates $desktopVersion/$updateStatus, which feed the
// statusbar version pill and the update toasts. Also honors the main
// process's "open updates" menu request.
useEffect(() => {
startUpdatePoller()
const unsubscribe = window.hermesDesktop?.onOpenUpdatesRequested?.(() => openUpdatesWindow())
return () => {
unsubscribe?.()
stopUpdatePoller()
}
}, [])
// The renderer OWNS ⌘W: on macOS the native menu accelerator would else
// close the window, so claim it unconditionally — the menu then routes ⌘W
// to us (close-preview-requested IPC) and we decide tab-vs-window.
useEffect(() => {
window.hermesDesktop?.setPreviewShortcutActive?.(true)
}, [])
// Remember the open chat (session id for notifications/resume) AND the last
// non-overlay route (a page like /skills, or a session route) so a relaunch
// lands where you were. Overlays (settings/command-center/…) aren't stored —
// you don't want to boot into a modal.
useEffect(() => {
if (routedSessionId) {
setRememberedSessionId(routedSessionId)
}
if (!isOverlayView(appViewForPath(locationPathname))) {
setRememberedRoute(locationPathname)
}
}, [locationPathname, routedSessionId])
const restoredRef = useRef(false)
// Restore once on cold start — only when the renderer booted at the default
// route (a hidden-then-shown window keeps its own route). Prefer the full
// remembered route (covers pages); fall back to the last session id.
useEffect(() => {
if (restoredRef.current || locationPathname !== NEW_CHAT_ROUTE) {
restoredRef.current = true
return
}
restoredRef.current = true
const route = getRememberedRoute()
if (route && route !== NEW_CHAT_ROUTE && !isOverlayView(appViewForPath(route))) {
navigate(route, { replace: true })
return
}
const last = getRememberedSessionId()
if (last) {
navigate(sessionRoute(last), { replace: true })
}
}, [locationPathname, navigate])
useEffect(() => {
if (resumeExhaustedSessionId && getRememberedSessionId() === resumeExhaustedSessionId) {
setRememberedSessionId(null)
}
}, [resumeExhaustedSessionId])
// Native-notification click -> jump to the session (runtime id translated to
// the stored id the chat route is keyed by); action buttons resolve in place.
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onFocusSession?.(sessionId => {
if (sessionId) {
navigate(sessionRoute(storedSessionIdForNotification(sessionId, runtimeIdByStoredSessionId.current)))
}
})
return () => unsubscribe?.()
}, [navigate, runtimeIdByStoredSessionId])
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onNotificationAction?.(({ actionId, sessionId }) => {
void respondToApprovalAction(sessionId ?? null, actionId)
})
return () => unsubscribe?.()
}, [])
// hermes:// deep links -> a reviewable /blueprint command in the composer.
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onDeepLink?.(payload => {
if (!payload || payload.kind !== 'blueprint' || !payload.name) {
return
}
const slots = Object.entries(payload.params || {})
.map(([k, v]) => {
const sval = /\s/.test(v) ? `"${v.replace(/"/g, '\\"')}"` : v
return `${k}=${sval}`
})
.join(' ')
const command = `/blueprint ${payload.name}${slots ? ' ' + slots : ''}`
requestComposerInsert(command, { mode: 'block', target: 'main' })
requestComposerFocus('main')
})
void window.hermesDesktop?.signalDeepLinkReady?.()
return () => unsubscribe?.()
}, [])
// ⌘W via the macOS menu accelerator → close the focused tab; if nothing is
// closeable, fall back to closing the window (so ⌘W still works as the
// OS-standard window close, esp. secondary windows). The Win/Linux keyboard
// path is the `view.closeTab` keybind (use-keybinds), sharing closeActiveTab.
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onClosePreviewRequested?.(() => void closeActiveTab())
return () => unsubscribe?.()
}, [])
// Another window mutated the shared session list -> re-pull the sidebar.
useEffect(() => {
if (isSecondaryWindow()) {
return
}
return onSessionsChanged(() => void refreshSessions())
}, [refreshSessions])
}
@@ -0,0 +1,71 @@
import { useEffect, useRef } from 'react'
import { setPetActivity } from '@/store/pet'
import { setPetScale } from '@/store/pet-gallery'
import {
setPetOverlayOpenAppHandler,
setPetOverlayScaleHandler,
setPetOverlaySubmitHandler
} from '@/store/pet-overlay'
import { $attentionSessionIds, $sessions } from '@/store/session'
import { isSecondaryWindow } from '@/store/windows'
import type { GatewayRequester } from '../types'
interface PetBridgeParams {
requestGateway: GatewayRequester
resumeSession: (sessionId: string) => Promise<unknown> | unknown
submitText: (text: string) => Promise<unknown> | unknown
}
/**
* Wires the popped-out pet overlay back into the app: submit a prompt, resize,
* and open the most-recent thread, plus mirroring "a session is awaiting the
* user" into the pet's pose. Handlers register ONCE through refs tracking the
* latest callbacks — re-registering on identity churn leaves a nulled-handler
* window that can drop a submit. Primary window only.
*/
export function usePetBridge({ requestGateway, resumeSession, submitText }: PetBridgeParams): void {
const submitTextRef = useRef(submitText)
submitTextRef.current = submitText
const resumeSessionRef = useRef(resumeSession)
resumeSessionRef.current = resumeSession
const requestGatewayRef = useRef(requestGateway)
requestGatewayRef.current = requestGateway
useEffect(() => {
if (isSecondaryWindow()) {
return
}
setPetOverlaySubmitHandler(text => void submitTextRef.current(text))
// Alt+wheel resize from the popped-out pet — persist through this window's
// gateway (the overlay has none) so it survives restart.
setPetOverlayScaleHandler(scale => setPetScale(requestGatewayRef.current, scale))
// Mail icon: $sessions is most-recent-first; the pet is global, so "most
// recent" is the right target.
setPetOverlayOpenAppHandler(() => {
const recent = $sessions.get()[0]
if (recent?.id) {
void resumeSessionRef.current(recent.id)
}
})
return () => {
setPetOverlaySubmitHandler(null)
setPetOverlayOpenAppHandler(null)
setPetOverlayScaleHandler(null)
}
}, [])
// Mirror "a session is blocked on the user" (clarify/approval) into the pet's
// awaitingInput flag so it shows the `waiting` pose.
useEffect(() => {
const sync = () => setPetActivity({ awaitingInput: $attentionSessionIds.get().length > 0 })
sync()
return $attentionSessionIds.listen(sync)
}, [])
}
@@ -0,0 +1,108 @@
import { useEffect } from 'react'
import { getSessionMessages, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes'
import { toChatMessages } from '@/lib/chat-messages'
import { publishSessionState, setSessionTileDelegate } from '@/store/session-states'
import type { SessionResumeResponse } from '@/types/hermes'
import type { usePromptActions } from '../../session/hooks/use-prompt-actions'
import type { useSessionStateCache } from '../../session/hooks/use-session-state-cache'
import type { GatewayRequester } from '../types'
type SessionStateCache = ReturnType<typeof useSessionStateCache>
interface SessionTileDelegateParams {
archiveSession: (storedSessionId: string) => Promise<unknown>
branchStoredSession: (storedSessionId: string) => Promise<unknown>
executeSlashCommand: ReturnType<typeof usePromptActions>['executeSlashCommand']
removeSession: (storedSessionId: string) => Promise<unknown>
requestGateway: GatewayRequester
runtimeIdByStoredSessionIdRef: SessionStateCache['runtimeIdByStoredSessionIdRef']
sessionStateByRuntimeIdRef: SessionStateCache['sessionStateByRuntimeIdRef']
updateSessionState: SessionStateCache['updateSessionState']
}
/**
* Publishes the session-tile delegate: resume / submit / interrupt / slash for
* tiled sessions WITHOUT touching the primary view ($activeSessionId /
* $messages stay the main thread's). Resume reuses a live runtime binding when
* one exists (incl. the main thread's own session); a cold tile binds +
* hydrates the cache, which publishSessionState mirrors to the tile.
*/
export function useSessionTileDelegate({
archiveSession,
branchStoredSession,
executeSlashCommand,
removeSession,
requestGateway,
runtimeIdByStoredSessionIdRef,
sessionStateByRuntimeIdRef,
updateSessionState
}: SessionTileDelegateParams): void {
useEffect(() => {
setSessionTileDelegate({
archiveSession: async storedSessionId => {
await archiveSession(storedSessionId)
},
branchSession: async storedSessionId => {
await branchStoredSession(storedSessionId)
},
deleteSession: async storedSessionId => {
await removeSession(storedSessionId)
},
executeSlash: async (rawCommand, sessionId) => {
await executeSlashCommand(rawCommand, { sessionId })
},
interruptSession: async runtimeId => {
await requestGateway('session.interrupt', { session_id: runtimeId })
},
resumeTile: async storedSessionId => {
const existing = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
const cached = existing ? sessionStateByRuntimeIdRef.current.get(existing) : undefined
if (existing && cached?.storedSessionId === storedSessionId) {
publishSessionState(existing, cached)
return existing
}
const [prefetch, resumed] = await Promise.all([
getSessionMessages(storedSessionId).catch(() => null),
requestGateway<SessionResumeResponse>('session.resume', { session_id: storedSessionId, cols: 96 })
])
const runtimeId = resumed?.session_id
if (!runtimeId) {
throw new Error('resume returned no session id')
}
updateSessionState(
runtimeId,
state => ({
...state,
busy: Boolean(resumed?.info?.running),
messages:
state.messages.length > 0 ? state.messages : toChatMessages(prefetch?.messages ?? resumed?.messages ?? [])
}),
storedSessionId
)
return runtimeId
},
submitToSession: async (runtimeId, text) => {
await requestGateway('prompt.submit', { session_id: runtimeId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS)
},
updateSession: (runtimeId, updater) => updateSessionState(runtimeId, updater)
})
}, [
archiveSession,
branchStoredSession,
executeSlashCommand,
removeSession,
requestGateway,
runtimeIdByStoredSessionIdRef,
sessionStateByRuntimeIdRef,
updateSessionState
])
}
+7
View File
@@ -0,0 +1,7 @@
// The contribution-driven shell package. `controller` registers panes /
// layouts / chrome and mounts the app root; `wiring` is the data controller +
// memoized pane surfaces; `panes` holds the real-data pane bodies + statusbar
// group setters. Only the controller is a public entry (the app root renders
// it); the rest are internal to this directory.
export { ContribController } from './controller'
export { ContribWiring, WiredPane } from './wiring'
+207
View File
@@ -0,0 +1,207 @@
/**
* Real-data panes + composable bar items for the contrib root:
*
* - `PreviewRailPane` — the REAL ChatPreviewRail; files-pane clicks feed it.
* - `FilesPane` — real file browser; activating a file opens it in preview.
* - Core statusbar items with LIVE store-backed labels, registered as DATA
* contributions (`area: 'statusBar.left' / 'statusBar.right'`, payload =
* StatusbarItem) — plugins add theirs through the identical call.
*/
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { atom } from 'nanostores'
import type { CSSProperties } from 'react'
import { ChatPreviewRail } from '@/app/chat/right-rail/preview'
import { RightSidebarPane } from '@/app/right-sidebar'
import { ReviewPane } from '@/app/right-sidebar/review'
import type { GroupSetter } from '@/app/shell/group-setter'
import type { StatusbarItem } from '@/app/shell/statusbar-controls'
import { TITLEBAR_HEIGHT } from '@/app/shell/titlebar'
import type { TitlebarTool } from '@/app/shell/titlebar-controls'
import { DecodeText } from '@/components/ui/decode-text'
import { ContribBoundary } from '@/contrib/react/boundary'
import { useContributions } from '@/contrib/react/use-contributions'
import { registry } from '@/contrib/registry'
import { getLogs } from '@/hermes'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { $filePreviewTarget, $previewTarget, setCurrentSessionPreviewTarget } from '@/store/preview'
import { $currentCwd } from '@/store/session'
// ---------------------------------------------------------------------------
// Logs — live agent-log tail. OPTIONAL chrome: not in any default layout,
// hidden until the ⌘K "Toggle logs" command opens it ($logsOpen).
// ---------------------------------------------------------------------------
export function LogsPane() {
const { data, error } = useQuery({
queryKey: ['contrib-logs-tail'],
queryFn: () => getLogs({ lines: 300 }),
refetchInterval: 5000
})
if (error) {
return <div className="p-3 text-xs text-(--ui-text-quaternary)">log unavailable: {String(error)}</div>
}
if (!data) {
return (
<div className="grid h-full place-items-center">
<DecodeText className="text-(--ui-text-quaternary)" cursor prefix={1} text="LOGS" />
</div>
)
}
// No chrome of its own — the zone header (when the user summons it) is the
// pane's only label. Just the tail.
return (
<pre className="h-full min-h-0 overflow-auto whitespace-pre-wrap break-words p-2.5 font-mono text-[0.66rem] leading-relaxed text-(--ui-text-secondary)">
{data.lines.join('\n')}
</pre>
)
}
// ---------------------------------------------------------------------------
// Preview — the real rail, fed by the files pane
// ---------------------------------------------------------------------------
/** Preview-server restart handler, provided by the wiring (usePreviewRouting).
* Atom-bridged: this module can't import contrib-wiring (it imports us). */
export const $restartPreviewServer = atom<((url: string, context?: string) => Promise<string>) | null>(null)
export function PreviewRailPane() {
const previewTarget = useStore($previewTarget)
const fileTarget = useStore($filePreviewTarget)
const restartPreviewServer = useStore($restartPreviewServer)
if (!previewTarget && !fileTarget) {
return (
<div className="grid h-full place-items-center px-4 text-center">
<div className="flex flex-col items-center gap-1.5">
<DecodeText className="text-(--ui-text-quaternary)" prefix={1} text="PREVIEW" />
<span className="text-[0.68rem] text-(--ui-text-quaternary)">click a file in the files pane</span>
</div>
</div>
)
}
return (
// The contrib layout zeroes --titlebar-height (content sits BELOW the
// titlebar, so the real components' clearance padding must collapse) —
// but the rail SIZES its per-file tab strip with that var. Restore the
// real value for this subtree so the tabs always render at full height.
<div
className={cn(ZONE_CONTENT, 'min-h-0 w-full overflow-hidden [&>aside]:pt-0')}
style={{ '--titlebar-height': `${TITLEBAR_HEIGHT}px` } as CSSProperties}
>
<ChatPreviewRail onRestartServer={restartPreviewServer ?? undefined} setTitlebarToolGroup={setTitlebarToolGroup} />
</div>
)
}
/** Open a file from the tree in the real preview pipeline. */
function previewFile(path: string) {
void normalizeOrLocalPreviewTarget(path, $currentCwd.get() || undefined)
.then(target => {
if (target) {
setCurrentSessionPreviewTarget(target, 'file-browser', path)
}
})
.catch(() => undefined)
}
// Layout fit for wrapped asides. Edge chrome (borders/shadows) is neutralized
// GLOBALLY by the tree's seam invariant (see LayoutTreeRoot) — only sizing
// and titlebar clearance are per-wrapper concerns.
const ZONE_CONTENT = 'h-full [&>aside]:h-full [&>aside]:w-full [&>aside]:pt-0'
export function FilesPane() {
return (
<div className={ZONE_CONTENT}>
<RightSidebarPane onActivateFile={previewFile} onActivateFolder={previewFile} />
</div>
)
}
// ---------------------------------------------------------------------------
// Review — the real git diff pane (⌘G / $reviewOpen)
// ---------------------------------------------------------------------------
export function ReviewPaneContent() {
const cwd = useStore($currentCwd)
// Keyed by cwd like DesktopController so switching projects rebuilds the
// diff state instead of showing the previous repo's files.
return (
<div className={cn(ZONE_CONTENT, 'flex min-h-0 flex-col [&>aside]:min-h-0 [&>aside]:flex-1')}>
<ReviewPane key={cwd || 'no-cwd'} />
</div>
)
}
// ---------------------------------------------------------------------------
// Statusbar composability: plugins contribute DATA items into
// `statusBar.left` / `statusBar.right`; the wiring feeds them into the REAL
// useStatusbarItems as extraLeftItems/extraRightItems. No core filler here —
// the real statusbar owns the core items (model pill, terminal toggle, …).
// ---------------------------------------------------------------------------
/** Collect statusbar contributions for one side. A `render()` contribution
* becomes a render-item (arbitrary stateful node); otherwise the declarative
* `data` payload is the StatusbarItem. */
export function useStatusbarContributions(side: 'left' | 'right'): StatusbarItem[] {
const items = useContributions(`statusBar.${side}`)
return items
.map(c =>
c.render
? ({
id: c.id,
render: () => <ContribBoundary id={c.id} variant="chip">{c.render!()}</ContribBoundary>
} satisfies StatusbarItem)
: (c.data as StatusbarItem)
)
.filter(Boolean)
}
/** Collect TitlebarTool data contributions for one side of the titlebar. */
export function useTitlebarToolContributions(side: 'left' | 'right'): TitlebarTool[] {
const items = useContributions(`titleBar.tools.${side}`)
return items.map(c => c.data as TitlebarTool).filter(Boolean)
}
/**
* Bridge a page's `GroupSetter` extension point (SkillsView, MessagingView,
* ChatPreviewRail, …) into the registry: each call replaces the group's items
* as DATA contributions in `<prefix>.<side>`, so page-owned items flow through
* the same pipe plugins use. Setting an empty list clears the group.
*/
export function registryGroupSetter<T>(prefix: string): GroupSetter<T> {
const disposers = new Map<string, () => void>()
return (id, items, side = 'right') => {
const key = `${side}:${id}`
disposers.get(key)?.()
disposers.set(
key,
registry.registerMany(
items.map((item, i) => ({
id: `${id}-${i}`,
area: `${prefix}.${side}`,
source: 'core',
order: 100 + i,
data: item as object
}))
)
)
}
}
/** The app's page-facing setters — the same `GroupSetter` shape pages already
* take as props, backed by the registry instead of component state. */
export const setStatusbarItemGroup = registryGroupSetter<StatusbarItem>('statusBar')
export const setTitlebarToolGroup = registryGroupSetter<TitlebarTool>('titleBar.tools')
+204
View File
@@ -0,0 +1,204 @@
/**
* Wiring surfaces — each pane is its own memoized component. Every surface
* reads the reactive state it renders from at the leaf (its own atom
* subscriptions) and reaches the controller's callbacks through the stable
* `actions` bag, so a state change scoped to one surface (or a bare
* wiring-controller tick) never re-renders another. This is what keeps the
* layout tree's zones independently rendered — the whole point of the shell.
*/
import { useStore } from '@nanostores/react'
import { type ComponentProps, lazy, memo, type ReactNode, Suspense, useMemo } from 'react'
import { Navigate, Route, Routes, useParams } from 'react-router-dom'
import { ContribBoundary } from '@/contrib/react/boundary'
import { useContributions } from '@/contrib/react/use-contributions'
import { $freshDraftReady, $gatewayState } from '@/store/session'
import { ChatView } from '../chat'
import { ChatSidebar } from '../chat/sidebar'
import { TerminalPaneChrome } from '../right-sidebar/terminal/chrome'
import { contributedRoutes, NEW_CHAT_ROUTE, ROUTES_AREA, sessionRoute } from '../routes'
import { useStatusSnapshot } from '../shell/hooks/use-status-snapshot'
import { useStatusbarItems } from '../shell/hooks/use-statusbar-items'
import { ModelMenuPanel } from '../shell/model-menu-panel'
import { StatusbarControls } from '../shell/statusbar-controls'
import { setStatusbarItemGroup, useStatusbarContributions } from './panes'
import type { SidebarActions, WiringActions } from './types'
// Same lazy-view split as DesktopController — pages load on demand. The
// full-page views the workspace route table mounts live here; overlay views
// (agents/settings/…) are the controller's and stay in wiring.tsx.
const ArtifactsView = lazy(async () => ({ default: (await import('../artifacts')).ArtifactsView }))
const MessagingView = lazy(async () => ({ default: (await import('../messaging')).MessagingView }))
const SkillsView = lazy(async () => ({ default: (await import('../skills')).SkillsView }))
export function LegacySessionRedirect() {
const { sessionId } = useParams()
return <Navigate replace to={sessionId ? sessionRoute(sessionId) : NEW_CHAT_ROUTE} />
}
export const SidebarSurface = memo(function SidebarSurface({
actions,
currentView
}: {
actions: SidebarActions
currentView: ComponentProps<typeof ChatSidebar>['currentView']
}) {
return <ChatSidebar currentView={currentView} {...actions} />
})
export const TerminalSurface = memo(function TerminalSurface() {
return (
<div className="relative flex h-full min-h-0 flex-col overflow-hidden bg-(--ui-editor-surface-background)">
<TerminalPaneChrome />
</div>
)
})
/** Owns the statusbar's own data hooks (status snapshot poll, contributed
* items) so its 15s refresh — and any statusbar-only churn — re-renders the
* bar alone, never the chat/sidebar/terminal. */
export const StatusbarSurface = memo(function StatusbarSurface({
actions,
agentsOpen,
chatOpen,
commandCenterOpen
}: {
actions: WiringActions
agentsOpen: boolean
chatOpen: boolean
commandCenterOpen: boolean
}) {
const gatewayState = useStore($gatewayState)
const freshDraftReady = useStore($freshDraftReady)
const { inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, actions.requestGateway)
const extraLeftItems = useStatusbarContributions('left')
const extraRightItems = useStatusbarContributions('right')
const { leftStatusbarItems, statusbarItems } = useStatusbarItems({
agentsOpen,
chatOpen,
commandCenterOpen,
extraLeftItems,
extraRightItems,
freshDraftReady,
gatewayState,
inferenceStatus,
openAgents: actions.openAgents,
openCommandCenterSection: actions.openCommandCenterSection,
requestGateway: actions.requestGateway,
statusSnapshot,
toggleCommandCenter: actions.toggleCommandCenter
})
return <StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />
})
/** The workspace pane: the real route table (chat + full-page views + plugin
* routes). Subscribes to `$gatewayState` and ROUTES_AREA itself; the gateway
* instance + voice cap arrive as props so a reconnect/config load re-renders
* only this surface. ChatView subscribes to its own session atoms, so
* streaming never round-trips through the controller. */
export const ChatRoutesSurface = memo(function ChatRoutesSurface({
actions,
maxVoiceRecordingSeconds
}: {
actions: WiringActions
maxVoiceRecordingSeconds?: number
}) {
const gatewayState = useStore($gatewayState)
useContributions(ROUTES_AREA)
const routeContributions = contributedRoutes()
// Recapture the live gateway instance whenever the connection state flips.
// getGateway reads a controller ref, so gatewayState is the intentional
// re-eval trigger (not a value the computation itself reads).
const gateway = useMemo(
() => actions.getGateway(),
// eslint-disable-next-line react-hooks/exhaustive-deps
[actions, gatewayState]
)
const modelMenuContent = useMemo(
() =>
gatewayState === 'open' ? (
<ModelMenuPanel
gateway={gateway || undefined}
onSelectModel={actions.selectModel}
requestGateway={actions.requestGateway}
/>
) : null,
[actions, gateway, gatewayState]
)
const chatView = (
<ChatView
gateway={gateway}
maxVoiceRecordingSeconds={maxVoiceRecordingSeconds}
modelMenuContent={modelMenuContent}
onAddContextRef={actions.onAddContextRef}
onAddUrl={actions.onAddUrl}
onAttachDroppedItems={actions.onAttachDroppedItems}
onAttachImageBlob={actions.onAttachImageBlob}
onBranchInNewChat={actions.onBranchInNewChat}
onCancel={actions.onCancel}
onDeleteSelectedSession={actions.onDeleteSelectedSession}
onDismissError={actions.onDismissError}
onEdit={actions.onEdit}
onPasteClipboardImage={actions.onPasteClipboardImage}
onPickFiles={actions.onPickFiles}
onPickFolders={actions.onPickFolders}
onPickImages={actions.onPickImages}
onReload={actions.onReload}
onRemoveAttachment={actions.onRemoveAttachment}
onRestoreToMessage={actions.onRestoreToMessage}
onRetryResume={actions.onRetryResume}
onSteer={actions.onSteer}
onSubmit={actions.onSubmit}
onThreadMessagesChange={actions.onThreadMessagesChange}
onToggleSelectedPin={actions.onToggleSelectedPin}
onTranscribeAudio={actions.onTranscribeAudio}
/>
)
// FULL-PAGE views (not chat) mark the zone body `data-zone-no-header`: a
// page is not a tab-able surface, so the zone's double-click header toggle
// stands down while one is showing (see onZoneDoubleClick).
const page = (view: ReactNode) => (
<div className="contents" data-zone-no-header>
<Suspense fallback={null}>{view}</Suspense>
</div>
)
return (
<Routes>
<Route element={chatView} index />
<Route element={chatView} path=":sessionId" />
<Route element={page(<SkillsView setStatusbarItemGroup={setStatusbarItemGroup} />)} path="skills" />
<Route element={page(<MessagingView setStatusbarItemGroup={setStatusbarItemGroup} />)} path="messaging" />
<Route element={page(<ArtifactsView setStatusbarItemGroup={setStatusbarItemGroup} />)} path="artifacts" />
<Route element={null} path="agents" />
<Route element={null} path="command-center" />
<Route element={null} path="cron" />
<Route element={null} path="profiles" />
<Route element={null} path="settings" />
<Route element={null} path="starmap" />
{/* Registry-contributed pages (core features + plugins) render in the
workspace pane like any built-in view — behind the same blast wall
as every other contribution mount. */}
{routeContributions.map(route => (
<Route
element={page(<ContribBoundary id={route.key}>{route.render()}</ContribBoundary>)}
key={route.key}
path={route.path.slice(1)}
/>
))}
<Route element={<Navigate replace to={NEW_CHAT_ROUTE} />} path="new" />
<Route element={<LegacySessionRedirect />} path="sessions/:sessionId" />
<Route element={<Navigate replace to={NEW_CHAT_ROUTE} />} path="*" />
</Routes>
)
})
+79
View File
@@ -0,0 +1,79 @@
import type { ComponentProps, ReactNode } from 'react'
import type { ChatView } from '../chat'
import type { ChatSidebar } from '../chat/sidebar'
import type { CommandCenterSection } from '../command-center'
import type { useGatewayRequest } from '../gateway/hooks/use-gateway-request'
import type { ModelMenuPanel } from '../shell/model-menu-panel'
export type GatewayRequester = ReturnType<typeof useGatewayRequest>['requestGateway']
/** The ChatSidebar handlers the controller owns — forwarded verbatim. */
export type SidebarActions = Pick<
ComponentProps<typeof ChatSidebar>,
| 'onArchiveSession'
| 'onBranchSession'
| 'onDeleteSession'
| 'onLoadMoreMessaging'
| 'onLoadMoreProfileSessions'
| 'onLoadMoreSessions'
| 'onManageCronJob'
| 'onNavigate'
| 'onNewSessionInWorkspace'
| 'onNewSessionSplit'
| 'onResumeSession'
| 'onTriggerCronJob'
>
/** The ChatView handlers the controller owns — forwarded verbatim. */
export type ChatActions = Pick<
ComponentProps<typeof ChatView>,
| 'onAddContextRef'
| 'onAddUrl'
| 'onAttachDroppedItems'
| 'onAttachImageBlob'
| 'onBranchInNewChat'
| 'onCancel'
| 'onDeleteSelectedSession'
| 'onDismissError'
| 'onEdit'
| 'onPasteClipboardImage'
| 'onPickFiles'
| 'onPickFolders'
| 'onPickImages'
| 'onReload'
| 'onRemoveAttachment'
| 'onRestoreToMessage'
| 'onRetryResume'
| 'onSteer'
| 'onSubmit'
| 'onThreadMessagesChange'
| 'onToggleSelectedPin'
| 'onTranscribeAudio'
>
/**
* The complete controller-owned callback surface. One object, one stable
* identity for the app's life — its fields are mutated in place each render,
* so surfaces bound to it never re-render on identity churn but always invoke
* the latest closure.
*/
export interface WiringActions extends SidebarActions, ChatActions {
/** The live gateway instance (held in a controller ref). Surfaces recapture
* it by subscribing to `$gatewayState`, so no gateway prop needs threading. */
getGateway: () => ComponentProps<typeof ChatView>['gateway']
openAgents: () => void
openCommandCenterSection: (section: CommandCenterSection) => void
requestGateway: GatewayRequester
selectModel: ComponentProps<typeof ModelMenuPanel>['onSelectModel']
toggleCommandCenter: () => void
}
/** The four wired surfaces the controller publishes; `WiredPane` renders one by
* key inside a registered pane / chrome slot. */
export interface WiringApi {
sidebar: ReactNode
chatRoutes: ReactNode
terminal: ReactNode
statusbar: ReactNode
}
+946
View File
@@ -0,0 +1,946 @@
/**
* Real-featureset wiring for the contrib (layout tree) root — the minimal
* subset of DesktopController's hook chain that makes the REAL surfaces work:
* gateway boot -> sessions list -> click-to-resume -> live transcript ->
* composer send, plus the real terminal.
*
* The wired nodes (sidebar / chat routes / terminal) are exposed through
* context; registered panes render `<WiredPane part="…"/>` to consume them.
*/
import { useStore } from '@nanostores/react'
import { useQueryClient } from '@tanstack/react-query'
import { type CSSProperties, lazy, type ReactNode, Suspense, useCallback, useEffect, useMemo, useRef } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { formatRefValue } from '@/components/assistant-ui/directive-text'
import { BootFailureOverlay } from '@/components/boot-failure-overlay'
import { DesktopInstallOverlay } from '@/components/desktop-install-overlay'
import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay'
import { NotificationStack } from '@/components/notifications'
import { DesktopOnboardingOverlay } from '@/components/onboarding'
import { FloatingPet } from '@/components/pet/floating-pet'
import { RemoteDisplayBanner } from '@/components/remote-display-banner'
import { emitGatewayEvent } from '@/contrib/events'
import { getSessionMessages, triggerCronJob } from '@/hermes'
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
import { sessionMessagesSignature } from '@/lib/session-signatures'
import { isMessagingSource } from '@/lib/session-source'
import { latestSessionTodos } from '@/lib/todos'
import { setCronFocusJobId } from '@/store/cron'
import { $pinnedSessionIds, pinSession, restoreWorktree, unpinSession } from '@/store/layout'
import { $filePreviewTarget, $previewTarget } from '@/store/preview'
import { $activeGatewayProfile, $freshSessionRequest, $profileScope, refreshActiveProfile } from '@/store/profile'
import { $startWorkSessionRequest, followActiveSessionCwd, resolveNewSessionCwd } from '@/store/projects'
import {
$activeSessionId,
$connection,
$currentCwd,
$freshDraftReady,
$gatewayState,
$messages,
$messagingSessions,
$resumeExhaustedSessionId,
$resumeFailedSessionId,
$selectedStoredSessionId,
$sessions,
sessionMatchesStoredId,
sessionPinId,
setAwaitingResponse,
setBusy,
setCurrentBranch,
setCurrentCwd,
setCurrentModel,
setCurrentProvider,
setMessages
} from '@/store/session'
import { focusOpenSession } from '@/store/session-states'
import { clearSessionTodos, setSessionTodos, todosForHydration } from '@/store/todos'
import { isSecondaryWindow } from '@/store/windows'
import { useSkinCommand } from '@/themes/use-skin-command'
import { requestComposerInsert } from '../chat/composer/focus'
import { useComposerActions } from '../chat/hooks/use-composer-actions'
import { CommandPalette } from '../command-palette'
import { useGatewayBoot } from '../gateway/hooks/use-gateway-boot'
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 { PetGenerateOverlay } from '../pet-generate/pet-generate-overlay'
import { FileActionDialogs } from '../right-sidebar/file-actions'
import { RemoteFolderPicker } from '../right-sidebar/files/remote-picker'
import { PersistentTerminal } from '../right-sidebar/terminal/persistent'
import { CRON_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE, syncWorkspaceIsPage } from '../routes'
import { SessionPickerOverlay } from '../session-picker-overlay'
import { SessionSwitcher } from '../session-switcher'
import { useContextSuggestions } from '../session/hooks/use-context-suggestions'
import { useCwdActions } from '../session/hooks/use-cwd-actions'
import { useHermesConfig } from '../session/hooks/use-hermes-config'
import { useMessageStream } from '../session/hooks/use-message-stream'
import { useModelControls } from '../session/hooks/use-model-controls'
import { usePreviewRouting } from '../session/hooks/use-preview-routing'
import { usePromptActions } from '../session/hooks/use-prompt-actions'
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 { useOverlayRouting } from '../shell/hooks/use-overlay-routing'
import { useWindowControlsOverlayWidth } from '../shell/hooks/use-window-controls-overlay-width'
import { KeybindPanel } from '../shell/keybind-panel'
import { titlebarControlsPosition } from '../shell/titlebar'
import { TitlebarControls } from '../shell/titlebar-controls'
import { UpdatesOverlay } from '../updates-overlay'
import { ContribWiringContext } from './context'
import { useBackgroundSync } from './hooks/use-background-sync'
import { useDesktopIntegrations } from './hooks/use-desktop-integrations'
import { usePetBridge } from './hooks/use-pet-bridge'
import { useSessionTileDelegate } from './hooks/use-session-tile-delegate'
import { $restartPreviewServer, useTitlebarToolContributions } from './panes'
import { ChatRoutesSurface, SidebarSurface, StatusbarSurface, TerminalSurface } from './surfaces'
import type { WiringActions, WiringApi } from './types'
// Overlay views the controller mounts over the shell — lazy, load on demand.
// The workspace-route full-page views (skills/messaging/artifacts) are the
// ChatRoutesSurface's and live in ./surfaces.
const AgentsView = lazy(async () => ({ default: (await import('../agents')).AgentsView }))
const CommandCenterView = lazy(async () => ({ default: (await import('../command-center')).CommandCenterView }))
const CronView = lazy(async () => ({ default: (await import('../cron')).CronView }))
const ProfilesView = lazy(async () => ({ default: (await import('../profiles')).ProfilesView }))
const SettingsView = lazy(async () => ({ default: (await import('../settings')).SettingsView }))
const StarmapView = lazy(async () => ({ default: (await import('../starmap')).StarmapView }))
// Surfaces (the four wired panes), the render context + WiredPane, and the
// WiringActions/WiringApi contracts all live in sibling modules — this file is
// the controller that assembles them.
export { WiredPane } from './context'
export function ContribWiring({ children }: { children: ReactNode }) {
const queryClient = useQueryClient()
const location = useLocation()
const navigate = useNavigate()
const busyRef = useRef(false)
const creatingSessionRef = useRef(false)
const messagingTranscriptSignatureRef = useRef(new Map<string, string>())
// Stable identity for the whole callback surface (see WiringActions). Mutated
// in place each render so memoized surfaces never re-render on churn.
const actionsRef = useRef<WiringActions | null>(null)
const gatewayState = useStore($gatewayState)
const activeSessionId = useStore($activeSessionId)
const currentCwd = useStore($currentCwd)
const freshDraftReady = useStore($freshDraftReady)
const resumeFailedSessionId = useStore($resumeFailedSessionId)
const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId)
const selectedStoredSessionId = useStore($selectedStoredSessionId)
const messagingSessions = useStore($messagingSessions)
const profileScope = useStore($profileScope)
const routedSessionId = routeSessionId(location.pathname)
const routeToken = `${location.pathname}:${location.search}:${location.hash}`
const routeTokenRef = useRef(routeToken)
routeTokenRef.current = routeToken
const getRouteToken = useCallback(() => routeTokenRef.current, [])
// Mirror "the workspace is showing a full page" into its atom — the
// workspace pane contribution re-registers headerVeto from it, so the main
// zone's tab bar stands down on pages (and returns with the chat).
useEffect(() => {
syncWorkspaceIsPage(location.pathname)
}, [location.pathname])
const {
agentsOpen,
chatOpen,
closeOverlayToPreviousRoute,
commandCenterInitialSection,
commandCenterOpen,
cronOpen,
currentView,
openAgents,
openCommandCenterSection,
openStarmap,
profilesOpen,
settingsOpen,
starmapOpen,
toggleCommandCenter
} = useOverlayRouting()
const {
activeSessionIdRef,
ensureSessionState,
resetViewSync,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionIdRef,
sessionStateByRuntimeIdRef,
syncSessionStateToView,
updateSessionState
} = useSessionStateCache({
activeSessionId,
busyRef,
selectedStoredSessionId,
setAwaitingResponse,
setBusy,
setMessages
})
const { connectionRef, gatewayRef, requestGateway } = useGatewayRequest()
const {
loadMoreMessagingForPlatform,
loadMoreSessions,
loadMoreSessionsForProfile,
refreshCronJobs,
refreshMessagingSessions,
refreshSessions
} = useSessionListActions({ profileScope })
const updateActiveSessionRuntimeInfo = useCallback(
(info: { branch?: string; cwd?: string }) => {
const sessionId = activeSessionIdRef.current
if (!sessionId) {
return
}
updateSessionState(sessionId, state => ({
...state,
branch: info.branch ?? state.branch,
cwd: info.cwd ?? state.cwd
}))
},
[activeSessionIdRef, updateSessionState]
)
const { refreshProjectBranch } = useCwdActions({
activeSessionId,
activeSessionIdRef,
onSessionRuntimeInfo: updateActiveSessionRuntimeInfo,
requestGateway
})
const { refreshHermesConfig, sttEnabled, voiceMaxRecordingSeconds } = useHermesConfig({
activeSessionIdRef,
refreshProjectBranch
})
const { refreshCurrentModel, selectModel, updateModelOptionsCache } = useModelControls({
activeSessionId,
queryClient,
requestGateway
})
const openProviderSettings = useCallback(() => navigate(`${SETTINGS_ROUTE}?tab=providers`), [navigate])
// Post-turn rehydrate from stored history (same behavior as DesktopController,
// including finished-todos restoration).
const hydrateFromStoredSession = useCallback(
async (
attempts = 1,
storedSessionId = selectedStoredSessionIdRef.current,
runtimeSessionId = activeSessionIdRef.current
) => {
if (!storedSessionId || !runtimeSessionId) {
return
}
const storedProfile = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))?.profile
for (let index = 0; index < Math.max(1, attempts); index += 1) {
try {
const latest = await getSessionMessages(storedSessionId, storedProfile)
const messages = toChatMessages(latest.messages)
updateSessionState(
runtimeSessionId,
state => ({ ...state, messages: preserveLocalAssistantErrors(messages, state.messages) }),
storedSessionId
)
const restored = todosForHydration(latestSessionTodos(messages))
if (restored) {
setSessionTodos(runtimeSessionId, restored)
} else {
clearSessionTodos(runtimeSessionId)
}
return
} catch {
// Best-effort fallback when live stream payloads are empty.
}
if (index < attempts - 1) {
await new Promise(resolve => window.setTimeout(resolve, 250))
}
}
},
[activeSessionIdRef, selectedStoredSessionIdRef, updateSessionState]
)
// Refresh the open messaging transcript (inbound platform turns arrive via
// the background gateway, not the desktop websocket). Signature-gated so a
// no-change poll doesn't churn the thread.
const refreshActiveMessagingTranscript = useCallback(async () => {
const storedSessionId = selectedStoredSessionIdRef.current
const runtimeSessionId = activeSessionIdRef.current
if (!storedSessionId || !runtimeSessionId || busyRef.current) {
return
}
const stored = $messagingSessions.get().find(s => sessionMatchesStoredId(s, storedSessionId))
if (!stored || !isMessagingSource(stored.source)) {
return
}
try {
const latest = await getSessionMessages(storedSessionId, stored.profile)
const signatureKey = `${stored.profile ?? 'default'}:${storedSessionId}`
const sig = sessionMessagesSignature(latest.messages)
if (messagingTranscriptSignatureRef.current.get(signatureKey) === sig) {
return
}
messagingTranscriptSignatureRef.current.set(signatureKey, sig)
const messages = toChatMessages(latest.messages)
updateSessionState(
runtimeSessionId,
state => ({ ...state, messages: preserveLocalAssistantErrors(messages, state.messages) }),
storedSessionId
)
} catch {
// Non-fatal: next poll or manual refresh can hydrate.
}
}, [activeSessionIdRef, busyRef, selectedStoredSessionIdRef, updateSessionState])
const { handleGatewayEvent } = useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession,
queryClient,
refreshHermesConfig,
refreshSessions,
sessionStateByRuntimeIdRef,
updateSessionState
})
// Agent-driven preview routing (agent opens a URL/file -> the preview rail
// follows) + the preview server restart handler, layered over the base
// gateway event stream exactly like DesktopController.
const { handleDesktopGatewayEvent, restartPreviewServer } = usePreviewRouting({
activeSessionIdRef,
baseHandleGatewayEvent: handleGatewayEvent,
currentCwd,
currentView,
requestGateway,
routedSessionId,
selectedStoredSessionId
})
// Composer @-mention context suggestions (files/dirs under the cwd).
useContextSuggestions({
activeSessionId,
activeSessionIdRef,
currentCwd,
gatewayState,
requestGateway
})
// Expose the restart handler to the preview pane contribution (module
// boundary crossed via atom — contrib-panes can't import this file).
useEffect(() => {
$restartPreviewServer.set(restartPreviewServer)
return () => $restartPreviewServer.set(null)
}, [restartPreviewServer])
const {
archiveSession,
branchCurrentSession,
branchStoredSession,
createBackendSessionForSend,
openNewSessionTile,
removeSession,
resumeSession,
selectSidebarItem,
startFreshSessionDraft
} = useSessionActions({
activeSessionId,
activeSessionIdRef,
busyRef,
creatingSessionRef,
ensureSessionState,
getRouteToken,
navigate,
requestGateway,
resetViewSync,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId,
selectedStoredSessionIdRef,
sessionStateByRuntimeIdRef,
syncSessionStateToView,
updateSessionState
})
// A profile switch/create drops to a fresh new-session draft so the
// previously open session doesn't bleed across contexts. Skip initial value.
const freshSessionRequest = useStore($freshSessionRequest)
const lastFreshRef = useRef(freshSessionRequest)
useEffect(() => {
if (freshSessionRequest === lastFreshRef.current) {
return
}
lastFreshRef.current = freshSessionRequest
startFreshSessionDraft()
}, [freshSessionRequest, startFreshSessionDraft])
// Swapping the live gateway to another profile must re-pull that profile's
// global model + active-profile pill (both are nanostores — the blanket
// invalidateQueries on swap doesn't touch them).
const activeGatewayProfile = useStore($activeGatewayProfile)
const lastGatewayProfileRef = useRef(activeGatewayProfile)
useEffect(() => {
if (activeGatewayProfile === lastGatewayProfileRef.current) {
return
}
lastGatewayProfileRef.current = activeGatewayProfile
// Force: the new profile has its own default, so reseed even if the
// composer already shows the previous profile's model.
void refreshCurrentModel(true)
void refreshActiveProfile()
}, [activeGatewayProfile, refreshCurrentModel])
// New session anchored to a workspace (sidebar "+" on a project/worktree).
// Seeds cwd + branch from the clicked workspace; an explicit worktree path
// also drills the sidebar into that project so the new lane is visible.
const startSessionInWorkspace = useCallback(
(path: null | string) => {
startFreshSessionDraft()
// A worktree lane carries its own path; the trunk "+" can be path-less
// (the main checkout is implicit), so fall back to the active project's
// root instead of no-op'ing on null.
const target = path?.trim() || resolveNewSessionCwd()
if (!target) {
return
}
setCurrentCwd(target)
void requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target })
.then(info => {
const resolved = info.cwd || target
setCurrentCwd(resolved)
setCurrentBranch(info.branch || '')
if (path?.trim()) {
restoreWorktree(resolved)
void followActiveSessionCwd(resolved)
}
})
.catch(() => undefined)
},
[requestGateway, startFreshSessionDraft]
)
// Composer "branch off into a new worktree": open a fresh session anchored
// to the just-created tree, then prefill the task that kicked it off.
const startWorkSessionRequest = useStore($startWorkSessionRequest)
const lastStartWorkTokenRef = useRef(startWorkSessionRequest?.token ?? 0)
useEffect(() => {
if (!startWorkSessionRequest || startWorkSessionRequest.token === lastStartWorkTokenRef.current) {
return
}
lastStartWorkTokenRef.current = startWorkSessionRequest.token
startSessionInWorkspace(startWorkSessionRequest.path)
if (startWorkSessionRequest.draft) {
requestComposerInsert(startWorkSessionRequest.draft, { target: 'main' })
}
}, [startSessionInWorkspace, startWorkSessionRequest])
const composer = useComposerActions({ activeSessionId, currentCwd, requestGateway })
const branchInNewChat = useCallback(
async (messageId?: string) => {
const branched = await branchCurrentSession(messageId)
if (branched) {
await refreshSessions().catch(() => undefined)
}
return branched
},
[branchCurrentSession, refreshSessions]
)
const handleSkinCommand = useSkinCommand()
const {
cancelRun,
editMessage,
executeSlashCommand,
handleThreadMessagesChange,
reloadFromMessage,
restoreToMessage,
steerPrompt,
submitText,
transcribeVoiceAudio
} = usePromptActions({
activeSessionId,
activeSessionIdRef,
branchCurrentSession: branchInNewChat,
busyRef,
createBackendSessionForSend,
getRouteToken,
handleSkinCommand,
openMemoryGraph: openStarmap,
refreshSessions,
requestGateway,
resumeStoredSession: resumeSession,
selectedStoredSessionIdRef,
startFreshSessionDraft,
sttEnabled,
updateSessionState
})
// Session-tile delegate (resume/submit/interrupt/slash + the session verbs
// the tile TAB menu needs, without touching the primary view).
useSessionTileDelegate({
archiveSession,
branchStoredSession,
executeSlashCommand,
removeSession,
requestGateway,
runtimeIdByStoredSessionIdRef,
sessionStateByRuntimeIdRef,
updateSessionState
})
// The popped-out pet overlay's bridge back into the app.
usePetBridge({ requestGateway, resumeSession, submitText })
// Clear a failed turn's red error banner. Errors are renderer-local (never
// persisted): a bare error placeholder is dropped entirely; a partial-output
// failure keeps its content and sheds the error. Both the runtime cache AND
// the live $messages view must be updated — preserveLocalAssistantErrors
// re-grafts any still-errored view message on the next session.info flush.
const dismissError = useCallback(
(messageId: string) => {
const runtimeSessionId = activeSessionIdRef.current
if (!runtimeSessionId) {
return
}
const clearErrorIn = (messages: ChatMessage[]): ChatMessage[] =>
messages.flatMap(message => {
if (message.id !== messageId || !message.error) {
return [message]
}
if (!chatMessageText(message).trim() && !message.parts.some(part => part.type !== 'text')) {
return []
}
return [{ ...message, error: undefined, pending: false }]
})
// View first: the cache update below triggers a re-sync that reads
// $messages as the error-preservation baseline.
setMessages(clearErrorIn($messages.get()))
updateSessionState(runtimeSessionId, state => ({
...state,
messages: clearErrorIn(state.messages)
}))
},
[activeSessionIdRef, updateSessionState]
)
useRouteResume({
activeSessionId,
activeSessionIdRef,
creatingSessionRef,
currentView,
freshDraftReady,
gatewayState,
locationPathname: location.pathname,
resumeSession,
resumeFailedSessionId,
resumeExhaustedSessionId,
routedSessionId,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId,
selectedStoredSessionIdRef,
startFreshSessionDraft
})
// Plugins hear the stream FIRST (isolated fan-out in contrib/events), then
// the app dispatches as before — a plugin listener can't affect app flow.
const handleGatewayEventWithPlugins = useCallback(
(event: Parameters<typeof handleDesktopGatewayEvent>[0]) => {
emitGatewayEvent(event)
handleDesktopGatewayEvent(event)
},
[handleDesktopGatewayEvent]
)
useGatewayBoot({
handleGatewayEvent: handleGatewayEventWithPlugins,
onConnectionReady: c => {
connectionRef.current = c
},
onGatewayReady: g => {
gatewayRef.current = g
},
refreshHermesConfig,
refreshSessions
})
// Only the open messaging transcript needs its own poll — local chats are
// live over the websocket already.
const activeIsMessaging =
!!selectedStoredSessionId &&
isMessagingSource(messagingSessions.find(s => sessionMatchesStoredId(s, selectedStoredSessionId))?.source)
// Keep app data live while the gateway is open (on-connect reseed + the
// cron / messaging / transcript visibility polls + fresh-draft reseed).
useBackgroundSync({
activeIsMessaging,
activeSessionId,
freshDraftReady,
gatewayState,
refreshActiveMessagingTranscript,
refreshCronJobs,
refreshCurrentModel,
refreshHermesConfig,
refreshMessagingSessions,
refreshSessions,
requestGateway
})
// Electron-main / OS / cross-window integrations: update polling, ⌘W close,
// deep links, native-notification nav, preview-shortcut enablement,
// remembered-session restore, and cross-window session-list sync.
const previewTarget = useStore($previewTarget)
const filePreviewTarget = useStore($filePreviewTarget)
useDesktopIntegrations({
chatOpen,
hasPreview: Boolean(filePreviewTarget || previewTarget),
locationPathname: location.pathname,
navigate,
refreshSessions,
resumeExhaustedSessionId,
routedSessionId,
runtimeIdByStoredSessionId: runtimeIdByStoredSessionIdRef
})
// Pin/unpin the selected session (statusbar keybind + chat header) — pinned
// on the durable lineage-root id so it survives auto-compression.
const toggleSelectedPin = useCallback(() => {
const sessionId = $selectedStoredSessionId.get()
if (!sessionId) {
return
}
const session = $sessions.get().find(s => sessionMatchesStoredId(s, sessionId))
const pinId = session ? sessionPinId(session) : sessionId
if ($pinnedSessionIds.get().includes(pinId)) {
unpinSession(pinId)
} else {
pinSession(pinId)
}
}, [])
// Single global listener for every rebindable hotkey plus the on-screen
// keybind editor's capture mode (same as DesktopController).
useKeybinds({
openNewSessionTab: () => void openNewSessionTile('center'),
startFreshSession: startFreshSessionDraft,
toggleCommandCenter,
toggleSelectedPin
})
// 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
// identity for the app's life (memoized surfaces don't re-render on churn)
// while every handler still closes over the latest values.
const nextActions: WiringActions = {
onAddContextRef: composer.addContextRefAttachment,
onAddUrl: url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url),
onArchiveSession: sessionId => void archiveSession(sessionId),
onAttachDroppedItems: composer.attachDroppedItems,
onAttachImageBlob: composer.attachImageBlob,
onBranchInNewChat: messageId => void branchInNewChat(messageId),
onBranchSession: sessionId => void branchStoredSession(sessionId),
onCancel: cancelRun,
onDeleteSelectedSession: () => {
const id = $selectedStoredSessionId.get()
if (id) {
void removeSession(id)
}
},
onDeleteSession: sessionId => void removeSession(sessionId),
onDismissError: dismissError,
onEdit: editMessage,
onLoadMoreMessaging: loadMoreMessagingForPlatform,
onLoadMoreProfileSessions: loadMoreSessionsForProfile,
onLoadMoreSessions: loadMoreSessions,
onManageCronJob: jobId => {
setCronFocusJobId(jobId)
navigate(CRON_ROUTE)
},
onNavigate: selectSidebarItem,
onNewSessionInWorkspace: startSessionInWorkspace,
onNewSessionSplit: dir => void openNewSessionTile(dir),
onPasteClipboardImage: opts => composer.pasteClipboardImage(opts),
onPickFiles: () => void composer.pickContextPaths('file'),
onPickFolders: () => void composer.pickContextPaths('folder'),
onPickImages: () => void composer.pickImages(),
onReload: reloadFromMessage,
onRemoveAttachment: id => void composer.removeAttachment(id),
onRestoreToMessage: restoreToMessage,
// Already on screen (open tile, or the main session)? Jump to its tab;
// otherwise load it into main.
onResumeSession: sessionId => {
if (!focusOpenSession(sessionId)) {
navigate(sessionRoute(sessionId))
}
},
onRetryResume: sessionId => void resumeSession(sessionId, true),
onSteer: steerPrompt,
onSubmit: submitText,
onThreadMessagesChange: handleThreadMessagesChange,
onToggleSelectedPin: toggleSelectedPin,
onTranscribeAudio: transcribeVoiceAudio,
onTriggerCronJob: jobId => {
void triggerCronJob(jobId)
.then(() => refreshCronJobs())
.catch(() => undefined)
},
getGateway: () => gatewayRef.current,
openAgents,
openCommandCenterSection,
requestGateway,
selectModel,
toggleCommandCenter
}
if (actionsRef.current) {
Object.assign(actionsRef.current, nextActions)
} else {
actionsRef.current = nextActions
}
const actions = actionsRef.current
// Each pane node is memoized on ONLY the reactive inputs it truly consumes;
// everything else reaches its surface through `actions` (stable) or the
// surface's own atom subscriptions. A wiring tick that doesn't touch a
// node's keys leaves its element reference intact, so `WiredPane` (memoized)
// bails on that pane subtree — panes render independently of one another.
const sidebarNode = useMemo(
() => <SidebarSurface actions={actions} currentView={currentView} />,
[actions, currentView]
)
const terminalNode = useMemo(() => <TerminalSurface />, [])
const statusbarNode = useMemo(
() => (
<StatusbarSurface
actions={actions}
agentsOpen={agentsOpen}
chatOpen={chatOpen}
commandCenterOpen={commandCenterOpen}
/>
),
[actions, agentsOpen, chatOpen, commandCenterOpen]
)
// The voice cap changes only on config load; the gateway instance + all
// chat reactivity are subscribed inside ChatRoutesSurface / ChatView.
const chatRoutesNode = useMemo(
() => <ChatRoutesSurface actions={actions} maxVoiceRecordingSeconds={voiceMaxRecordingSeconds} />,
[actions, voiceMaxRecordingSeconds]
)
const api = useMemo<WiringApi>(
() => ({
chatRoutes: chatRoutesNode,
sidebar: sidebarNode,
statusbar: statusbarNode,
terminal: terminalNode
}),
[chatRoutesNode, sidebarNode, statusbarNode, terminalNode]
)
// The REAL titlebar tool clusters (sidebar/flip toggles, haptics, keybinds,
// settings gear) — fixed chrome positioned via the same CSS vars AppShell
// sets, computed here from the live connection. Page-registered tools
// (preview's monitor/devtools cluster, …) arrive as registry contributions.
const leftTitlebarTools = useTitlebarToolContributions('left')
const rightTitlebarTools = useTitlebarToolContributions('right')
const connection = useStore($connection)
const controlsPos = titlebarControlsPosition(connection?.windowButtonPosition, Boolean(connection?.isFullscreen))
// Exact vertical centering: titlebarControlsPosition() returns
// (TITLEBAR_HEIGHT - TITLEBAR_CONTROL_HEIGHT) / 2, but TitlebarControls
// also applies a hard translate-y-0.5 (+2px) to its clusters. Cancel that
// constant so cluster center == bar center — measured, not eyeballed.
const controlsTranslateY = 2
// Windows/WSLg reserve native min/max/close on the right (AppShell parity:
// prefer the live WCO measurement, fall back to the static reservation).
const measuredOverlayWidth = useWindowControlsOverlayWidth()
const nativeOverlayWidth = measuredOverlayWidth ?? connection?.nativeOverlayWidth ?? 0
const titlebarToolsRight = nativeOverlayWidth > 0 ? `${nativeOverlayWidth}px` : '0.75rem'
// Pane-registered tools (preview's monitor/devtools cluster) anchor flush
// against the static system cluster — in the tree layout the titlebar band
// sits ABOVE the grid, so AppShell's pane-width anchoring doesn't apply.
const SYSTEM_TOOL_COUNT = 4
const paneToolCount = rightTitlebarTools.filter(tool => !tool.hidden).length
const systemToolsWidth = `calc(${SYSTEM_TOOL_COUNT} * (var(--titlebar-control-size) + 0.25rem))`
const titlebarToolsWidth =
paneToolCount > 0
? `calc(${systemToolsWidth} + ${paneToolCount} * (var(--titlebar-control-size) + 0.25rem))`
: systemToolsWidth
return (
<ContribWiringContext.Provider value={api}>
<div
className="contents"
style={
{
'--titlebar-controls-left': `${controlsPos.left}px`,
'--titlebar-controls-top': `${controlsPos.top - controlsTranslateY}px`,
'--titlebar-tools-right': titlebarToolsRight,
'--titlebar-tools-width': titlebarToolsWidth,
'--shell-preview-toolbar-gap': systemToolsWidth
} as CSSProperties
}
>
<TitlebarControls
leftTools={leftTitlebarTools}
onOpenSettings={() => navigate(SETTINGS_ROUTE)}
tools={rightTitlebarTools}
/>
{children}
</div>
{/* The full real overlay set (mirrors DesktopController's `overlays`). */}
<RemoteDisplayBanner />
{!isSecondaryWindow() && <DesktopInstallOverlay />}
{!isSecondaryWindow() && (
<DesktopOnboardingOverlay
enabled={gatewayState === 'open'}
onCompleted={() => {
void refreshHermesConfig()
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
}}
requestGateway={requestGateway}
/>
)}
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
<SessionPickerOverlay onResume={resumeSession} />
<ModelVisibilityOverlay gateway={gatewayRef.current || undefined} onOpenProviders={openProviderSettings} />
<UpdatesOverlay />
<GatewayConnectingOverlay />
<BootFailureOverlay />
<CommandPalette />
<PetGenerateOverlay />
<SessionSwitcher />
<FileActionDialogs />
<RemoteFolderPicker />
{settingsOpen && (
<Suspense fallback={null}>
<SettingsView
gateway={gatewayRef.current}
onClose={closeOverlayToPreviousRoute}
onConfigSaved={() => {
void refreshHermesConfig()
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
}}
onMainModelChanged={(provider, model) => {
setCurrentProvider(provider)
setCurrentModel(model)
updateModelOptionsCache(provider, model, true)
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
}}
/>
</Suspense>
)}
{commandCenterOpen && (
<Suspense fallback={null}>
<CommandCenterView
initialSection={commandCenterInitialSection}
onClose={closeOverlayToPreviousRoute}
onDeleteSession={removeSession}
onNavigateRoute={path => navigate(path)}
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
/>
</Suspense>
)}
{agentsOpen && (
<Suspense fallback={null}>
<AgentsView onClose={closeOverlayToPreviousRoute} />
</Suspense>
)}
{cronOpen && (
<Suspense fallback={null}>
<CronView
onClose={closeOverlayToPreviousRoute}
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
/>
</Suspense>
)}
{profilesOpen && (
<Suspense fallback={null}>
<ProfilesView onClose={closeOverlayToPreviousRoute} />
</Suspense>
)}
{starmapOpen && (
<Suspense fallback={null}>
<StarmapView onClose={closeOverlayToPreviousRoute} />
</Suspense>
)}
{/* The full hotkey map (⌘/ and the titlebar keyboard button). */}
<KeybindPanel />
{/* Toasts above everything. */}
<NotificationStack />
{/* Petdex floating mascot — renders nothing unless installed + enabled. */}
<FloatingPet />
{/* Single persistent xterm host chasing the terminal pane's slot rect. */}
<PersistentTerminal onAddSelectionToChat={composer.addTerminalSelectionAttachment} />
</ContribWiringContext.Provider>
)
}
+3 -1
View File
@@ -54,8 +54,8 @@ import {
} from '../overlays/panel'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { jobState, jobTitle, STATE_DOT } from './job-state'
import { cronEditorUpdates, jobIsScriptOnly, validateCronEditor } from './cron-job-model'
import { jobState, jobTitle, STATE_DOT } from './job-state'
const DEFAULT_DELIVER = 'local'
@@ -398,6 +398,7 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
notify({ kind: 'success', title: c.created, message: truncate(jobTitle(created), 60) })
} else if (editor.mode === 'edit') {
const scriptOnlyJob = jobIsScriptOnly(editor.job)
const updated = await updateCronJob(
editor.job.id,
cronEditorUpdates(values, { scriptOnlyJob })
@@ -756,6 +757,7 @@ function CronEditorDialog({
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
const validationError = validateCronEditor({
prompt,
schedule,
@@ -1,26 +0,0 @@
import type { SessionInfo } from '@/hermes'
// Cheap signature compare so a poll only swaps the atom (and re-renders the
// sidebar) when the visible rows actually changed.
export function sameCronSignature(a: SessionInfo[], b: SessionInfo[]): boolean {
if (a.length !== b.length) {
return false
}
return a.every((session, i) => {
const other = b[i]
return (
other != null &&
session.id === other.id &&
session._lineage_root_id === other._lineage_root_id &&
session.title === other.title &&
session.source === other.source &&
session.profile === other.profile &&
session.preview === other.preview &&
session.message_count === other.message_count &&
session.last_active === other.last_active &&
session.ended_at === other.ended_at
)
})
}
File diff suppressed because it is too large Load Diff
@@ -39,6 +39,7 @@ import {
setCurrentCwd,
setSessionsLoading
} from '@/store/session'
import { resetTileRuntimeBindings } from '@/store/session-states'
import type { RpcEvent } from '@/types/hermes'
// After this many consecutive failed reconnects (≈45s with the 1→15s backoff)
@@ -167,6 +168,9 @@ export function useGatewayBoot({
}
reconnectAttempt = 0
// A respawned backend re-mints (recycles) runtime ids, so any tile's
// bound runtime id is now stale — drop them so each tile re-resumes.
resetTileRuntimeBindings()
// Resync state that may have moved on the backend while we were asleep.
await callbacksRef.current.refreshHermesConfig().catch(() => undefined)
await callbacksRef.current.refreshSessions().catch(() => undefined)
+34 -24
View File
@@ -1,18 +1,16 @@
import { useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
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 { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell'
import { matchesQuery } from '@/hooks/use-media-query'
import { PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } from '@/lib/keybinds/actions'
import { activateTreeTabSlot, cycleTreeTabInFocusedZone, layoutHasRootSide } from '@/components/pane-shell/tree/store'
import { contributedKeybindHandler, PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } from '@/lib/keybinds/actions'
import { comboAllowedInInput, comboFromEvent, isEditableTarget } from '@/lib/keybinds/combo'
import { $repoStatus } from '@/store/coding-status'
import { toggleCommandPalette } from '@/store/command-palette'
import { $capture, $comboIndex, endCapture, setBinding, toggleKeybindPanel } from '@/store/keybinds'
import {
CHAT_SIDEBAR_PANE_ID,
FILE_BROWSER_PANE_ID,
requestSessionSearchFocus,
setFileBrowserOpen,
toggleFileBrowserOpen,
@@ -30,6 +28,7 @@ import {
import { requestNewWorktree } from '@/store/projects'
import { toggleReview } from '@/store/review'
import { setModelPickerOpen } from '@/store/session'
import { reopenLastClosedTile } from '@/store/session-states'
import {
$switcherOpen,
closeSwitcher,
@@ -45,7 +44,6 @@ import { openNewSessionInNewWindow } from '@/store/windows'
import { useTheme } from '@/themes/context'
import { requestComposerFocus, requestVoiceToggle } from '../chat/composer/focus'
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
import {
AGENTS_ROUTE,
ARTIFACTS_ROUTE,
@@ -62,6 +60,8 @@ export interface KeybindRuntimeDeps {
toggleCommandCenter: () => void
/** Drop to a fresh new-session draft. */
startFreshSession: () => void
/** Open a fresh session as a tab in the main zone (⌘T), leaving the primary. */
openNewSessionTab: () => void
/** Pin/unpin the active session. */
toggleSelectedPin: () => void
}
@@ -82,7 +82,13 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
const profileSwitchHandlers: HandlerMap = {}
for (let slot = 1; slot <= PROFILE_SLOT_COUNT; slot += 1) {
profileSwitchHandlers[`profile.switch.${slot}`] = () => switchProfileToSlot(slot)
// ⌘1…⌘9 switch the FOCUSED zone's tab when it's a real tab strip; only a
// single-pane (or unfocused) layout falls through to the profile switch.
profileSwitchHandlers[`profile.switch.${slot}`] = () => {
if (!activateTreeTabSlot(slot)) {
switchProfileToSlot(slot)
}
}
}
const goToSession = (sessionId: null | string) => {
@@ -138,9 +144,12 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
deps.startFreshSession()
window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut'))
},
'session.newTab': () => deps.openNewSessionTab(),
'session.newWindow': () => void openNewSessionInNewWindow(),
'session.next': () => stepSession(1),
'session.prev': () => stepSession(-1),
// ⌃Tab cycles the focused session/main tab strip; only a non-tabbed focus
// falls through to the recent-session switcher.
'session.next': () => void (cycleTreeTabInFocusedZone(1) || stepSession(1)),
'session.prev': () => void (cycleTreeTabInFocusedZone(-1) || stepSession(-1)),
...sessionSlotHandlers,
'session.focusSearch': requestSessionSearchFocus,
'session.togglePin': deps.toggleSelectedPin,
@@ -148,20 +157,13 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
// through instead of silently doing nothing).
'workspace.newWorktree': () => $repoStatus.get() && requestNewWorktree(),
'view.toggleSidebar': () => {
if (matchesQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)) {
window.dispatchEvent(new CustomEvent(PANE_TOGGLE_REVEAL_EVENT, { detail: { id: CHAT_SIDEBAR_PANE_ID } }))
} else {
toggleSidebarOpen()
}
},
'view.toggleRightSidebar': () => {
if (matchesQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)) {
window.dispatchEvent(new CustomEvent(PANE_TOGGLE_REVEAL_EVENT, { detail: { id: FILE_BROWSER_PANE_ID } }))
} else {
toggleFileBrowserOpen()
}
},
// Narrow-viewport reveal is handled inside the store toggles now.
'view.toggleSidebar': toggleSidebarOpen,
// ⌘J toggles the right sidebar — but a layout with no right side (e.g.
// terminal-on-bottom) would leave it a dead key, so it falls back to the
// terminal there. The single "secondary panel" toggle.
'view.toggleRightSidebar': () =>
layoutHasRootSide('right') ? toggleFileBrowserOpen() : setTerminalTakeover(!$terminalTakeover.get()),
'view.toggleReview': toggleReview,
'view.showFiles': showFiles,
'view.showTerminal': () => setTerminalTakeover(!$terminalTakeover.get()),
@@ -177,6 +179,12 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
'view.prevTerminal': () => $terminalTakeover.get() && cycleTerminal(-1),
'view.closeTerminal': () => $terminalTakeover.get() && closeActiveTerminal(),
'view.flipPanes': togglePanesFlipped,
// ⌘W: close the focused tab (terminal / preview target / zone tree tab).
// On macOS the menu accelerator owns ⌘W and routes through the same
// closeActiveTab via IPC (see use-desktop-integrations); this binding is
// the Win/Linux path where ⌘W reaches the renderer directly.
'view.closeTab': () => void closeActiveTab(),
'view.reopenTab': reopenLastClosedTile,
'appearance.toggleMode': () => setMode(resolvedMode === 'dark' ? 'light' : 'dark'),
@@ -242,7 +250,9 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
return
}
const handler = handlersRef.current[actionId]
// Built-in handlers first (they carry React context); contributed
// actions bring their own `run` through the registry.
const handler = handlersRef.current[actionId] ?? contributedKeybindHandler(actionId)
if (!handler) {
return
+6 -1
View File
@@ -1 +1,6 @@
export { DesktopController as default } from './desktop-controller'
// The app root is the contribution-driven shell: panes, titlebar/statusbar
// items, keybinds, palette commands, routes, and themes all register through
// the contribution registry (src/contrib) — core surfaces use the same calls
// plugins do. Everything lives under ./contrib: the wiring (gateway boot,
// sessions, streams) + pane surfaces, and the pane/layout registration.
export { ContribController as default } from './contrib'
+16 -3
View File
@@ -1,8 +1,10 @@
import { type ReactNode, useEffect } from 'react'
import { type CSSProperties, type ReactNode, useEffect } from 'react'
import { TITLEBAR_HEIGHT } from '@/app/shell/titlebar'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { translateNow } from '@/i18n'
import { ESCAPE_PRIORITY, isTopEscapeLayer, pushEscapeLayer } from '@/lib/escape-layers'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
@@ -32,8 +34,10 @@ export function OverlayView({
// stop propagation themselves, so opening (e.g.) the model picker inside
// Settings still closes the picker first instead of the underlying overlay.
useEffect(() => {
const releaseLayer = pushEscapeLayer(ESCAPE_PRIORITY.overlay)
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape' || event.defaultPrevented) {
if (event.key !== 'Escape' || event.defaultPrevented || !isTopEscapeLayer(ESCAPE_PRIORITY.overlay)) {
return
}
@@ -44,7 +48,10 @@ export function OverlayView({
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
return () => {
window.removeEventListener('keydown', onKeyDown)
releaseLayer()
}
}, [onClose])
return (
@@ -64,6 +71,12 @@ export function OverlayView({
}
}}
role="presentation"
// Window-level chrome: overlays always clear the real titlebar. The
// contrib shell zeroes --titlebar-height for CONTENT areas (panes sit
// below its in-flow title bar), and CSS vars inherit through the DOM —
// so a fixed overlay mounted inside a zone would read 0 and bleed to
// the edges. Re-pin the real height at the overlay root.
style={{ '--titlebar-height': `${TITLEBAR_HEIGHT}px` } as CSSProperties}
>
<div
className={cn(
@@ -218,6 +218,7 @@ function ReviewDirRow({
<span className="min-w-0 flex-1 truncate" title={node.name}>
{node.name}
</span>
{!open && <DiffCount added={node.added} className="text-[0.64rem] leading-4" removed={node.removed} />}
</div>
{open && node.children && (
<ReviewNodeList animate={animate} depth={depth + 1} motion={useMotion} nodes={node.children} />
@@ -84,7 +84,9 @@ export function ReviewPane() {
{(loading || isRepo) && (
<RightSidebarSectionHeader data-suppress-pane-reveal-side="">
<div className="flex min-w-0 flex-1">
<SidebarPanelLabel>{c.review}</SidebarPanelLabel>
{/* Pure self-naming label — redundant under a zone tab that already
says "review", so the zone header hides it (styles.css). */}
<SidebarPanelLabel data-pane-self-label="">{c.review}</SidebarPanelLabel>
</div>
<Tip label={treeMode === 'tree' ? c.viewAsList : c.viewAsTree}>
<Button
+77 -1
View File
@@ -1,3 +1,8 @@
import { atom } from 'nanostores'
import type { ReactNode } from 'react'
import { registry } from '@/contrib/registry'
export const SESSION_ROUTE_PREFIX = '/'
export const NEW_CHAT_ROUTE = '/'
export const SETTINGS_ROUTE = '/settings'
@@ -16,6 +21,11 @@ export type AppView =
| 'chat'
| 'command-center'
| 'cron'
// A contributed (plugin) full page at its own route — NOT chat. Without this
// distinction contributed paths fell through appViewForPath's 'chat' default,
// so the sidebar kept a session highlighted and the titlebar kept the
// session-title dropdown while a plugin page was showing.
| 'extension'
| 'messaging'
| 'profiles'
| 'settings'
@@ -56,6 +66,52 @@ export const APP_ROUTES = [
const APP_VIEW_BY_PATH = new Map<string, AppView>(APP_ROUTES.map(route => [route.path, route.view]))
const RESERVED_PATHS: ReadonlySet<string> = new Set(APP_ROUTES.map(route => route.path))
// ── Contributed routes — the `routes` registry area ─────────────────────────
// A contribution mounts a FULL PAGE in the workspace pane at `data.path`
// (`render` on the contribution itself, like every other area). Contributed
// paths are reserved exactly like APP_ROUTES so the session-id parser never
// mistakes them for a session route. Navigate with `host.navigate(path)`.
export const ROUTES_AREA = 'routes'
/** Payload of a `routes` contribution's `data`. */
export interface RouteContribution {
/** Absolute path, e.g. `/kanban`. One segment; no params. */
path: string
}
export function contributedRoutes(): Array<{ key: string; path: string; title?: string; render: () => ReactNode }> {
return registry
.getArea(ROUTES_AREA)
.map(c => ({
key: `${c.source ?? 'core'}:${c.id}`,
path: (c.data as RouteContribution | undefined)?.path ?? '',
title: c.title,
render: c.render!
}))
.filter(route => Boolean(route.path.startsWith('/') && route.render) && !RESERVED_PATHS.has(route.path))
}
function isContributedPath(pathname: string): boolean {
return contributedRoutes().some(route => route.path === pathname)
}
// ── Contributed sidebar nav — the `sidebar.nav` registry area ────────────────
// A DATA contribution adds a row to the sidebar's top nav (below Artifacts).
// Pair with a ROUTES_AREA page: the row navigates to `path` and lights up
// while the app is there.
export const SIDEBAR_NAV_AREA = 'sidebar.nav'
/** Payload of a `sidebar.nav` data contribution. */
export interface SidebarNavContribution {
/** Codicon name, e.g. `'project'`. */
codicon: string
label: string
/** Route to navigate to (usually a contributed page's path). */
path: string
}
// Views that render as a full-screen modal card (OverlayView) over the shell.
// While one is open the app's titlebar control clusters must hide so they don't
// bleed over the overlay (they sit at a higher z-index than the overlay card).
@@ -77,7 +133,7 @@ export function isNewChatRoute(pathname: string): boolean {
}
export function routeSessionId(pathname: string): string | null {
if (!pathname.startsWith(SESSION_ROUTE_PREFIX) || RESERVED_PATHS.has(pathname)) {
if (!pathname.startsWith(SESSION_ROUTE_PREFIX) || RESERVED_PATHS.has(pathname) || isContributedPath(pathname)) {
return null
}
@@ -95,5 +151,25 @@ export function appViewForPath(pathname: string): AppView {
return 'chat'
}
if (isContributedPath(pathname)) {
return 'extension'
}
return APP_VIEW_BY_PATH.get(pathname) ?? 'chat'
}
/** True while the workspace pane shows a FULL PAGE (skills/messaging/
* artifacts/plugin routes) instead of the chat. Published by the wiring
* (which owns the router location); the workspace pane contribution mirrors
* it as `headerVeto` so the zone tab bar stands down on pages. Overlays
* (settings/…) don't count — the chat stays beneath them. */
export const $workspaceIsPage = atom(false)
export function syncWorkspaceIsPage(pathname: string): void {
const view = appViewForPath(pathname)
const isPage = view !== 'chat' && !isOverlayView(view)
if (isPage !== $workspaceIsPage.get()) {
$workspaceIsPage.set(isPage)
}
}
+12 -4
View File
@@ -5,7 +5,7 @@ import { useNavigate } from 'react-router-dom'
import { sessionTitle } from '@/lib/chat-runtime'
import { cn } from '@/lib/utils'
import { $attentionSessionIds, $workingSessionIds } from '@/store/session'
import { $attentionSessionIds, $unreadFinishedSessionIds, $workingSessionIds } from '@/store/session'
import { $switcherIndex, $switcherOpen, $switcherSessions, closeSwitcher } from '@/store/session-switcher'
import { HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from './floating-hud'
@@ -19,6 +19,7 @@ export function SessionSwitcher() {
const index = useStore($switcherIndex)
const working = useStore($workingSessionIds)
const attention = useStore($attentionSessionIds)
const unread = useStore($unreadFinishedSessionIds)
const navigate = useNavigate()
const activeRef = useRef<HTMLDivElement>(null)
@@ -33,6 +34,7 @@ export function SessionSwitcher() {
const workingIds = new Set(working)
const attentionIds = new Set(attention)
const unreadIds = new Set(unread)
const pick = (sessionId: string) => {
closeSwitcher()
@@ -74,7 +76,7 @@ export function SessionSwitcher() {
}}
ref={selected ? activeRef : undefined}
>
<SwitcherDot attention={attentionIds.has(session.id)} working={workingIds.has(session.id)} />
<SwitcherDot attention={attentionIds.has(session.id)} working={workingIds.has(session.id)} unread={unreadIds.has(session.id)} />
<span className="min-w-0 flex-1 truncate">{sessionTitle(session)}</span>
{i < 9 && (
<span
@@ -95,12 +97,18 @@ export function SessionSwitcher() {
)
}
function SwitcherDot({ attention, working }: { attention: boolean; working: boolean }) {
function SwitcherDot({ attention, working, unread }: { attention: boolean; working: boolean; unread: boolean }) {
return (
<span
className={cn(
'size-1 shrink-0 rounded-full',
attention ? 'bg-amber-400' : working ? 'animate-pulse bg-(--ui-accent)' : 'bg-(--ui-text-quaternary)/50'
attention
? 'bg-amber-400'
: working
? 'animate-pulse bg-(--ui-accent)'
: unread
? 'bg-emerald-500'
: 'bg-(--ui-text-quaternary)/50'
)}
/>
)
@@ -26,6 +26,7 @@ import { followActiveSessionCwd } from '@/store/projects'
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
import {
$currentCwd,
sessionMatchesStoredId,
setCurrentBranch,
setCurrentCwd,
setCurrentFastMode,
@@ -405,7 +406,17 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
}
if (payload?.usage) {
setCurrentUsage(current => ({ ...current, ...payload.usage }))
// Per-session twin FIRST (the statusbar reads it for focused tiles);
// the primary-only global mirrors the ACTIVE session — ungated it
// let a background tile's turn overwrite the primary's count.
updateSessionState(sessionId, state => ({
...state,
usage: { calls: 0, input: 0, output: 0, total: 0, ...state.usage, ...payload.usage }
}))
if (isActiveEvent) {
setCurrentUsage(current => ({ ...current, ...payload.usage }))
}
}
} else if (event.type === 'session.title') {
// Live auto-title push (titler runs async, after the turn's refresh).
@@ -413,9 +424,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
const nextTitle = typeof payload?.title === 'string' ? payload.title.trim() : ''
if (storedId && nextTitle) {
setSessions(prev =>
prev.map(s => (s.id === storedId || s._lineage_root_id === storedId ? { ...s, title: nextTitle } : s))
)
setSessions(prev => prev.map(s => (sessionMatchesStoredId(s, storedId) ? { ...s, title: nextTitle } : s)))
}
} else if (event.type === 'tool.start' || event.type === 'tool.progress' || event.type === 'tool.generating') {
if (!sessionId) {
@@ -1453,6 +1453,7 @@ describe('usePromptActions submit session-context isolation (#54527)', () => {
it('aborts recovery submit when the user switches sessions during timeout resume', async () => {
const calls: { method: string; params?: Record<string, unknown> }[] = []
let submitAttempts = 0
let releaseResume: () => void = () => {}
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: STORED_SESSION_A }
@@ -5,8 +5,9 @@ import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS, transcribeAudio } from '@/hermes'
import { useI18n } from '@/i18n'
import { stripAnsi } from '@/lib/ansi'
import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
import { type ChatMessage, textPart } from '@/lib/chat-messages'
import { pathLabel, SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
import { triggerHaptic } from '@/lib/haptics'
import { setMutableRef } from '@/lib/mutable-ref'
import { normalize } from '@/lib/text'
@@ -43,23 +44,28 @@ import type {
SessionSteerResponse
} from '../../../types'
import {
applyBranchVisibility,
applyReloadOptimistic,
applyRewindOptimistic,
finalizeInterruptedMessages,
planEdit,
planReload,
planRestore,
runRewindSubmit
} from './rewind'
import { useSlashCommand } from './slash'
import { useSubmitPrompt } from './submit'
import {
appendText,
blobToDataUrl,
delay,
friendlyRemoteAttachError,
type GatewayRequest,
inlineErrorMessage,
isSessionBusyError,
isSessionNotFoundError,
readFileDataUrlForAttach,
readImageForRemoteAttach,
type SubmitTextOptions,
visibleUserIndexAtOrdinal,
visibleUserOrdinal,
withSessionBusyRetry
type SubmitTextOptions
} from './utils'
interface HandoffResult {
@@ -402,6 +408,13 @@ export function usePromptActions({
return { error: inlineErrorMessage(err, copy.handoff.failed(target)), ok: false }
}
const markCompleted = (): HandoffResult => {
appendSessionTextMessage(sid, 'system', copy.handoff.systemNote(target))
notify({ kind: 'success', message: copy.handoff.success(target) })
return { ok: true }
}
const deadline = Date.now() + 60_000
let lastState = 'pending'
@@ -424,10 +437,7 @@ export function usePromptActions({
}
if (state === 'completed') {
appendSessionTextMessage(sid, 'system', copy.handoff.systemNote(target))
notify({ kind: 'success', message: copy.handoff.success(target) })
return { ok: true }
return markCompleted()
}
if (state === 'failed') {
@@ -441,10 +451,7 @@ export function usePromptActions({
}).catch(() => null)
if (cleanup?.state === 'completed') {
appendSessionTextMessage(sid, 'system', copy.handoff.systemNote(target))
notify({ kind: 'success', message: copy.handoff.success(target) })
return { ok: true }
return markCompleted()
}
return { error: copy.handoff.timedOut, ok: false }
@@ -471,7 +478,7 @@ export function usePromptActions({
const submitText = useCallback(
async (rawText: string, options?: SubmitTextOptions) => {
const visibleText = rawText.trim()
const visibleText = sanitizeComposerInput(rawText).trim()
const attachments = options?.attachments ?? $composerAttachments.get()
if (!attachments.length && SLASH_COMMAND_RE.test(visibleText)) {
@@ -511,21 +518,16 @@ export function usePromptActions({
setAwaitingResponse(false)
setTurnStartedAt(null)
const finalizeMessages = (messages: ChatMessage[], streamId?: string | null) =>
messages
.filter(message => !((message.pending || message.id === streamId) && !chatMessageText(message).trim()))
.map(message => (message.pending || message.id === streamId ? { ...message, pending: false } : message))
if (!sessionId) {
releaseBusy()
setMessages(finalizeMessages($messages.get()))
setMessages(finalizeInterruptedMessages($messages.get()))
return
}
updateSessionState(sessionId, state => {
const streamId = state.streamId
const messages = finalizeMessages(state.messages, streamId)
const messages = finalizeInterruptedMessages(state.messages, streamId)
return {
...state,
@@ -596,7 +598,7 @@ export function usePromptActions({
// window) so the caller can fall back to queueing the words for the next turn.
const steerPrompt = useCallback(
async (rawText: string): Promise<boolean> => {
const text = rawText.trim()
const text = sanitizeComposerInput(rawText).trim()
const sessionId = activeSessionId || activeSessionIdRef.current
if (!text || !sessionId) {
@@ -630,66 +632,19 @@ export function usePromptActions({
return
}
const messages = $messages.get()
const parentIndex = parentId ? messages.findIndex(message => message.id === parentId) : messages.length - 1
const plan = planReload($messages.get(), parentId)
const userIndex =
parentIndex >= 0
? [...messages.slice(0, parentIndex + 1)].reverse().findIndex(message => message.role === 'user')
: -1
if (userIndex < 0) {
if (!plan) {
return
}
const absoluteUserIndex = parentIndex - userIndex
const userMessage = messages[absoluteUserIndex]
const userText = userMessage ? chatMessageText(userMessage).trim() : ''
if (!userText) {
return
}
const targetAssistant =
parentId && messages[parentIndex]?.role === 'assistant'
? messages[parentIndex]
: messages.slice(absoluteUserIndex + 1).find(message => message.role === 'assistant')
const branchGroupId = targetAssistant?.branchGroupId ?? branchGroupForUser(userMessage)
const truncateBeforeUserOrdinal = visibleUserOrdinal(messages, absoluteUserIndex)
clearNotifications()
updateSessionState(activeSessionId, state => {
const nextUserIndex = state.messages.findIndex(
(message, index) => index > absoluteUserIndex && message.role === 'user'
)
const end = nextUserIndex < 0 ? state.messages.length : nextUserIndex
return {
...state,
busy: true,
awaitingResponse: true,
pendingBranchGroup: branchGroupId,
sawAssistantPayload: false,
interrupted: false,
messages: [
...state.messages.slice(0, absoluteUserIndex + 1),
...state.messages
.slice(absoluteUserIndex + 1, end)
.map(message => (message.role === 'assistant' ? { ...message, branchGroupId, hidden: true } : message))
]
}
})
updateSessionState(activeSessionId, state => applyReloadOptimistic(state, plan))
try {
await requestGateway(
'prompt.submit',
{
session_id: activeSessionId,
text: userText,
truncate_before_user_ordinal: truncateBeforeUserOrdinal
},
{ session_id: activeSessionId, text: plan.text, truncate_before_user_ordinal: plan.truncateOrdinal },
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS
)
} catch (err) {
@@ -714,41 +669,8 @@ export function usePromptActions({
// fresh turn. Live/stuck turns interrupt first, and a raced "session busy"
// response interrupts + retries through the shared busy gate.
const submitRewindPrompt = useCallback(
async (sessionId: string, text: string, truncateOrdinal: number | undefined, interruptFirst: boolean) => {
const interrupt = async () => {
try {
await requestGateway('session.interrupt', { session_id: sessionId })
} catch {
// Best-effort. The submit path still gates on the gateway state.
}
}
const submit = () =>
requestGateway(
'prompt.submit',
{
session_id: sessionId,
text,
...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal })
},
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS
)
if (interruptFirst) {
await interrupt()
}
try {
await submit()
} catch (err) {
if (!isSessionBusyError(err)) {
throw err
}
await interrupt()
await withSessionBusyRetry(submit)
}
},
(sessionId: string, text: string, truncateOrdinal: number | undefined, interruptFirst: boolean) =>
runRewindSubmit(requestGateway, sessionId, text, truncateOrdinal, interruptFirst),
[requestGateway]
)
@@ -761,30 +683,7 @@ export function usePromptActions({
}
const messages = $messages.get()
const idIndex = messages.findIndex(m => m.id === messageId && m.role === 'user')
const fallbackIndex =
target?.userOrdinal === null || target?.userOrdinal === undefined
? -1
: visibleUserIndexAtOrdinal(messages, target.userOrdinal)
const sourceIndex = idIndex >= 0 ? idIndex : fallbackIndex
const source = messages[sourceIndex]
if (!source || source.role !== 'user') {
throw new Error('Could not find the message to restore.')
}
const text = (chatMessageText(source).trim() || target?.text?.trim() || '').trim()
if (!text) {
throw new Error('Cannot restore an empty message.')
}
const truncateBeforeUserOrdinal =
target?.userOrdinal === null || target?.userOrdinal === undefined
? visibleUserOrdinal(messages, sourceIndex)
: target.userOrdinal
const plan = planRestore(messages, messageId, target)
// The turns we're discarding may have spawned todos and background
// processes; they belong to the abandoned timeline, so wipe their status
@@ -797,18 +696,10 @@ export function usePromptActions({
setMutableRef(busyRef, true)
setBusy(true)
setAwaitingResponse(true)
updateSessionState(sessionId, state => ({
...state,
busy: true,
awaitingResponse: true,
pendingBranchGroup: null,
sawAssistantPayload: false,
interrupted: false,
messages: state.messages.slice(0, sourceIndex + 1)
}))
updateSessionState(sessionId, state => applyRewindOptimistic(state, plan.sourceIndex))
try {
await submitRewindPrompt(sessionId, text, truncateBeforeUserOrdinal, busyRef.current || $busy.get())
await submitRewindPrompt(sessionId, plan.text, plan.truncateOrdinal, busyRef.current || $busy.get())
} catch (err) {
// The rewind never landed (e.g. the gateway stayed busy past the retry
// deadline). Roll the optimistic truncation back to the full original
@@ -832,34 +723,17 @@ export function usePromptActions({
const editMessage = useCallback(
async (edited: AppendMessage) => {
const sessionId = activeSessionId || activeSessionIdRef.current
const sourceId = edited.sourceId || edited.parentId
const text = appendText(edited)
if (!sessionId || !sourceId || !text || edited.role !== 'user') {
return
}
const messages = $messages.get()
const sourceIndex = messages.findIndex(m => m.id === sourceId)
const source = messages[sourceIndex]
const plan = sessionId ? planEdit(messages, edited) : null
if (!source || source.role !== 'user' || chatMessageText(source).trim() === text) {
if (!sessionId || !plan) {
return
}
// Sending an edit is a revert: rewind to this prompt and re-run with the
// new text. It can fire mid-turn; submitRewindPrompt always interrupts
// first, so a live turn is wound down before the resubmit.
// Failed turn: optimistic user msg never reached the gateway, so truncating
// by ordinal would 422. Submit as a plain resend instead.
const nextMessage = messages[sourceIndex + 1]
const isFailedTurn = nextMessage?.role === 'assistant' && Boolean(nextMessage.error)
const editedMessage: ChatMessage = { ...source, parts: [textPart(text)] }
// Editing rewinds the conversation to this prompt — same as restore — so
// drop the abandoned timeline's todos/background rows (and kill the live
// processes) before the re-run repopulates them.
// new text (submitRewindPrompt interrupts a live turn first). Same as
// restore, so drop the abandoned timeline's todos/background rows before
// the re-run repopulates them.
clearSessionTodos(sessionId)
resetSessionBackground(sessionId)
clearPreviewArtifacts(sessionId)
@@ -868,33 +742,20 @@ export function usePromptActions({
setMutableRef(busyRef, true)
setBusy(true)
setAwaitingResponse(true)
updateSessionState(sessionId, state => ({
...state,
busy: true,
awaitingResponse: true,
pendingBranchGroup: null,
sawAssistantPayload: false,
interrupted: false,
messages: [...state.messages.slice(0, sourceIndex), editedMessage]
}))
updateSessionState(sessionId, state => applyRewindOptimistic(state, plan.sourceIndex, plan.editedMessage))
const isStaleTargetError = (err: unknown) =>
/no longer in session history|not in session history/i.test(err instanceof Error ? err.message : String(err))
try {
await submitRewindPrompt(
sessionId,
text,
isFailedTurn ? undefined : visibleUserOrdinal(messages, sourceIndex),
busyRef.current || $busy.get()
)
await submitRewindPrompt(sessionId, plan.text, plan.truncateOrdinal, busyRef.current || $busy.get())
} catch (err) {
let surfaced = err
if (!isFailedTurn && isStaleTargetError(err)) {
if (!plan.isFailedTurn && isStaleTargetError(err)) {
try {
// Already interrupted on the first attempt — submit as a plain resend.
await submitRewindPrompt(sessionId, text, undefined, false)
await submitRewindPrompt(sessionId, plan.text, undefined, false)
return
} catch (retryErr) {
@@ -917,34 +778,13 @@ export function usePromptActions({
const handleThreadMessagesChange = useCallback(
(nextMessages: readonly ThreadMessage[]) => {
const visibleIds = new Set(nextMessages.map(m => m.id))
const sessionId = activeSessionIdRef.current
if (!sessionId) {
return
}
updateSessionState(sessionId, state => {
let changed = false
const messages = state.messages.map(message => {
if (message.role !== 'assistant' || !message.branchGroupId) {
return message
}
const hidden = !visibleIds.has(message.id)
if (message.hidden === hidden) {
return message
}
changed = true
return { ...message, hidden }
})
return changed ? { ...state, messages } : state
})
updateSessionState(sessionId, state => applyBranchVisibility(state, nextMessages))
},
[activeSessionIdRef, updateSessionState]
)
@@ -952,6 +792,9 @@ export function usePromptActions({
return {
cancelRun,
editMessage,
// Session tiles route their slash input here (targets THEIR session via
// options.sessionId; app-level effects — branch, handoff — act on main).
executeSlashCommand,
handleThreadMessagesChange,
handoffSession,
reloadFromMessage,
@@ -0,0 +1,271 @@
/**
* Shared rewind/interrupt core for the prompt verbs — the ONE implementation
* of the submit primitive + the pure message math behind cancel / reload /
* restore / edit / branch-visibility. Both the primary chat (`index.ts`) and
* session tiles (`session-tile-actions.ts`) build on these so the two surfaces
* can't silently diverge (the tile's "sends only once" busy-ref bug was exactly
* that class of drift). The functions here are PURE — planners compute from a
* `ChatMessage[]`, optimistic transforms map a `ClientSessionState` to the next
* — so each caller keeps its own state-write + error-handling wiring.
*/
import type { AppendMessage, ThreadMessage } from '@assistant-ui/react'
import type { ClientSessionState } from '@/app/types'
import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes'
import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
import { appendText, isSessionBusyError, visibleUserIndexAtOrdinal, visibleUserOrdinal, withSessionBusyRetry } from './utils'
type RequestGateway = <T = unknown>(method: string, params?: Record<string, unknown>, timeoutMs?: number) => Promise<T>
/**
* Rewind a turn: `prompt.submit` with an optional `truncate_before_user_ordinal`
* (drops that user turn + everything after). Idle rewinds submit directly
* (interrupting an idle agent can leave a stale interrupt flag that cancels the
* fresh turn); live/stuck turns interrupt first, and a raced "session busy"
* response interrupts + retries through the shared busy gate.
*/
export async function runRewindSubmit(
requestGateway: RequestGateway,
sessionId: string,
text: string,
truncateOrdinal: number | undefined,
interruptFirst: boolean
): Promise<void> {
const interrupt = async () => {
try {
await requestGateway('session.interrupt', { session_id: sessionId })
} catch {
// Best-effort. The submit path still gates on the gateway state.
}
}
const submit = () =>
requestGateway(
'prompt.submit',
{ session_id: sessionId, text, ...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal }) },
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS
)
if (interruptFirst) {
await interrupt()
}
try {
await submit()
} catch (err) {
if (!isSessionBusyError(err)) {
throw err
}
await interrupt()
await withSessionBusyRetry(submit)
}
}
/** Cancel/stop finalize: drop empty pending/stream placeholders, un-pend the rest. */
export function finalizeInterruptedMessages(messages: ChatMessage[], streamId?: null | string): ChatMessage[] {
return messages
.filter(message => !((message.pending || message.id === streamId) && !chatMessageText(message).trim()))
.map(message => (message.pending || message.id === streamId ? { ...message, pending: false } : message))
}
// ---------------------------------------------------------------------------
// Reload (regenerate)
// ---------------------------------------------------------------------------
export interface ReloadPlan {
branchGroupId: string
text: string
truncateOrdinal: number
userIndex: number
}
/** The user turn to re-run for a reload from `parentId` (or the last turn). */
export function planReload(messages: ChatMessage[], parentId: null | string): null | ReloadPlan {
const parentIndex = parentId ? messages.findIndex(m => m.id === parentId) : messages.length - 1
const userBack =
parentIndex >= 0 ? [...messages.slice(0, parentIndex + 1)].reverse().findIndex(m => m.role === 'user') : -1
if (userBack < 0) {
return null
}
const userIndex = parentIndex - userBack
const userMessage = messages[userIndex]
const text = userMessage ? chatMessageText(userMessage).trim() : ''
if (!userMessage || !text) {
return null
}
const targetAssistant =
parentId && messages[parentIndex]?.role === 'assistant'
? messages[parentIndex]
: messages.slice(userIndex + 1).find(m => m.role === 'assistant')
return {
branchGroupId: targetAssistant?.branchGroupId ?? branchGroupForUser(userMessage),
text,
truncateOrdinal: visibleUserOrdinal(messages, userIndex),
userIndex
}
}
/** Optimistic reload state: keep the user turn, hide the branch's assistants. */
export function applyReloadOptimistic(state: ClientSessionState, plan: ReloadPlan): ClientSessionState {
const nextUserIndex = state.messages.findIndex((m, i) => i > plan.userIndex && m.role === 'user')
const end = nextUserIndex < 0 ? state.messages.length : nextUserIndex
return {
...state,
awaitingResponse: true,
busy: true,
interrupted: false,
messages: [
...state.messages.slice(0, plan.userIndex + 1),
...state.messages
.slice(plan.userIndex + 1, end)
.map(m => (m.role === 'assistant' ? { ...m, branchGroupId: plan.branchGroupId, hidden: true } : m))
],
pendingBranchGroup: plan.branchGroupId,
sawAssistantPayload: false
}
}
// ---------------------------------------------------------------------------
// Restore (rewind checkpoint)
// ---------------------------------------------------------------------------
export interface RestoreTarget {
text?: string
userOrdinal?: null | number
}
export interface RestorePlan {
sourceIndex: number
text: string
truncateOrdinal: number
}
/** Resolve the user turn to rewind to; throws with a user-facing reason. */
export function planRestore(messages: ChatMessage[], messageId: string, target?: RestoreTarget): RestorePlan {
const idIndex = messages.findIndex(m => m.id === messageId && m.role === 'user')
const fallbackIndex =
target?.userOrdinal === null || target?.userOrdinal === undefined
? -1
: visibleUserIndexAtOrdinal(messages, target.userOrdinal)
const sourceIndex = idIndex >= 0 ? idIndex : fallbackIndex
const source = messages[sourceIndex]
if (!source || source.role !== 'user') {
throw new Error('Could not find the message to restore.')
}
const text = (chatMessageText(source).trim() || target?.text?.trim() || '').trim()
if (!text) {
throw new Error('Cannot restore an empty message.')
}
const truncateOrdinal =
target?.userOrdinal === null || target?.userOrdinal === undefined
? visibleUserOrdinal(messages, sourceIndex)
: target.userOrdinal
return { sourceIndex, text, truncateOrdinal }
}
// ---------------------------------------------------------------------------
// Edit (revert + resubmit with new text)
// ---------------------------------------------------------------------------
export interface EditPlan {
editedMessage: ChatMessage
isFailedTurn: boolean
sourceIndex: number
text: string
truncateOrdinal: number | undefined
}
/** Resolve the edited user turn, or null when nothing changed / invalid. */
export function planEdit(messages: ChatMessage[], edited: AppendMessage): EditPlan | null {
const sourceId = edited.sourceId || edited.parentId
const text = appendText(edited)
if (!sourceId || !text || edited.role !== 'user') {
return null
}
const sourceIndex = messages.findIndex(m => m.id === sourceId)
const source = messages[sourceIndex]
if (!source || source.role !== 'user' || chatMessageText(source).trim() === text) {
return null
}
// Failed turn: the optimistic user msg never reached the gateway, so a
// truncate-by-ordinal would 422 — resubmit plainly instead.
const nextMessage = messages[sourceIndex + 1]
const isFailedTurn = nextMessage?.role === 'assistant' && Boolean(nextMessage.error)
return {
editedMessage: { ...source, parts: [textPart(text)] },
isFailedTurn,
sourceIndex,
text,
truncateOrdinal: isFailedTurn ? undefined : visibleUserOrdinal(messages, sourceIndex)
}
}
/** Optimistic rewind-to state for restore/edit: drop everything after the
* source turn (edit swaps in the edited message; restore keeps the original). */
export function applyRewindOptimistic(
state: ClientSessionState,
sourceIndex: number,
editedMessage?: ChatMessage
): ClientSessionState {
return {
...state,
awaitingResponse: true,
busy: true,
interrupted: false,
messages: editedMessage
? [...state.messages.slice(0, sourceIndex), editedMessage]
: state.messages.slice(0, sourceIndex + 1),
pendingBranchGroup: null,
sawAssistantPayload: false
}
}
// ---------------------------------------------------------------------------
// Branch visibility (assistant-ui hides non-active branches)
// ---------------------------------------------------------------------------
/** Sync each assistant branch message's `hidden` to what the thread renders. */
export function applyBranchVisibility(state: ClientSessionState, next: readonly ThreadMessage[]): ClientSessionState {
const visibleIds = new Set(next.map(m => m.id))
let changed = false
const messages = state.messages.map(message => {
if (message.role !== 'assistant' || !message.branchGroupId) {
return message
}
const hidden = !visibleIds.has(message.id)
if (message.hidden === hidden) {
return message
}
changed = true
return { ...message, hidden }
})
return changed ? { ...state, messages } : state
}
@@ -4,6 +4,7 @@ import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes'
import type { Translations } from '@/i18n'
import { type ChatMessage, textPart } from '@/lib/chat-messages'
import { optimisticAttachmentRef } from '@/lib/chat-runtime'
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
import { setMutableRef } from '@/lib/mutable-ref'
import {
$composerAttachments,
@@ -48,6 +49,26 @@ interface SubmitPromptDeps {
updater: (state: ClientSessionState) => ClientSessionState,
storedSessionId?: string | null
) => ClientSessionState
/** Composer-scope seams: the main chat runs on the module-level globals
* (defaults); a session tile injects its own so a tile submit never writes
* the primary view's $busy/$messages or clears the main attachment chips. */
scope?: {
clearAttachments: () => void
readAttachments: () => ComposerAttachment[]
setAwaitingResponse: (awaiting: boolean) => void
setBusy: (busy: boolean) => void
setMessages: (updater: (current: ChatMessage[]) => ChatMessage[]) => void
}
}
// Stable identity — a fresh default object per render would churn the
// useCallback below on every render.
const MAIN_SUBMIT_SCOPE: NonNullable<SubmitPromptDeps['scope']> = {
clearAttachments: clearComposerAttachments,
readAttachments: () => $composerAttachments.get(),
setAwaitingResponse,
setBusy,
setMessages
}
/** The prompt submit pipeline, extracted from usePromptActions. */
@@ -62,12 +83,13 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
requestGateway,
selectedStoredSessionIdRef,
syncAttachmentsForSubmit,
updateSessionState
updateSessionState,
scope = MAIN_SUBMIT_SCOPE
} = deps
return useCallback(
async (rawText: string, options?: SubmitTextOptions) => {
const visibleText = rawText.trim()
const visibleText = sanitizeComposerInput(rawText).trim()
const usingComposerAttachments = !options?.attachments
// Drop undefined/null holes a session switch or draft restore can leave in
@@ -75,7 +97,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
// this, the sibling iterations below (a.kind / a.label / a.refText, and the
// sync step) throw "Cannot read properties of undefined (reading 'refText')"
// and break the chat surface.
const attachments = (options?.attachments ?? $composerAttachments.get()).filter((a): a is ComposerAttachment =>
const attachments = (options?.attachments ?? scope.readAttachments()).filter((a): a is ComposerAttachment =>
Boolean(a)
)
@@ -158,8 +180,8 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
const releaseBusy = () => {
releaseSubmitLock()
setMutableRef(busyRef, false)
setBusy(false)
setAwaitingResponse(false)
scope.setBusy(false)
scope.setAwaitingResponse(false)
}
// Idempotent optimistic insert — re-running with the resolved sessionId
@@ -198,7 +220,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
const dropOptimistic = (sid: null | string) => {
if (!sid) {
setMessages(current => current.filter(m => m.id !== optimisticId))
scope.setMessages(current => current.filter(m => m.id !== optimisticId))
return
}
@@ -224,8 +246,8 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
}
setMutableRef(busyRef, true)
setBusy(true)
setAwaitingResponse(true)
scope.setBusy(true)
scope.setAwaitingResponse(true)
clearNotifications()
let sessionId: null | string = activeSessionId
@@ -233,7 +255,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
if (sessionId) {
seedOptimistic(sessionId)
} else {
setMessages(current => [...current, buildUserMessage()])
scope.setMessages(current => [...current, buildUserMessage()])
}
if (!sessionId && startingStoredSessionId) {
@@ -382,7 +404,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
}
if (usingComposerAttachments) {
clearComposerAttachments()
scope.clearAttachments()
}
// Submit landed — the turn now runs (busy stays true), but the submit
@@ -439,6 +461,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
createBackendSessionForSend,
getRouteToken,
requestGateway,
scope,
selectedStoredSessionIdRef,
syncAttachmentsForSubmit,
updateSessionState
@@ -426,6 +426,7 @@ describe('resumeSession failure recovery', () => {
storedSessionId: 'stored-1',
streamId: null,
turnStartedAt: null,
usage: null,
yolo: false
}
]
@@ -2,6 +2,7 @@ import type { MutableRefObject } from 'react'
import { useCallback, useRef } from 'react'
import type { NavigateFunction } from 'react-router-dom'
import { revealTreePane } from '@/components/pane-shell/tree/store'
import { deleteSession, getSessionMessages, setSessionArchived } from '@/hermes'
import { useI18n } from '@/i18n'
import { preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
@@ -44,6 +45,7 @@ import {
setTurnStartedAt,
setYoloActive
} from '@/store/session'
import { closeSessionTile, dropSessionState, openSessionTile, patchSessionTile, publishSessionState, type TileDock } from '@/store/session-states'
import { broadcastSessionsChanged } from '@/store/session-sync'
import { isWatchWindow } from '@/store/windows'
import type { SessionCreateResponse, SessionResumeResponse, UsageStats } from '@/types/hermes'
@@ -88,6 +90,54 @@ interface SessionActionsOptions {
) => ClientSessionState
}
// Stored ids created in THIS renderer run. A brand-new session lives only in the
// gateway's in-memory map until its first turn persists a state.db row — so if a
// respawning/flapping backend drops it, both resume RPC and the REST transcript
// 404 even though the user just made it. We must NOT treat that as "gone" (which
// yanks them to a fresh draft — the "new sessions clear themselves" bug); the
// bounded retry rebinds it when the backend returns. Boot-into-a-stale-last-id
// (NOT in this set) still legitimately drops to a draft.
const createdThisRun = new Set<string>()
// Reflect a stored row's persisted token counts into the live usage atom
// (total is derived, so callers can't drift it out of sync with input/output).
function applyStoredUsage(stored: { input_tokens?: number | null; output_tokens?: number | null }) {
const input = stored.input_tokens || 0
const output = stored.output_tokens || 0
setCurrentUsage(current => ({ ...current, input, output, total: input + output }))
}
// `session.create` params from the current profile + sticky-UI model/effort/fast,
// ensuring the gateway is on that profile first. Shared by the primary send path
// and the "open in split" tile path; `cwd` is the one thing that differs (the
// live composer cwd for a send, the resolved new-session cwd for a fresh tile).
//
// Resolving null profile to the active gateway's is load-bearing: in global-remote
// mode one backend serves every profile, so an omitted profile silently lands the
// chat on the launch (default) profile — the "rubberbands back to default" bug.
// A no-op for single-profile/local-pooled users (a backend resolves its own launch
// profile to None). The sticky UI model/effort/fast ride as per-session overrides,
// never the profile default (that lives in Settings → Model).
async function desktopSessionCreateParams(cwd: string): Promise<Record<string, unknown>> {
const profile = $newChatProfile.get() ?? normalizeProfileKey($activeGatewayProfile.get())
await ensureGatewayProfile(profile)
const model = $currentModel.get().trim()
const provider = $currentProvider.get().trim()
const effort = $currentReasoningEffort.get().trim()
return {
cols: 96,
source: 'desktop',
...(cwd && { cwd }),
...(profile ? { profile } : {}),
...(model ? { model, ...(provider ? { provider } : {}) } : {}),
...(effort ? { reasoning_effort: effort } : {}),
...($currentFastMode.get() ? { fast: true } : {})
}
}
interface FreshSessionDraftOptions {
replaceRoute?: boolean
workspaceTarget?: NewChatWorkspaceTarget
@@ -186,19 +236,9 @@ export function useSessionActions({
creatingSessionRef.current = true
try {
// A plain new session (top "New Session", /new, keybind) leaves
// $newChatProfile null to mean "use the live context"; the per-profile
// "+" sets it explicitly. Resolve null to the active gateway profile so
// session.create always carries it: in global-remote mode one backend
// serves every profile, so an omitted profile param silently lands the
// chat on the launch (default) profile — the "rubberbands back to
// default" bug. This is a no-op for single-profile/local-pooled users:
// a backend resolves its own launch profile to None (_profile_home).
const newChatProfile = $newChatProfile.get() ?? normalizeProfileKey($activeGatewayProfile.get())
await ensureGatewayProfile(newChatProfile)
// An explicit one-shot workspace target (null → detached, string → that
// folder) wins; otherwise fall through to the live cwd, then the
// project-aware default (resolveNewSessionCwd).
// folder) wins; otherwise the live cwd, then the project-aware default
// (resolveNewSessionCwd — a project's new session keeps its repo cwd).
const workspaceTarget = $newChatWorkspaceTarget.get()
const cwd =
@@ -208,26 +248,8 @@ export function useSessionActions({
? workspaceTarget.trim()
: $currentCwd.get().trim() || resolveNewSessionCwd()
// The composer's model/effort/fast is sticky UI state ($currentModel,
// $currentProvider, $currentReasoningEffort, $currentFastMode). Ship it
// with every session.create so the new chat opens on whatever the picker
// shows — applied as per-session overrides, never written to the profile
// default (that lives in Settings → Model).
const uiModel = $currentModel.get().trim()
const uiProvider = $currentProvider.get().trim()
const uiEffort = $currentReasoningEffort.get().trim()
const uiFast = $currentFastMode.get()
const created = await requestGateway<SessionCreateResponse>('session.create', {
cols: 96,
source: 'desktop',
...(cwd && { cwd }),
...(newChatProfile ? { profile: newChatProfile } : {}),
...(uiModel ? { model: uiModel, ...(uiProvider ? { provider: uiProvider } : {}) } : {}),
...(uiEffort ? { reasoning_effort: uiEffort } : {}),
...(uiFast ? { fast: true } : {})
})
const params = await desktopSessionCreateParams(cwd)
const created = await requestGateway<SessionCreateResponse>('session.create', params)
const stored = created.stored_session_id ?? null
if (
@@ -246,6 +268,7 @@ export function useSessionActions({
ensureSessionState(created.session_id, stored)
if (stored) {
createdThisRun.add(stored)
// Seed the sidebar preview with the user's first message so the row
// reads meaningfully while the turn is in flight, instead of flashing
// "Untitled session" until the turn persists and auto-title runs. The
@@ -310,6 +333,44 @@ export function useSessionActions({
[navigate, startFreshSessionDraft]
)
/** Create a fresh session and open it as a tile — leaves the primary chat alone.
* Used by the New session row's "Open in split" menu (and any future
* "new chat beside" affordance). */
const openNewSessionTile = useCallback(
async (dir: TileDock = 'right') => {
try {
// Fresh tile → the resolved new-session cwd (project/default), not the
// primary composer's live cwd.
const params = await desktopSessionCreateParams(resolveNewSessionCwd().trim())
const created = await requestGateway<SessionCreateResponse>('session.create', params)
const stored = created.stored_session_id
if (!stored) {
await requestGateway('session.close', { session_id: created.session_id }).catch(() => undefined)
notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed })
return
}
createdThisRun.add(stored)
// Seed the sidebar + per-runtime cache, but DON'T steal the primary
// selection — this session lives in the tile. Prime it with the create
// runtime so the tile skips a redundant resume.
upsertOptimisticSession(created, stored, null, null)
const runtimeInfo = applyRuntimeInfo(created.info)
updateSessionState(created.session_id, state => (runtimeInfo ? { ...state, ...runtimeInfo } : state), stored)
openSessionTile(stored, dir)
patchSessionTile(stored, { runtimeId: created.session_id })
revealTreePane(`session-tile:${stored}`)
broadcastSessionsChanged()
} catch (error) {
notifyError(error, copy.createSessionFailed)
}
},
[copy, requestGateway, updateSessionState]
)
const openSettings = useCallback(() => {
navigate(SETTINGS_ROUTE)
}, [navigate])
@@ -375,6 +436,7 @@ export function useSessionActions({
if (state.storedSessionId !== storedSessionId) {
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
sessionStateByRuntimeIdRef.current.delete(runtimeId)
dropSessionState(runtimeId)
return null
}
@@ -423,11 +485,13 @@ export function useSessionActions({
if (cachedViewState !== cachedState) {
sessionStateByRuntimeIdRef.current.set(cachedRuntimeId, cachedViewState)
publishSessionState(cachedRuntimeId, cachedViewState)
}
if (sessionShouldHaveTranscript(stored) && cachedViewState.messages.length === 0) {
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
sessionStateByRuntimeIdRef.current.delete(cachedRuntimeId)
dropSessionState(cachedRuntimeId)
} else {
setFreshDraftReady(false)
clearNotifications()
@@ -464,6 +528,7 @@ export function useSessionActions({
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
sessionStateByRuntimeIdRef.current.delete(cachedRuntimeId)
dropSessionState(cachedRuntimeId)
}
}
}
@@ -471,6 +536,16 @@ export function useSessionActions({
setFreshDraftReady(false)
setActiveSessionId(null)
activeSessionIdRef.current = null
// A warm-cache hit at entry skipped the cold-path transcript clear, but the
// warm path can still bail down to here — an empty-transcript drop, or the
// cache getting purged during the profile-swap await — so the PREVIOUS
// session's transcript would leak into this cold resume ("switching
// sessions shows the same messages"). Clear it so the loader/prefetch
// paints fresh; guarded so the normal cold path (already cleared) no-ops.
if ($messages.get().length > 0) {
setMessages([])
}
busyRef.current = true
setBusy(true)
setAwaitingResponse(false)
@@ -485,12 +560,7 @@ export function useSessionActions({
applyStoredSessionPreviewRuntimeInfo(stored)
if (stored) {
setCurrentUsage(current => ({
...current,
input: stored.input_tokens || 0,
output: stored.output_tokens || 0,
total: (stored.input_tokens || 0) + (stored.output_tokens || 0)
}))
applyStoredUsage(stored)
}
let resumedRunning = false
@@ -642,6 +712,16 @@ export function useSessionActions({
// permanently-dead id. (Booting straight into a no-longer-existent
// last-session id is the common trigger.)
if ($messages.get().length === 0 && isSessionGoneError(fallbackError)) {
// A session created THIS run isn't gone — its backend just flapped
// before the turn-less session persisted. Keep the empty view and arm
// the bounded retry to rebind, rather than yanking to a fresh draft.
// Only a stale id from a PRIOR run drops to a draft.
if (createdThisRun.has(storedSessionId)) {
setResumeFailedSessionId(storedSessionId)
return
}
startFreshSessionDraft(true)
return
@@ -876,6 +956,18 @@ export function useSessionActions({
if (closingRuntimeId) {
clearQueuedPrompts(closingRuntimeId)
}
// A tiled copy of this session must not outlive it: collapse the pane
// and evict its mirrored runtime state so nothing submits to (or renders)
// a deleted session.
const tiledRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
closeSessionTile(storedSessionId)
if (tiledRuntimeId) {
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
sessionStateByRuntimeIdRef.current.delete(tiledRuntimeId)
dropSessionState(tiledRuntimeId)
}
} catch (err) {
if (removed) {
setSessions(prev => [removed, ...prev])
@@ -892,12 +984,7 @@ export function useSessionActions({
const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
if (stored) {
setCurrentUsage(current => ({
...current,
input: stored.input_tokens || 0,
output: stored.output_tokens || 0,
total: (stored.input_tokens || 0) + (stored.output_tokens || 0)
}))
applyStoredUsage(stored)
}
setMessages(previousMessages)
@@ -918,8 +1005,10 @@ export function useSessionActions({
copy,
navigate,
requestGateway,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId,
selectedStoredSessionIdRef,
sessionStateByRuntimeIdRef,
startFreshSessionDraft
]
)
@@ -956,6 +1045,16 @@ export function useSessionActions({
// not appear to do nothing until the next full refresh.
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
$pinnedSessionIds.set($pinnedSessionIds.get().filter(id => id !== storedSessionId && id !== archivedPinId))
// An archived session is hidden from the sidebar; its tile must go too.
const tiledRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
closeSessionTile(storedSessionId)
if (tiledRuntimeId) {
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
sessionStateByRuntimeIdRef.current.delete(tiledRuntimeId)
dropSessionState(tiledRuntimeId)
}
notify({ durationMs: 2_000, kind: 'success', message: copy.archived })
} catch (err) {
if (archived) {
@@ -968,7 +1067,13 @@ export function useSessionActions({
notifyError(err, copy.archiveFailed)
}
},
[copy, selectedStoredSessionId, startFreshSessionDraft]
[
copy,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId,
sessionStateByRuntimeIdRef,
startFreshSessionDraft
]
)
return {
@@ -977,6 +1082,7 @@ export function useSessionActions({
branchStoredSession,
closeSettings,
createBackendSessionForSend,
openNewSessionTile,
openSettings,
removeSession,
resumeSession,
@@ -8,6 +8,7 @@ import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/p
import {
$currentCwd,
$sessions,
sessionMatchesStoredId,
setCurrentBranch,
setCurrentCwd,
setCurrentFastMode,
@@ -20,6 +21,10 @@ import {
setSessions,
setYoloActive
} from '@/store/session'
// Re-exported for the many session-actions/tile call sites that already import
// it from here; the canonical definition lives in @/store/session.
export { sessionMatchesStoredId }
import { reportBackendContract, reportInstallMethodWarning } from '@/store/updates'
import type { SessionCreateResponse, SessionInfo, SessionRuntimeInfo } from '@/types/hermes'
@@ -183,10 +188,6 @@ export function patchSessionWorkspace(sessionId: string, cwd: string | undefined
setSessions(prev => prev.map(session => (session.id === sessionId ? { ...session, cwd } : session)))
}
export function sessionMatchesStoredId(session: SessionInfo, storedSessionId: string): boolean {
return session.id === storedSessionId || session._lineage_root_id === storedSessionId
}
export function sessionShouldHaveTranscript(session: SessionInfo | undefined): boolean {
return (session?.message_count ?? 0) > 0
}
@@ -1,6 +1,7 @@
import { useCallback, useRef } from 'react'
import { getCronJobs, listAllProfileSessions, type SessionInfo } from '@/hermes'
import { sameCronSignature } from '@/lib/session-signatures'
import {
isMessagingSource,
LOCAL_SESSION_SOURCE_IDS,
@@ -29,8 +30,6 @@ import {
setSessionsTotal
} from '@/store/session'
import { sameCronSignature } from '../../desktop-controller-utils'
// The recents list is local-only: cron rows have their own section, and each
// messaging platform (telegram, discord, …) is fetched separately into its own
// self-managed sidebar section (refreshMessagingSessions). Excluding both here
@@ -21,6 +21,7 @@ import {
setTurnStartedAt,
setYoloActive
} from '@/store/session'
import { publishSessionState } from '@/store/session-states'
import type { ClientSessionState } from '../../types'
@@ -263,6 +264,10 @@ export function useSessionStateCache({
const previous = ensureSessionState(sessionId, storedSessionId)
const next = updater({ ...previous, messages: previous.messages })
sessionStateByRuntimeIdRef.current.set(sessionId, next)
// Mirror into the reactive multi-session store — session tiles (and any
// other non-primary surface) subscribe per runtime id there instead of
// through the single active $messages view.
publishSessionState(sessionId, next)
if (previous.storedSessionId !== next.storedSessionId || !next.busy) {
setSessionWorking(previous.storedSessionId, false)
+12 -1
View File
@@ -6,7 +6,7 @@ import { Tip } from '@/components/ui/tooltip'
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Archive, Bell, Download, Globe, Info, KeyRound, RefreshCw, Settings2, Upload, Wrench, Zap } from '@/lib/icons'
import { Archive, Bell, Download, Globe, Info, KeyRound, Package, RefreshCw, Settings2, Upload, Wrench, Zap } from '@/lib/icons'
import { notifyError } from '@/store/notifications'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
@@ -22,6 +22,7 @@ import { SECTIONS } from './constants'
import { GatewaySettings } from './gateway-settings'
import { KEYS_VIEWS, KeysSettings, type KeysView } from './keys-settings'
import { NotificationsSettings } from './notifications-settings'
import { PluginsSettings } from './plugins-settings'
import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings'
import { SessionsSettings } from './sessions-settings'
import type { SettingsPageProps, SettingsView as SettingsViewId } from './types'
@@ -32,6 +33,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
'gateway',
'keys',
'notifications',
'plugins',
'sessions',
'about'
]
@@ -186,6 +188,13 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
label: t.settings.nav.apiKeys,
onSelect: () => setActiveView('keys')
},
{
active: activeView === 'plugins',
icon: Package,
id: 'plugins',
label: t.settings.nav.plugins,
onSelect: () => setActiveView('plugins')
},
{
active: activeView === 'sessions',
icon: Archive,
@@ -259,6 +268,8 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
<KeysSettings view={keysView} />
) : activeView === 'notifications' ? (
<NotificationsSettings />
) : activeView === 'plugins' ? (
<PluginsSettings />
) : (
<SessionsSettings />
)}
@@ -186,3 +186,139 @@ describe('ModelSettings', () => {
expect(await screen.findByText(/still run on/)).toBeTruthy()
})
})
describe('ModelSettings MoA preset editor', () => {
const moaConfig = () => ({
default_preset: 'default',
active_preset: '',
presets: {
default: {
reference_models: [
{ provider: 'nous', model: 'hermes-4' },
{ provider: 'openrouter', model: 'deepseek/deepseek-v4-pro' }
],
aggregator: { provider: 'openrouter', model: 'anthropic/claude-opus-4.8' },
reference_temperature: 0,
aggregator_temperature: 0,
max_tokens: 4096,
enabled: true
}
},
reference_models: [
{ provider: 'nous', model: 'hermes-4' },
{ provider: 'openrouter', model: 'deepseek/deepseek-v4-pro' }
],
aggregator: { provider: 'openrouter', model: 'anthropic/claude-opus-4.8' },
reference_temperature: 0,
aggregator_temperature: 0,
max_tokens: 4096,
enabled: true
})
beforeEach(() => {
getGlobalModelOptions.mockResolvedValue({
providers: [
{
name: 'Nous',
slug: 'nous',
models: ['hermes-4', 'hermes-4-mini'],
authenticated: true,
capabilities: { 'hermes-4': { reasoning: true, fast: true } }
},
{
name: 'OpenRouter',
slug: 'openrouter',
models: ['deepseek/deepseek-v4-pro', 'anthropic/claude-opus-4.8'],
authenticated: true
}
]
})
getMoaModels.mockResolvedValue(moaConfig())
saveMoaModels.mockImplementation((body: unknown) => Promise.resolve(body))
})
async function openReferenceEditor() {
await renderModelSettings()
expect(await screen.findByText('Reference 1')).toBeTruthy()
}
function slotSelects() {
// Combobox order in the MoA section (last 7 on the page): preset select,
// then provider+model per reference (2 refs), then aggregator
// provider+model. Reference 1's pair is therefore at -6 / -5.
const all = screen.getAllByRole('combobox')
return { ref1Provider: all.at(-6)!, ref1Model: all.at(-5)! }
}
it('holds the autosave while a slot is half-filled (provider changed, model pending)', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
try {
await openReferenceEditor()
fireEvent.click(slotSelects().ref1Provider)
fireEvent.click(await screen.findByRole('option', { name: 'OpenRouter' }))
// Model was cleared by the provider change → config incomplete → the
// debounced autosave must NOT fire, even well past the 600ms window.
await vi.advanceTimersByTimeAsync(2000)
expect(saveMoaModels).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('saves once the model pick completes the slot', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
try {
await openReferenceEditor()
fireEvent.click(slotSelects().ref1Provider)
fireEvent.click(await screen.findByRole('option', { name: 'OpenRouter' }))
await vi.advanceTimersByTimeAsync(700)
fireEvent.click(slotSelects().ref1Model)
fireEvent.click(await screen.findByRole('option', { name: 'anthropic/claude-opus-4.8' }))
await vi.advanceTimersByTimeAsync(700)
expect(saveMoaModels).toHaveBeenCalledTimes(1)
const sent = saveMoaModels.mock.calls[0][0] as ReturnType<typeof moaConfig>
expect(sent.presets.default.reference_models[0]).toEqual({
provider: 'openrouter',
model: 'anthropic/claude-opus-4.8'
})
// The untouched slots ride along unchanged — nothing reverts to defaults.
expect(sent.presets.default.reference_models[1]).toEqual({
provider: 'openrouter',
model: 'deepseek/deepseek-v4-pro'
})
expect(sent.presets.default.aggregator).toEqual({
provider: 'openrouter',
model: 'anthropic/claude-opus-4.8'
})
} finally {
vi.useRealTimers()
}
})
it('does not clear the model or save when the same provider is re-selected', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
try {
await openReferenceEditor()
fireEvent.click(slotSelects().ref1Provider)
fireEvent.click(await screen.findByRole('option', { name: 'Nous' }))
await vi.advanceTimersByTimeAsync(700)
// Radix treats re-picking the current value as a no-op (no
// onValueChange), so nothing changes: no save, model still shown.
expect(saveMoaModels).not.toHaveBeenCalled()
expect(screen.getByText('nous · hermes-4')).toBeTruthy()
} finally {
vi.useRealTimers()
}
})
})
@@ -130,6 +130,24 @@ const NO_PROVIDERS: readonly ModelOptionProvider[] = [{ name: '—', slug: '', m
export const withActive = (models: readonly string[], active: string): readonly string[] =>
active && !models.includes(active) ? [active, ...models] : models
// A slot is complete when both halves are chosen. Changing a slot's provider
// intentionally clears its model (see updateMoaSlot), so every provider change
// passes through an incomplete state while the user picks the new model.
export const moaSlotComplete = (slot: MoaModelSlot): boolean => !!(slot.provider.trim() && slot.model.trim())
// True when every slot in every preset is fully specified — the only state
// that is safe to persist. The backend rejects configs with half-filled slots
// (HTTP 422) instead of silently swapping the preset for hardcoded defaults
// (#64156), so the autosave must simply wait for the edit to finish rather
// than trying to "repair" the payload.
export const moaConfigComplete = (config: MoaConfigResponse): boolean =>
Object.values(config.presets).every(
preset =>
preset.reference_models.length > 0 &&
preset.reference_models.every(moaSlotComplete) &&
moaSlotComplete(preset.aggregator)
)
interface StaleAuxWarningProps {
applying: boolean
onReset: () => void
@@ -317,19 +335,44 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
[]
)
// Guard against stale save responses overwriting newer state.
const moaSaveGeneration = useRef(0)
// Quiet debounced persist for inline MoA edits — mirrors the config page's
// autosave so slot/aggregator tweaks save themselves, matching the
// preset-level ops (set default / add / delete) that already persist on
// click. No `applying` spinner, so selecting stays responsive.
//
// While any slot is half-filled (provider picked, model pending) the save is
// HELD, not sent: the previous complete config stays on disk and the next
// edit that completes the slot flushes the whole preset. Every edit bumps
// the generation so an in-flight response from an older save can never
// repaint over the user's mid-edit state.
const scheduleMoaSave = useCallback((next: MoaConfigResponse) => {
if (moaSaveTimer.current) {
window.clearTimeout(moaSaveTimer.current)
moaSaveTimer.current = null
}
const generation = moaSaveGeneration.current + 1
moaSaveGeneration.current = generation
if (!moaConfigComplete(next)) {
return
}
moaSaveTimer.current = window.setTimeout(() => {
void saveMoaModels(next)
.then(setMoa)
.catch(err => setError(err instanceof Error ? err.message : String(err)))
.then(saved => {
if (moaSaveGeneration.current === generation) {
setMoa(saved)
}
})
.catch(err => {
if (moaSaveGeneration.current === generation) {
setError(err instanceof Error ? err.message : String(err))
}
})
}, 600)
}, [])
@@ -359,7 +402,10 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
const updateMoaSlot = useCallback((slot: MoaModelSlot, patch: Partial<MoaModelSlot>): MoaModelSlot => {
const next = { ...slot, ...patch }
if (patch.provider) {
// Picking a new provider invalidates the model choice (models are
// per-provider). A same-provider update must not wipe the model — Radix
// filters same-value changes, but programmatic callers may not.
if (patch.provider && patch.provider !== slot.provider) {
next.model = ''
}
@@ -368,6 +414,16 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
const saveMoa = useCallback(async (next: MoaConfigResponse) => {
const epoch = profileEpoch.current
// Explicit preset ops (set default / add / delete) supersede any pending
// debounced slot autosave — cancel it and invalidate in-flight responses
// so the two writers can't race each other's state.
if (moaSaveTimer.current) {
window.clearTimeout(moaSaveTimer.current)
moaSaveTimer.current = null
}
moaSaveGeneration.current += 1
setApplying(true)
setError('')
@@ -991,11 +1047,15 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
<SelectValue placeholder={m.provider} />
</SelectTrigger>
<SelectContent>
{moaSlotProviderOptions.map(provider => (
<SelectItem key={provider.slug || 'none'} value={provider.slug || 'none'}>
{provider.name}
</SelectItem>
))}
{withActive(moaSlotProviderOptions.map(p => p.slug || 'none'), slot.provider).map(slug => {
const provider = moaSlotProviderOptions.find(p => (p.slug || 'none') === slug)
return (
<SelectItem key={slug} value={slug}>
{provider?.name || slug}
</SelectItem>
)
})}
</SelectContent>
</Select>
<Select
@@ -1037,10 +1097,10 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
}
description={
<span className="font-mono text-[0.68rem]">
{slot.provider} · {slot.model}
{slot.provider} · {slot.model || m.model}
</span>
}
key={`${selectedMoaPreset}-${slot.provider}-${slot.model}-${index}`}
key={`${selectedMoaPreset}-${index}`}
title={`Reference ${index + 1}`}
/>
))}
@@ -1070,11 +1130,15 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
<SelectValue placeholder={m.provider} />
</SelectTrigger>
<SelectContent>
{moaSlotProviderOptions.map(provider => (
<SelectItem key={provider.slug || 'none'} value={provider.slug || 'none'}>
{provider.name}
</SelectItem>
))}
{withActive(moaSlotProviderOptions.map(p => p.slug || 'none'), currentMoaPreset.aggregator.provider).map(slug => {
const provider = moaSlotProviderOptions.find(p => (p.slug || 'none') === slug)
return (
<SelectItem key={slug} value={slug}>
{provider?.name || slug}
</SelectItem>
)
})}
</SelectContent>
</Select>
<Select
@@ -0,0 +1,124 @@
import { useStore } from '@nanostores/react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Switch } from '@/components/ui/switch'
import { Tip } from '@/components/ui/tooltip'
import { $pluginRecords, type PluginRecord, setPluginEnabled } from '@/contrib/plugins-store'
import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
import { getStatus } from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Package } from '@/lib/icons'
import { notifyError } from '@/store/notifications'
import { EmptyState, ListRow, Pill, SectionHeading, SettingsContent } from './primitives'
const KIND_ORDER: Record<PluginRecord['kind'], number> = { disk: 0, runtime: 1, bundled: 2 }
function reveal(file: string) {
void window.hermesDesktop?.revealPath?.(file)?.catch(() => undefined)
}
async function revealPluginsDir() {
try {
const { hermes_home } = await getStatus()
// openDir (not reveal): the door often doesn't exist on first use, and
// showItemInFolder on a missing path silently no-ops (esp. Windows).
const result = await window.hermesDesktop?.openDir?.(`${hermes_home}/desktop-plugins`)
if (result && !result.ok) {
notifyError(result.error ?? 'unknown error', 'Could not open the plugins folder')
}
} catch (err) {
notifyError(err, 'Could not resolve the plugins folder')
}
}
function PluginRow({ record }: { record: PluginRecord }) {
const { t } = useI18n()
const p = t.settings.plugins
return (
<ListRow
action={
<div className="flex items-center justify-end gap-2">
{record.file && (
<Tip label={p.reveal}>
<Button onClick={() => reveal(record.file!)} size="icon" variant="ghost">
<Codicon name="folder-opened" size="0.85rem" />
</Button>
</Tip>
)}
<Switch
aria-label={`${record.status === 'disabled' ? p.enable : p.disable} ${record.name}`}
checked={record.status !== 'disabled'}
onCheckedChange={on => {
triggerHaptic('selection')
void setPluginEnabled(record.id, on)
}}
/>
</div>
}
description={
record.status === 'error' ? (
<span className="text-(--ui-danger,#f87171)">{record.error}</span>
) : (
record.file ?? record.id
)
}
title={
<span className="flex items-center gap-2">
{record.name}
<Pill>{p.kinds[record.kind]}</Pill>
{record.status === 'error' && <Pill tone="primary">{p.failed}</Pill>}
</span>
}
/>
)
}
export function PluginsSettings() {
const { t } = useI18n()
const p = t.settings.plugins
const records = useStore($pluginRecords)
const rows = Object.values(records).sort(
(a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind] || a.name.localeCompare(b.name)
)
return (
<SettingsContent>
<SectionHeading icon={Package} meta={p.count(rows.length)} title={p.title} />
<p className="mb-4 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">{p.blurb}</p>
<div className="mb-4 flex items-center gap-2">
<Button onClick={() => void revealPluginsDir()} size="sm" variant="outline">
<Codicon name="folder-opened" size="0.8rem" />
{p.openFolder}
</Button>
<Button
onClick={() => {
triggerHaptic('selection')
void discoverRuntimePlugins()
}}
size="sm"
variant="outline"
>
<Codicon name="refresh" size="0.8rem" />
{p.rescan}
</Button>
</div>
{rows.length === 0 ? (
<EmptyState title={p.empty} />
) : (
<div className="divide-y divide-(--ui-stroke-tertiary)">
{rows.map(record => (
<PluginRow key={record.id} record={record} />
))}
</div>
)}
</SettingsContent>
)
}
+1
View File
@@ -9,6 +9,7 @@ export type SettingsView =
| 'gateway'
| 'keys'
| 'notifications'
| 'plugins'
| 'providers'
| 'sessions'
| `config:${string}`
-237
View File
@@ -1,237 +0,0 @@
import { useStore } from '@nanostores/react'
import type { CSSProperties, ReactNode } from 'react'
import { useSyncExternalStore } from 'react'
import { NotificationStack } from '@/components/notifications'
import { PaneShell } from '@/components/pane-shell'
import { FloatingPet } from '@/components/pet/floating-pet'
import { SidebarProvider } from '@/components/ui/sidebar'
import { useMediaQuery } from '@/hooks/use-media-query'
import {
$fileBrowserOpen,
$panesFlipped,
$sidebarOpen,
FILE_BROWSER_DEFAULT_WIDTH,
FILE_BROWSER_PANE_ID,
setSidebarOpen
} from '@/store/layout'
import { $paneWidthOverride } from '@/store/panes'
import { $connection } from '@/store/session'
import { isSecondaryWindow } from '@/store/windows'
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
import { useWindowControlsOverlayWidth } from './hooks/use-window-controls-overlay-width'
import { KeybindPanel } from './keybind-panel'
import { StatusbarControls, type StatusbarItem } from './statusbar-controls'
import { TITLEBAR_HEIGHT, titlebarControlsPosition } from './titlebar'
import { TitlebarControls, type TitlebarTool } from './titlebar-controls'
interface AppShellProps {
children: ReactNode
leftStatusbarItems?: readonly StatusbarItem[]
leftTitlebarTools?: readonly TitlebarTool[]
// Fixed-position overlays that must share <main>'s stacking context so pane
// resize handles (z-20) paint above them. The persistent terminal lives here:
// hoisting it to the root `overlays` layer (sibling of <main>, z above z-3)
// would cover every pane's drag handle.
mainOverlays?: ReactNode
onOpenSettings: () => void
overlays?: ReactNode
// Rails that sit at the window's left edge in the flipped layout but never
// force-collapse to hover-reveal overlays — so they cover the top-left traffic
// lights (and zero the titlebar inset) even below the collapse breakpoint.
previewPaneOpen?: boolean
statusbarItems?: readonly StatusbarItem[]
terminalPaneOpen?: boolean
titlebarTools?: readonly TitlebarTool[]
}
// Renderer-side fallback so layout snaps even when the main-process fullscreen event
// hasn't landed yet (e.g. dev reloads, before the IPC bridge is wired).
function subscribeWindowSize(cb: () => void) {
window.addEventListener('resize', cb)
window.addEventListener('fullscreenchange', cb)
return () => {
window.removeEventListener('resize', cb)
window.removeEventListener('fullscreenchange', cb)
}
}
const viewportIsFullscreen = () =>
window.innerWidth >= window.screen.width && window.innerHeight >= window.screen.height
export function AppShell({
children,
leftStatusbarItems,
leftTitlebarTools,
mainOverlays,
onOpenSettings,
overlays,
previewPaneOpen = false,
statusbarItems,
terminalPaneOpen = false,
titlebarTools
}: AppShellProps) {
const sidebarOpen = useStore($sidebarOpen)
const fileBrowserOpen = useStore($fileBrowserOpen)
const panesFlipped = useStore($panesFlipped)
const narrowViewport = useMediaQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)
const fileBrowserWidthOverride = useStore($paneWidthOverride(FILE_BROWSER_PANE_ID))
const connection = useStore($connection)
const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false)
const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen
// Every secondary window (new-session scratch, subagent watch, cmd-click
// pop-out) is a compact side panel — none of them carry the full titlebar
// tool cluster. Gate on isSecondaryWindow, never the narrower new-session flag.
const hideTitlebarControls = isSecondaryWindow()
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen)
// Width Windows/WSLg reserve for the native min/max/close overlay (zero on
// macOS, where window controls sit on the left and are reported via
// windowButtonPosition instead). The right tool cluster has to clear them.
// Prefer the EXACT width measured from the live Window Controls Overlay
// (precise + self-correcting across DPI/host themes); fall back to the static
// reservation the main process sends when the WCO API isn't available.
const measuredOverlayWidth = useWindowControlsOverlayWidth()
const staticOverlayWidth = connection?.nativeOverlayWidth ?? 0
const nativeOverlayWidth = measuredOverlayWidth ?? staticOverlayWidth
const titlebarToolsRight = nativeOverlayWidth > 0 ? `${nativeOverlayWidth}px` : '0.75rem'
// When the native window controls overlay our titlebar band — Windows and
// WSLg both paint Electron's Window Controls Overlay and report
// nativeOverlayWidth > 0 — the right rail's editor-style tab strip (which
// normally lives IN that band) would render at y=0 under the fixed titlebar
// tool cluster and collide with it. Drop the right rail one titlebar-height so
// it opens BELOW the band. macOS / plain Linux paint no overlay → 0 inset,
// layout byte-for-byte unchanged. Consumed as --right-rail-top-inset.
const rightRailTopInset = nativeOverlayWidth > 0 ? 'var(--titlebar-height)' : '0px'
// The inset clears the top-left titlebar buttons when nothing covers the
// window's left edge. Default layout: the sessions sidebar sits there.
// Flipped layout: the file browser does instead. Both force-collapse to a
// hover-reveal overlay (0px track) below the collapse breakpoint, so the edge
// is uncovered there regardless of their stored open state. A standalone
// session window renders no sidebar at all, so its edge is always uncovered.
const collapsibleLeftPaneOpen = panesFlipped ? fileBrowserOpen : sidebarOpen
// The terminal + preview rails never force-collapse, so when they're the
// leftmost open pane (flipped layout) they cover the edge even when narrow.
const persistentLeftPaneOpen = panesFlipped && (terminalPaneOpen || previewPaneOpen)
const leftEdgePaneOpen =
!isSecondaryWindow() && ((!narrowViewport && collapsibleLeftPaneOpen) || persistentLeftPaneOpen)
const titlebarContentInset = leftEdgePaneOpen
? 0
: titlebarControls.left + TITLEBAR_HEIGHT + Math.round(TITLEBAR_HEIGHT / 2)
// The static system cluster (haptics, profiles, settings, right-sidebar) is
// hardcoded in TitlebarControls. Pane-supplied tools (preview's group) render
// in a separate cluster anchored further left.
//
// Width math has to include the `gap-x-1` (0.25rem) between buttons:
// N buttons + (N - 1) inner gaps, plus one extra 0.25rem of breathing room
// between the pane-tool cluster and the system cluster so they don't sit
// flush against each other. Modeled as N gaps (N - 1 inner + 1 trailing)
// to keep the formula generic for any pane-tool count.
const SYSTEM_TOOL_COUNT = 4
const paneToolCount = titlebarTools?.filter(tool => !tool.hidden).length ?? 0
const systemToolsWidth = `calc(${SYSTEM_TOOL_COUNT} * (var(--titlebar-control-size) + 0.25rem))`
const fileBrowserWidth =
fileBrowserWidthOverride !== undefined ? `${fileBrowserWidthOverride}px` : FILE_BROWSER_DEFAULT_WIDTH
// Where the pane-tool cluster's right edge sits, measured from the inner
// titlebar padding (--titlebar-tools-right). Two anchors:
// - file-browser closed → flush against static cluster's left edge
// - file-browser open → flush against the file-browser pane's left edge
// (= preview pane's right edge)
const previewToolbarGap = fileBrowserOpen ? fileBrowserWidth : systemToolsWidth
// Used by the drag region to know where the rightmost interactive element
// ends. When pane tools are present, that's `gap + paneCount * controlSize
// + paneCount * 0.25rem` (the leftmost button is at `tools-right + gap +
// paneCount * (size + gap-x-1)`). Otherwise the static cluster's footprint
// is enough.
const titlebarToolsWidth =
paneToolCount > 0
? `calc(${previewToolbarGap} + ${paneToolCount} * (var(--titlebar-control-size) + 0.25rem))`
: systemToolsWidth
return (
<SidebarProvider
className="h-screen min-h-0 flex-col bg-background"
onOpenChange={setSidebarOpen}
open={sidebarOpen}
style={
{
// Alias for shadcn <Sidebar> descendants. Resolves to the chat-sidebar
// pane track via PaneShell's emitted --pane-chat-sidebar-width.
'--sidebar-width': 'var(--pane-chat-sidebar-width)',
'--titlebar-height': `${TITLEBAR_HEIGHT}px`,
'--titlebar-content-inset': `${titlebarContentInset}px`,
'--titlebar-controls-left': `${titlebarControls.left}px`,
'--titlebar-controls-top': `${titlebarControls.top}px`,
'--titlebar-tools-right': titlebarToolsRight,
'--titlebar-tools-width': titlebarToolsWidth,
// Drops the right rail below the titlebar band when the OS/host paints
// window controls over it (Windows/WSLg); 0px elsewhere.
'--right-rail-top-inset': rightRailTopInset,
// Anchor for the pane-tool cluster's right edge in TitlebarControls.
// Sourced from the layout store rather than the PaneShell-emitted
// --pane-*-width vars because the titlebar is a sibling of PaneShell
// and CSS variables resolve at the consumer's scope.
'--shell-preview-toolbar-gap': previewToolbarGap
} as CSSProperties
}
>
{!hideTitlebarControls && (
<TitlebarControls leftTools={leftTitlebarTools} onOpenSettings={onOpenSettings} tools={titlebarTools} />
)}
{nativeOverlayWidth > 0 && (
<div
aria-hidden
className="pointer-events-none fixed right-0 top-0 z-[4] h-(--titlebar-height) w-(--titlebar-tools-right) border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background)"
/>
)}
<main className="relative z-3 flex min-h-0 w-full flex-1 flex-col overflow-hidden transition-none">
<PaneShell className="min-h-0 flex-1">
<div
aria-hidden="true"
className="pointer-events-none absolute left-0 top-0 z-1 h-(--titlebar-height) w-(--titlebar-controls-left) [-webkit-app-region:drag]"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute top-0 z-1 h-(--titlebar-height) left-[calc(var(--titlebar-controls-left)+(var(--titlebar-control-size)*2)+0.75rem)] right-[calc(var(--titlebar-tools-right)+var(--titlebar-tools-width)+0.75rem)] [-webkit-app-region:drag]"
/>
{children}
</PaneShell>
{/* Fixed overlays scoped to main's stacking context (terminal). Rendered
after PaneShell so it paints over pane content, but its z stays under
the panes' z-20 resize handles, keeping every pane resizable. */}
{mainOverlays}
{/* The compact pop-out drops the statusbar — it's a scratch window, not
the full shell. */}
{!isSecondaryWindow() && <StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />}
</main>
{overlays}
{/* Keybind map dialog (titlebar ⌨ button / ⌘/). */}
<KeybindPanel />
{/* Mounted at the shell root (after overlays) so success/error toasts
surface above every route and overlay — not just the chat view. */}
<NotificationStack />
{/* Petdex floating mascot — in-window, always-on-top, reactive to agent
activity. Renders nothing unless a pet is installed + enabled. */}
<FloatingPet />
</SidebarProvider>
)
}
@@ -36,21 +36,27 @@ function useGatewayLogTail(): string[] {
useEffect(() => {
let cancelled = false
const load = () =>
getLogs({ file: 'gui', lines: LOG_TAIL })
.then(res => {
if (cancelled) {
return
}
// async: getLogs THROWS (not rejects) when the desktop bridge is missing
// (plain-browser mode) — a sync throw here would take down the root
// error boundary before the .catch even attaches.
const load = async () => {
try {
const res = await getLogs({ file: 'gui', lines: LOG_TAIL })
setLines(
res.lines
.map(line => line.trim())
.filter(line => line && !LOG_NOISE_RE.test(line))
.slice(-LOG_VISIBLE)
)
})
.catch(() => {})
if (cancelled) {
return
}
setLines(
res.lines
.map(line => line.trim())
.filter(line => line && !LOG_NOISE_RE.test(line))
.slice(-LOG_VISIBLE)
)
} catch {
// Bridge/gateway unavailable — keep the last tail.
}
}
void load()
const timer = window.setInterval(load, LOG_POLL_MS)
@@ -0,0 +1,6 @@
// The `GroupSetter` shape pages take as an extension-point prop (SkillsView,
// MessagingView, ChatPreviewRail, …). The live implementation is the
// registry-backed `registryGroupSetter` in app/contrib/panes.tsx.
type Side = 'left' | 'right'
export type GroupSetter<T> = (id: string, items: readonly T[], side?: Side) => void
@@ -22,9 +22,13 @@ import {
$connection,
$currentCwd,
$currentUsage,
$selectedStoredSessionId,
$sessions,
$sessionStartedAt,
$turnStartedAt,
sessionMatchesStoredId
} from '@/store/session'
import { $focusedRuntimeId, $focusedSessionState, $focusedStoredSessionId } from '@/store/session-states'
import { $subagentsBySession, activeSubagentCount, failedSubagentCount } from '@/store/subagents'
import { $gatewayRestarting } from '@/store/system-actions'
import {
@@ -40,6 +44,8 @@ import type { StatusResponse } from '@/types/hermes'
import { CRON_ROUTE } from '../../routes'
import type { StatusbarItem } from '../statusbar-controls'
const EMPTY_USAGE = { calls: 0, input: 0, output: 0, total: 0 } as const
function workspaceLabel(cwd: string): string {
const normalized = cwd.replace(/[\\/]+$/, '')
const leaf = normalized.split(/[\\/]/).filter(Boolean).pop()
@@ -80,15 +86,15 @@ export function useStatusbarItems({
const { t } = useI18n()
const copy = t.shell.statusbar
const fileMenu = t.fileMenu
const activeSessionId = useStore($activeSessionId)
const primaryActiveSessionId = useStore($activeSessionId)
const activeGatewayProfile = useStore($activeGatewayProfile)
const terminalTakeover = useStore($terminalTakeover)
const busy = useStore($busy)
const primaryBusy = useStore($busy)
const currentCwd = useStore($currentCwd)
const currentUsage = useStore($currentUsage)
const primaryUsage = useStore($currentUsage)
const gatewayRestarting = useStore($gatewayRestarting)
const sessionStartedAt = useStore($sessionStartedAt)
const turnStartedAt = useStore($turnStartedAt)
const primarySessionStartedAt = useStore($sessionStartedAt)
const primaryTurnStartedAt = useStore($turnStartedAt)
const subagentsBySession = useStore($subagentsBySession)
const updateStatus = useStore($updateStatus)
const updateApply = useStore($updateApply)
@@ -97,11 +103,42 @@ export function useStatusbarItems({
const desktopVersion = useStore($desktopVersion)
const connection = useStore($connection)
// The FOCUSED session (interacted tile, else the primary — the same
// derivation the titlebar title follows): every session-scoped readout
// below (context count, timers, busy pulse) tracks it, so clicking into a
// tile makes the statusbar describe THAT session.
const focusedStoredSessionId = useStore($focusedStoredSessionId)
const focusedRuntimeId = useStore($focusedRuntimeId)
const focusedState = useStore($focusedSessionState)
const sessions = useStore($sessions)
const selectedStoredSessionId = useStore($selectedStoredSessionId)
const primaryFocused = !focusedStoredSessionId || focusedStoredSessionId === selectedStoredSessionId
const activeSessionId = primaryFocused ? primaryActiveSessionId : (focusedRuntimeId ?? null)
const busy = primaryFocused ? primaryBusy : Boolean(focusedState?.busy)
// EMPTY_USAGE (module constant) keeps the fallback referentially stable —
// a fresh `{...}` each render would bust the usage-label memos below.
const currentUsage = primaryFocused ? primaryUsage : (focusedState?.usage ?? EMPTY_USAGE)
const turnStartedAt = primaryFocused ? primaryTurnStartedAt : (focusedState?.turnStartedAt ?? null)
// A tile's session-start comes from its stored row (the cache only knows
// runtime state); seconds → ms.
const focusedRow = focusedStoredSessionId
? sessions.find(s => sessionMatchesStoredId(s, focusedStoredSessionId))
: null
const sessionStartedAt = primaryFocused
? primarySessionStartedAt
: focusedRow?.started_at
? focusedRow.started_at * 1000
: null
const contextUsage = useMemo(() => usageContextLabel(currentUsage), [currentUsage])
const contextBar = useMemo(() => contextBarLabel(currentUsage), [currentUsage])
const approvalModeItem = useApprovalModeStatusbarItem(activeGatewayProfile, requestGateway)
const gatewayMenuContent = useMemo(
() => (close: () => void) => (
<GatewayMenuPanel
+14 -5
View File
@@ -6,14 +6,16 @@ import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { Kbd, KbdCombo } from '@/components/ui/kbd'
import { useContributions } from '@/contrib/react/use-contributions'
import { useI18n } from '@/i18n'
import {
KEYBIND_ACTIONS,
allKeybindActions,
KEYBIND_CATEGORIES,
KEYBIND_PANEL_ACTION,
KEYBIND_READONLY,
type KeybindActionMeta,
type KeybindReadonly
type KeybindReadonly,
KEYBINDS_AREA
} from '@/lib/keybinds/actions'
import { formatCombo } from '@/lib/keybinds/combo'
import { arraysEqual } from '@/lib/storage'
@@ -22,6 +24,7 @@ import {
$capture,
$keybindPanelOpen,
beginCapture,
bindingsFor,
closeKeybindPanel,
conflictsFor,
endCapture,
@@ -36,6 +39,9 @@ export function KeybindPanel() {
const bindings = useStore($bindings)
const k = t.keybinds
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set())
// Subscribe so contributed actions appear/disappear live in the map.
useContributions(KEYBINDS_AREA)
const actionList = allKeybindActions()
const openCombo = bindings[KEYBIND_PANEL_ACTION]?.[0]
@@ -74,7 +80,7 @@ export function KeybindPanel() {
{/* Body */}
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-1.5">
{KEYBIND_CATEGORIES.map(category => {
const actions = KEYBIND_ACTIONS.filter(
const actions = actionList.filter(
action => action.category === category && action.id !== KEYBIND_PANEL_ACTION
)
@@ -139,9 +145,12 @@ function KeybindRow({ action }: { action: KeybindActionMeta }) {
const bindings = useStore($bindings)
const capture = useStore($capture)
const combos = bindings[action.id] ?? []
// bindingsFor resolves stored overrides for late-registered (contributed)
// actions too — $bindings only carries built-ins, so a raw lookup would show
// the default instead of the user's rebinding for a plugin/contrib action.
const combos = bindingsFor(action.id, bindings)
const capturing = capture === action.id
const label = k.actions[action.id] ?? action.id
const label = k.actions[action.id] ?? action.label ?? action.id
const isDefault = arraysEqual(combos, [...action.defaults])
const conflict = combos
@@ -25,6 +25,10 @@ export interface StatusbarMenuItem {
export interface StatusbarItem {
id: string
/** Escape hatch: render an arbitrary node into the bar (own state, tooltip,
* events). When set, it OWNS the slot — label/variant/onSelect are ignored.
* This is how a plugin drops a full stateful React component into the bar. */
render?: () => ReactNode
label?: ReactNode
detail?: ReactNode
icon?: ReactNode
@@ -92,6 +96,11 @@ export function StatusbarControls({ className, leftItems = [], items = [], ...pr
function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate: ReturnType<typeof useNavigate> }) {
const [menuOpen, setMenuOpen] = useState(false)
// Render escape hatch: the contribution owns its own chrome/state/tooltip.
if (item.render) {
return <>{item.render()}</>
}
const content = (
<>
{item.icon}
@@ -100,7 +109,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
</>
)
if (item.variant === 'menu' && (item.menuContent || (item.menuItems && item.menuItems.length > 0))) {
if (item.variant === 'menu' && (item.menuContent || !!item.menuItems?.length)) {
// The `Tip` helper can't wrap a menu: its TooltipTrigger needs a DOM child,
// but DropdownMenu's Root renders no element, so the hover listeners never
// land on the button and the tooltip silently never shows. Compose the two
@@ -1,7 +1,9 @@
import { useStore } from '@nanostores/react'
import type { ComponentProps, ReactNode } from 'react'
import { type ComponentProps, type MouseEvent, type ReactNode, useEffect, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { toggleLayoutEditMode } from '@/components/pane-shell/edit-mode'
import { resetLayoutTree } from '@/components/pane-shell/tree/store'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
@@ -9,10 +11,8 @@ import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics'
import { toggleKeybindPanel } from '@/store/keybinds'
import {
$fileBrowserOpen,
$panesFlipped,
$sidebarOpen,
toggleFileBrowserOpen,
togglePanesFlipped,
@@ -32,7 +32,7 @@ export interface TitlebarTool {
hidden?: boolean
href?: string
icon: ReactNode
onSelect?: () => void
onSelect?: (event?: MouseEvent) => void
title?: string
to?: string
}
@@ -46,14 +46,60 @@ interface TitlebarControlsProps extends ComponentProps<'div'> {
onOpenSettings: () => void
}
/**
* The layout button's glyph. Morphs into its composite reset form — the
* layout icon wearing a small counter-clockwise arrow badge ("layout, back
* to how it was") — ONLY while the pointer is on the button AND ⌘/Ctrl is
* held: hover gates via CSS (`group/tool` on the button), the modifier via
* the window listener. Pressing the modifier elsewhere changes nothing.
*/
function LayoutGlyph({ modHeld }: { modHeld: boolean }) {
return (
<>
<span className={cn('inline-flex', modHeld && 'group-hover/tool:hidden')}>
<Codicon name="layout" />
</span>
<span className={cn('relative hidden', modHeld && 'group-hover/tool:inline-flex')}>
<Codicon name="layout" />
<span className="absolute -bottom-1 -right-1.5 grid place-items-center rounded-full bg-(--ui-bg-chrome) p-px">
<Codicon className="-scale-x-100" name="refresh" size="0.5625rem" />
</span>
</span>
</>
)
}
/** Live ⌘/Ctrl tracking — mod-click affordances telegraph themselves (the
* layout button morphs into its reset form while the modifier is down). */
function useModifierHeld(): boolean {
const [held, setHeld] = useState(false)
useEffect(() => {
const sync = (event: KeyboardEvent) => setHeld(event.metaKey || event.ctrlKey)
const clear = () => setHeld(false)
window.addEventListener('keydown', sync)
window.addEventListener('keyup', sync)
window.addEventListener('blur', clear)
return () => {
window.removeEventListener('keydown', sync)
window.removeEventListener('keyup', sync)
window.removeEventListener('blur', clear)
}
}, [])
return held
}
export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: TitlebarControlsProps) {
const { t } = useI18n()
const navigate = useNavigate()
const location = useLocation()
const modHeld = useModifierHeld()
const hapticsMuted = useStore($hapticsMuted)
const fileBrowserOpen = useStore($fileBrowserOpen)
const sidebarOpen = useStore($sidebarOpen)
const panesFlipped = useStore($panesFlipped)
const toggleHaptics = () => {
if (!hapticsMuted) {
@@ -67,14 +113,13 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
}
}
// Each titlebar button controls the pane physically on its side, so a flip
// swaps which pane each one toggles. Default: sessions left, file browser
// right. Flipped: file browser left, sessions right. Sidebar toggles never
// carry an active highlight — they're plain show/hide affordances.
const fileBrowserEdge = { open: fileBrowserOpen, toggle: toggleFileBrowserOpen }
const sessionsEdge = { open: sidebarOpen, toggle: toggleSidebarOpen }
const leftEdge = panesFlipped ? fileBrowserEdge : sessionsEdge
const rightEdge = panesFlipped ? sessionsEdge : fileBrowserEdge
// POSITIONAL toggles: each button shows/hides everything on its physical
// side of the main zone (the layout tree collapses the whole side), so they
// stay correct through flips and rearranges. $sidebarOpen ≙ left side,
// $fileBrowserOpen ≙ right side. Never an active highlight — plain
// show/hide affordances.
const leftEdge = { open: sidebarOpen, toggle: toggleSidebarOpen }
const rightEdge = { open: fileBrowserOpen, toggle: toggleFileBrowserOpen }
const leftToolbarTools: TitlebarTool[] = [
{
@@ -111,6 +156,26 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
// Static system tools — always pinned to the screen's right edge.
const systemTools: TitlebarTool[] = [
{
className: 'group/tool',
// Hover + held ⌘/Ctrl morphs the glyph into its reset form (see
// LayoutGlyph) — the mod-click telegraphs itself before it happens.
icon: <LayoutGlyph modHeld={modHeld} />,
id: 'layout',
label: t.titlebar.layoutEditor,
onSelect: event => {
if (event?.metaKey || event?.ctrlKey) {
triggerHaptic('warning')
resetLayoutTree()
return
}
triggerHaptic('open')
toggleLayoutEditMode()
},
title: t.titlebar.layoutEditorTitle
},
{
active: hapticsMuted,
icon: <Codicon name={hapticsMuted ? 'mute' : 'unmute'} />,
@@ -118,15 +183,6 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
label: hapticsMuted ? t.titlebar.unmuteHaptics : t.titlebar.muteHaptics,
onSelect: toggleHaptics
},
{
icon: <Codicon name="keyboard" />,
id: 'keybinds',
label: t.titlebar.openKeybinds,
onSelect: () => {
triggerHaptic('open')
toggleKeybindPanel()
}
},
{
icon: <Codicon name="settings-gear" />,
id: 'settings',
@@ -147,8 +203,6 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
}
const visibleSystemTools = systemTools.filter(tool => !tool.hidden)
const settingsTool = visibleSystemTools.find(tool => tool.id === 'settings')
const visibleSystemToolsBeforeSettings = visibleSystemTools.filter(tool => tool.id !== 'settings')
const visiblePaneTools = tools.filter(tool => !tool.hidden)
return (
@@ -187,10 +241,9 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
aria-label={t.shell.appControls}
className="fixed right-(--titlebar-tools-right) top-(--titlebar-controls-top) z-70 flex flex-row items-center justify-end gap-x-1 pointer-events-auto select-none [-webkit-app-region:no-drag]"
>
{visibleSystemToolsBeforeSettings.map(tool => (
{visibleSystemTools.map(tool => (
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
))}
{settingsTool && <TitlebarToolButton navigate={navigate} tool={settingsTool} />}
<TitlebarToolButton navigate={navigate} tool={rightSidebarTool} />
</div>
</>
@@ -228,12 +281,12 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
aria-pressed={tool.active ?? undefined}
className={className}
disabled={tool.disabled}
onClick={() => {
onClick={event => {
if (tool.to) {
navigate(tool.to)
}
tool.onSelect?.()
tool.onSelect?.(event)
}}
onPointerDown={event => event.stopPropagation()}
size="icon-titlebar"
@@ -1,39 +0,0 @@
import { useCallback, useMemo, useState } from 'react'
type Side = 'left' | 'right'
type Groups<T> = Record<Side, Record<string, readonly T[]>>
export type GroupSetter<T> = (id: string, items: readonly T[], side?: Side) => void
interface GroupRegistry<T> {
flat: { left: T[]; right: T[] }
set: GroupSetter<T>
}
export function useGroupRegistry<T>(): GroupRegistry<T> {
const [groups, setGroups] = useState<Groups<T>>({ left: {}, right: {} })
const set = useCallback<GroupSetter<T>>((id, items, side = 'right') => {
setGroups(current => {
const next = { ...current, [side]: { ...current[side] } }
if (items.length === 0) {
delete next[side][id]
} else {
next[side][id] = items
}
return next
})
}, [])
const flat = useMemo(
() => ({
left: Object.values(groups.left).flat(),
right: Object.values(groups.right).flat()
}),
[groups]
)
return { flat, set }
}

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