Compare commits

...
Author SHA1 Message Date
Miguel PalhasandClaude Opus 5 9ba1078386 feat(maestro): port the structured pane to React
Closes #246. The transcript, composer and approval controls move from
structured.ts/structuredRender.ts into components under
web/src/components/structured/, against docs/STRUCTURED-VIEW.md: eight row
kinds, both projections, the single-active approval nonce, and diff rendering
with its own line counts.

The pure layers stay shared — structuredDiff.ts computes the diff,
structuredRender.ts still sanitizes markdown — and model.ts holds the
client-side decisions (activity grouping, notice classification, offered
decisions, `/` completion) so they are testable without a DOM.

Like TerminalPane, the pane draws the body only; pane chrome reads the
connection through onStatusChange.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 00:41:18 +01:00
Miguel PalhasandClaude Opus 4.6 f0a1774e4d fix(maestro): integration review fixes for wave 3
- Add #root div to index.html so React entry point can mount
- Replace unsafe type cast in Sidebar with typed CatalogStore selector

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-09 00:29:05 +01:00
Miguel Palhas ac720eb266 Closes #245: port terminal pane to React 2026-09-09 00:26:16 +01:00
Miguel Palhas 0ba83c3d8f Closes #245: port terminal pane (xterm attach) to React
TerminalPane is a thin ref-based wrapper: xterm lifecycle lives in a
single mount effect keyed on sessionId, tearing the socket and terminal
down on cleanup. Preserves webglRenderer.ts's page-wide WebGL context
budget (acquire on mount, release on unmount, re-acquire on context
loss while still mounted) and reconnect.ts's exponential 500ms->10s
backoff. keys.ts's mobile modifier encoding moves into KeyBar.tsx.

Pane chrome (title, disconnect, sessions drawer) stays out of scope;
composition happens one level up.
2026-09-09 00:24:08 +01:00
Miguel Palhas e8e98baf4d feat(maestro): port the sidebar to React
Ports catalog.ts's tree renderer to components under
src/components/sidebar/, following docs/SIDEBAR.md: two-line cards
behind a 5px activity rail, adaptive meta line (duration, detail,
program, diff stat), collapsible folders, j/k walk, inline rename and a
right-click menu.

The nav walk reads a pure model (model.ts) rather than the DOM, so the
rule that an expanded folder header is skipped and a collapsed one is
not is testable without a render. Rail motion needs @keyframes, so it
stays in sidebar.css rather than the Tailwind config.

Closes #244
2026-09-09 00:21:13 +01:00
Miguel Palhas b286323c9c Closes #243: port theme system and design tokens to Tailwind config 2026-09-09 00:14:11 +01:00
Miguel Palhas 71ec7e073c Closes #241: port CatalogStore and ConnectionPool to Zustand stores 2026-09-09 00:14:08 +01:00
Miguel Palhas 46bae13212 feat(maestro): map design tokens to Tailwind config, port theme to React
Wires tailwind.config.ts to every custom-property token in styles.css
(spacing, OKLCH colours, radii, z-index, sizing, durations) so Tailwind
utilities resolve to the same variables the vanilla CSS uses. Adds
ThemeContext/useTheme as a React port of theme.ts, keeping the same
localStorage keys and data-theme/data-palette attributes so it can run
alongside the vanilla shell.
2026-09-09 00:12:59 +01:00
Miguel PalhasandClaude Sonnet 5 20d303278d feat(web): port CatalogStore and ConnectionPool to Zustand stores
Ports events.ts's CatalogStore and connections.ts's ConnectionPool to
Zustand stores (src/stores/catalogStore.ts, connectionStore.ts) built
via factory functions so tests can inject fake sockets/schedulers, same
as the originals. Reconnect/backoff schedule, the 6-max-parked LRU, and
the #140 view-reconciliation race handling all carry over unchanged.

The vanilla events.ts/connections.ts stay in place for the current app;
this is prep for the React port (#241).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 00:12:09 +01:00
Miguel Palhas 589e0144e2 Closes #239: scaffold React + Tailwind + Zustand 2026-09-09 00:03:04 +01:00
Miguel Palhas 0061378193 feat(maestro): scaffold React + Tailwind + Zustand
Adds react, react-dom, zustand, tailwindcss, @tailwindcss/vite as a new
skeleton alongside the existing vanilla SPA. New src/index.tsx entry
point (unused until wired into index.html in a later issue); main.ts
stays the active entry.
2026-09-09 00:01:33 +01:00
Miguel Palhas 1cf4825b1d fix(web): collapse agent commentary
ci / web (push) Failing after 6s
ci / flake (push) Successful in 3m31s
ci / rust (push) Successful in 6m18s
2026-09-08 17:21:08 +01:00
Miguel PalhasandClaude Opus 4.6 f799aaf564 fix(maestro): restore focus after surgical tool-row patch
ci / web (push) Successful in 41s
ci / flake (push) Successful in 4m36s
ci / rust (push) Successful in 8m42s
The patch path did a DOM replaceWith and returned early, skipping the
focus-restoration logic in rebuildTranscript. Now saves and restores
tool-head focus within the surgical swap.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 15:37:58 +01:00
naps62-yolo 5af5ceb9a8 feat(maestro): trigger haiku naming for ACP sessions (#206)
ci / web (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / flake (push) Has been cancelled
2026-09-08 15:35:27 +01:00
Miguel PalhasandClaude Opus 4.6 fbdcec6f69 chore(maestro): allow cargo test in local settings
ci / flake (push) Successful in 3m39s
ci / rust (push) Successful in 6m32s
ci / web (push) Failing after 27s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 15:05:41 +01:00
Miguel PalhasandClaude Opus 4.6 6af4cda650 fix(maestro): gitroot tests tolerate /tmp inside a repo
The sandbox has /tmp/.git, so the ancestor walk finds a git root
above the tempdir. Weakened the assertion: the result must not point
inside the .git-free tempdir, but may point above it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 14:50:50 +01:00
Miguel Palhas ab4beea2d9 Merge branch 'trombone-2'
ci / web (push) Failing after 27s
ci / rust (push) Failing after 1m1s
ci / flake (push) Successful in 3m18s
2026-09-08 14:36:21 +01:00
Miguel Palhas 44ef5619bf Merge branch 'staccato-2'
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / flake (push) Has been cancelled
2026-09-08 14:36:20 +01:00
Miguel Palhas 6adb5dabf0 Merge branch 'harpsichord-2'
ci / web (push) Failing after 31s
ci / rust (push) Failing after 56s
ci / flake (push) Successful in 4m3s
2026-09-08 14:33:49 +01:00
Miguel Palhas e9c9e1c647 Merge remote-tracking branch 'origin/main' into staccato-2 2026-09-08 14:33:26 +01:00
Miguel PalhasandClaude Opus 4.6 4167b21fe9 feat(maestro): render queued messages as blurred chat bubbles
Queued prompts now appear inline in the transcript as faded user bubbles
instead of a pill badge above the composer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 14:33:22 +01:00
Miguel PalhasandClaude Opus 4.6 b9f8f02e11 fix(maestro): fix diff double-spacing and add +/- markers
Block spans + literal \n text nodes inside <pre> caused empty lines
between every diff line. Drop the text nodes, add unified-diff prefixes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 14:32:51 +01:00
Miguel PalhasandClaude Opus 4.6 f36b7808a7 feat(maestro): show image previews in user message bubbles
ci / rust (push) Waiting to run
ci / web (push) Waiting to run
ci / flake (push) Waiting to run
Uploaded images now render as thumbnails in the user bubble instead of
showing only the server file path as text.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 14:32:42 +01:00
Miguel PalhasandClaude Opus 4.6 c2d427691d fix(web): stop tool-group flashing and config-menu closure on updates
Reuse existing tool-group DOM elements in rebuildTranscript instead of
tearing them down — preserves CSS transition state and focus. Defer
config-menu DOM rebuild while the menu is open so <select> dropdowns
survive background state updates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 14:26:14 +01:00
Miguel Palhas 50b862b1fa Merge branch 'vivace'
ci / web (push) Failing after 29s
ci / rust (push) Failing after 50s
ci / flake (push) Successful in 3m30s
2026-09-08 13:47:19 +01:00
Miguel Palhas 4668cf8ece Merge remote-tracking branch 'origin/main' into recorder-4
ci / rust (push) Failing after 42s
ci / web (push) Failing after 20s
ci / flake (push) Successful in 3m39s
2026-09-08 13:46:03 +01:00
Miguel PalhasandClaude Opus 4.6 638155de03 feat(maestro): move activity indicator into scroll area
The "Running 1m ..." status now sits at the tail of the chat
history instead of pinned above the input, matching Claude Code's
placement.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 13:45:56 +01:00
Miguel Palhas 1c0fff5507 Merge remote-tracking branch 'origin/main' into vivace 2026-09-08 13:45:48 +01:00
Miguel PalhasandClaude Opus 4.6 10c9db6d87 feat(web): show last agent text outside collapsed group during active turn
Intermediate narration was hidden inside the collapsed tool group
while the agent was still working. The last AgentText/AgentThought
now stays visible as current status, absorbed only when the turn ends
or newer text replaces it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 13:45:45 +01:00
Miguel PalhasandClaude Opus 4.6 b339fecad3 feat(maestro): route maestro send through ACP for structured sessions
ci / rust (push) Waiting to run
ci / web (push) Waiting to run
ci / flake (push) Waiting to run
Structured sessions were rejected by `send_input` with NotTerminal.
Now `run_send` checks the session view and routes to
`send_structured_prompt` for structured sessions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 13:43:01 +01:00
Miguel PalhasandClaude Opus 4.6 3c10a3a08a fix(maestro): skip Starting sessions in liveness watcher
ci / web (push) Failing after 21s
ci / flake (push) Successful in 3m30s
ci / rust (push) Successful in 6m8s
A structured session still booting its adapter has no ACP handle and
no tmux pane, so the liveness pass misclassified it as a vanished
terminal session and reaped it. Claude Code adapters take long enough
to boot that the 2s liveness tick fires before the handshake finishes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 07:25:48 +01:00
Miguel Palhas fd79c8116a Merge remote-tracking branch 'origin/main' into tuba
ci / web (push) Failing after 7s
ci / flake (push) Successful in 3m15s
ci / rust (push) Successful in 5m54s
2026-09-07 21:49:31 +01:00
Miguel PalhasandClaude Opus 4.6 cb8b93f178 test(maestro): update tests for async structured spawn
Tests now assert `Starting` after create and use `wait_for_ready`
before checking post-handshake state. Unsupported command test
expects `BadRequest` instead of a persisted Dead session.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 21:28:22 +01:00
Miguel PalhasandClaude Opus 4.6 60e57fde31 feat(web): collapse resolved approval rows
ci / web (push) Failing after 25s
ci / flake (push) Successful in 3m36s
ci / rust (push) Successful in 6m12s
Resolved permission boxes vanish from the transcript instead of
lingering as "answered" cards. When a turn has 2+ tools the resolved
approval is absorbed into the tool group as a compact italic line;
otherwise it is hidden entirely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 21:20:31 +01:00
Miguel PalhasandClaude Opus 4.6 8d35f7f8c8 feat(maestro): async structured session spawn
Register a structured session immediately with `activity: Starting`
and run the ACP handshake in a background task. The HTTP caller gets
an instant response; the user can begin typing a prompt while the
adapter boots.

- Add `ActivityState::Starting` to the protocol
- Open the event log eagerly so the structured WS can connect during
  boot (empty transcript + idle state)
- `queue_acp_prompt` stashes a prompt while `Starting`; the background
  task drains it on successful handshake
- `apply_structured_activity` skips overwriting `Starting`
- `subscribe_acp_projection` allows connecting during `Starting`
- TUI/CLI map `Starting` to idle colour / "starting" label

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 21:08:08 +01:00
Miguel Palhas bc0fd6ee60 Merge branch 'etude'
ci / web (push) Failing after 20s
ci / flake (push) Successful in 3m32s
ci / rust (push) Successful in 6m3s
2026-09-07 20:56:24 +01:00
Miguel PalhasandClaude Opus 4.6 ed5a088299 feat(web): equal-width project cards, drop worktree counters
Project boxes now use a fixed two-column grid so every card is the
same width. The worktree count line is removed — the information
lives in the worktree picker that follows a project selection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 20:48:43 +01:00
Miguel Palhas 9c77965c51 Merge remote-tracking branch 'origin/main' into arpeggio
ci / web (push) Failing after 29s
ci / rust (push) Successful in 6m48s
ci / flake (push) Successful in 4m8s
2026-09-07 20:33:38 +01:00
Miguel PalhasandClaude Opus 4.6 49df4c2e9e fix(web): surgical DOM patch for tool rows to stop flashing
Patch deltas on existing rows now swap just the affected element via
replaceWith() instead of tearing down and rebuilding the entire
transcript. Stops the visible flash and preserves text selection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 20:33:34 +01:00
Miguel PalhasandClaude Opus 4.6 65d7186e1c feat(web): harness cards with tool logos lead the spawn dialog
ci / rust (push) Successful in 8m28s
ci / web (push) Successful in 25s
ci / flake (push) Successful in 3m27s
Move the harness/command choice above the project picker so the
operator decides what runs before where. Replace the flat command
tiles with larger centered cards that render each tool's icon from
toolIcon.ts at 24x24. The Custom escape hatch gets a three-dot
ellipsis icon and a dashed border to read as secondary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 20:29:35 +01:00
Miguel PalhasandClaude Opus 4.6 47654aa434 feat(web): gate sidebar hover preview on ctrl
ci / rust (push) Failing after 5s
ci / web (push) Successful in 26s
ci / flake (push) Successful in 3m0s
Plain hover no longer previews — ctrl+hover does.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 15:22:58 +01:00
Miguel PalhasandClaude Opus 4.6 6ca2ab1707 merge: resolve conflict with main's prompt queue
ci / web (push) Successful in 30s
ci / flake (push) Successful in 3m38s
ci / rust (push) Successful in 6m18s
Keep icon cancel button, always-"Send" label, and queue status span
from main. Drop unused `active` local after label simplification.
Update test expectation to match.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 14:35:47 +01:00
Miguel PalhasandClaude Opus 4.6 b0b0160ccb feat(web): align composer and activity to content width, icon cancel button
Move max-width constraint from .structured-rows up to .structured-main
so the activity banner and composer match the transcript width. Replace
text cancel button with a stop-sign icon inside a spinning ring. Drop
the accent border-top on composer focus.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 14:32:56 +01:00
naps62 c4cd8a691e Merge pull request 'fix(web): queue structured prompts' (#204) from quaver into main
ci / web (push) Successful in 30s
ci / flake (push) Successful in 3m46s
ci / rust (push) Successful in 6m33s
Reviewed-on: #204
2026-09-07 12:28:49 +01:00
Miguel Palhas 0381a571f2 Merge remote-tracking branch 'origin/main' into brillante
ci / web (push) Failing after 6s
ci / flake (push) Successful in 4m23s
ci / rust (push) Successful in 7m8s
2026-09-07 11:42:28 +01:00
Miguel PalhasandClaude Opus 4.6 8f3d47c49e feat(web): absorb intermediate agent text into tool groups
Intermediate agent text/thought rows between tool calls in the same
turn now render inside the collapsed tool group instead of after it,
preserving chronological order. The last agent text (no subsequent
tool) stays outside as the prominent final answer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 11:42:25 +01:00
Miguel Palhas ba0c54759b fix(tui): complete test presets
ci / web (pull_request) Successful in 27s
ci / flake (pull_request) Successful in 47s
ci / rust (pull_request) Successful in 7m3s
2026-09-07 11:39:51 +01:00
Miguel PalhasandClaude Opus 4.6 78912e7925 fix(tui): add missing auto_approve field in draft test
ci / web (push) Successful in 27s
ci / flake (push) Successful in 3m30s
ci / rust (push) Successful in 6m1s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 11:32:32 +01:00
Miguel PalhasandClaude Opus 4.6 fef9be6c01 feat(web): cap structured view width for readability
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 11:30:52 +01:00
Miguel Palhas 5387b29049 Merge remote-tracking branch 'origin/main' into quaver
ci / web (pull_request) Successful in 30s
ci / flake (pull_request) Successful in 49s
ci / rust (pull_request) Failing after 52s
2026-09-07 11:25:33 +01:00
Miguel PalhasandClaude Opus 4.6 88afd0df3f feat(maestro): auto-approve ACP permissions via preset config
ci / web (push) Failing after 6s
ci / rust (push) Failing after 51s
ci / flake (push) Successful in 2m41s
Structured sessions ignored the --dangerously-skip-permissions flags in
preset commands because the daemon discards CLI flags when spawning the
ACP adapter binary. This adds an `auto_approve` bool to SpawnPreset and
CreateSessionRequest so the operator can declare it in config.toml:

  { id = "claude", command = "claude", auto_approve = true }

When set, the daemon's permission pump immediately responds AllowAlways
to every ACP permission request without surfacing it to clients.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 11:19:24 +01:00
Miguel Palhas df51aa7be9 Merge remote-tracking branch 'origin/main' into quaver
ci / web (pull_request) Successful in 27s
ci / flake (pull_request) Successful in 46s
ci / rust (pull_request) Successful in 5m16s
# Conflicts:
#	web/src/structured.ts
2026-09-07 11:06:13 +01:00
naps62-yolo ac8a1b319d feat(web): redesign spawn dialog advanced area (#205)
ci / web (push) Successful in 40s
ci / flake (push) Successful in 3m51s
ci / rust (push) Successful in 6m26s
2026-09-07 10:50:44 +01:00
Miguel PalhasandClaude Opus 4.6 17a57269f7 fix(maestro): satisfy field_reassign_with_default lint
ci / rust (push) Successful in 9m13s
ci / web (push) Failing after 7s
ci / flake (push) Successful in 3m56s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 10:47:07 +01:00
Miguel Palhas 43bfda5a60 fix(web): queue structured prompts
ci / web (pull_request) Successful in 47s
ci / flake (pull_request) Successful in 1m17s
ci / rust (pull_request) Successful in 9m35s
2026-09-07 10:42:49 +01:00
Miguel Palhas 6ffbca3596 Merge remote-tracking branch 'origin/main' into recorder-2 2026-09-07 10:41:59 +01:00
Miguel PalhasandClaude Opus 4.6 2cde58b42f fix(web): compact tool-call groups in structured view
Drop the per-tool name list from group headers (redundant with the rows
below), remove individual borders inside groups, and tighten padding so
20-tool blocks scan as a dense list instead of a wall of cards.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 10:41:55 +01:00
Miguel Palhas 721c34aa11 Merge remote-tracking branch 'origin/main' into tritone
ci / web (push) Successful in 28s
ci / rust (push) Failing after 1m4s
ci / flake (push) Successful in 3m4s
2026-09-07 10:40:10 +01:00
Miguel PalhasandClaude Opus 5 a7d8b5e22f fix(maestro): keep the draft first when the composer wraps
ci / web (push) Successful in 28s
ci / flake (push) Successful in 3m24s
ci / rust (push) Successful in 6m8s
The attach button precedes the draft in the DOM, so under the ≤520px wrap
rule it took a line of its own above the textarea. An explicit order puts
the draft back on the first line and groups every control below it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 10:27:46 +01:00
Miguel Palhas 2f695e1354 Merge remote-tracking branch 'origin/main' into balalaika 2026-09-07 10:25:40 +01:00
Miguel PalhasandClaude Opus 5 b4d4b0712c fix(maestro): keep the composer's one-row layout
Reverts the stacked control strip from f78aa13. The floating card is still
gone — flush band, one hairline against the transcript that doubles as the
focus indicator — but the composer keeps its own `--surface` fill so it
still reads as distinct from the pane, and the draft, gear and Send stay on
one bottom-aligned row.

At phone width that row cannot hold a draft plus three controls, so it
wraps: the draft takes a full line and the controls drop below it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 10:23:15 +01:00
Miguel Palhas b949cffb9e feat(tui): refine structured transcript 2026-09-07 10:18:19 +01:00
Miguel PalhasandClaude Opus 5 f78aa13b36 fix(maestro): flatten structured composer
The composer was a floating bordered card inset from the pane edge, and
its send button was bottom-aligned beside the textarea, so it sank as the
draft grew.

It is now a flush band at the foot of the pane: one hairline rule against
the transcript, which doubles as the focus indicator. The textarea spans
the full width and the controls sit in a fixed strip below it, so the send
button keeps its place however tall the draft grows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 10:16:56 +01:00
91 changed files with 10785 additions and 593 deletions
+18
View File
@@ -0,0 +1,18 @@
{
"permissions": {
"allow": [
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git fetch:*)",
"Bash(git merge:*)",
"Bash(npx tsc:*)",
"Bash(npm run build:*)",
"Bash(npm install:*)",
"Bash(./node_modules/.bin/tsc:*)",
"Bash(npm test:*)",
"Bash(cargo check:*)",
"Bash(cargo clippy:*)",
"Bash(cargo test:*)"
]
}
}
+2 -2
View File
@@ -41,8 +41,8 @@ pub enum Command {
Attach(AttachArgs),
/// Kill a session by ID.
Kill(KillArgs),
/// Type text into a running session, as if it had been typed on an
/// attached terminal.
/// Send text to a running session. Terminal sessions receive raw
/// keystrokes; structured sessions receive an ACP prompt.
Send(SendArgs),
/// Install or update the agent activity hook (#106).
Hooks(HooksArgs),
+29 -15
View File
@@ -57,6 +57,7 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
env,
metadata,
worktree,
auto_approve: false,
};
client.create_session(&body).await
}
@@ -129,9 +130,12 @@ pub fn resolve_session(sessions: &[Session], needle: &str) -> Result<uuid::Uuid,
/// `maestro send`. Answers with the session that was typed into, so the
/// caller can name it once the daemon has accepted the text.
///
/// A bare ID skips the catalog fetch; anything else needs the list to match
/// against. Text comes from the arguments, or from stdin when there is none —
/// the shape a heredoc or a pipe wants.
/// Terminal sessions get raw keystrokes via `send_input`; structured sessions
/// get an ACP prompt via `send_structured_prompt` (`--no-enter` is ignored
/// there — ACP prompts are always complete messages).
///
/// Text comes from the arguments, or from stdin when there is none — the
/// shape a heredoc or a pipe wants.
#[allow(clippy::result_large_err)]
pub async fn run_send(
client: &DaemonClient,
@@ -154,19 +158,28 @@ pub async fn run_send(
return Err(CliError::NothingToSend);
}
let id = match uuid::Uuid::parse_str(args.session.trim()) {
Ok(id) => id,
Err(_) => {
let sessions = client.list_sessions().await?;
resolve_session(&sessions, &args.session)?
}
};
// We always need the session list: even a bare UUID needs the view field
// to decide which endpoint to hit.
let sessions = client.list_sessions().await?;
let id = resolve_session(&sessions, &args.session)?;
let view = sessions
.iter()
.find(|s| s.id == id)
.map(|s| s.view)
.unwrap_or(SessionView::Terminal);
let req = SendInputRequest {
text,
submit: !args.no_enter,
};
client.send_input(id, &req).await?;
match view {
SessionView::Structured => {
client.send_structured_prompt(id, &text).await?;
}
SessionView::Terminal => {
let req = SendInputRequest {
text,
submit: !args.no_enter,
};
client.send_input(id, &req).await?;
}
}
Ok(id)
}
@@ -215,6 +228,7 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
ActivityState::Idle => "idle",
ActivityState::AwaitingInput => "awaiting",
ActivityState::Error => "error",
ActivityState::Starting => "starting",
};
// Show the live foreground command alongside the spawn command when
// the daemon has observed one that differs (NAP-340).
@@ -2,7 +2,7 @@
//! was unit-tested, but nothing exercised the client, the error decoding, or
//! the unreachable-daemon path without compiling and forking a real daemon.
use maestro_protocol::{DaemonClient, DaemonInfo};
use maestro_protocol::{DaemonClient, DaemonInfo, SessionView};
use maestro_testing::{session, MockDaemon};
use uuid::Uuid;
@@ -28,6 +28,7 @@ async fn info_carries_version_and_presets() {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
auto_approve: false,
}],
..Default::default()
});
@@ -97,6 +98,7 @@ async fn spawn_round_trips_through_the_daemon() {
env: Default::default(),
metadata: Default::default(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -200,6 +202,35 @@ async fn send_resolves_a_session_by_name() {
assert_eq!(sent, id);
}
/// A structured session routes through the ACP prompt endpoint, not the
/// terminal input one — `--no-enter` is irrelevant (ACP prompts are
/// complete messages).
#[tokio::test]
async fn send_to_structured_session_uses_acp_prompt() {
let mock = MockDaemon::start().await;
let id = Uuid::from_u128(13);
let mut s = session(id, "/tmp", "claude");
s.view = SessionView::Structured;
mock.add_session(s);
let args = maestro_cli::cli::SendArgs {
session: id.to_string(),
text: vec!["fix".into(), "the".into(), "tests".into()],
no_enter: false,
};
let sent = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
.await
.expect("send");
assert_eq!(sent, id);
// Terminal input must stay empty — the message went through ACP.
assert!(mock.inputs().is_empty());
let prompts = mock.structured_prompts();
assert_eq!(prompts.len(), 1);
assert_eq!(prompts[0].0, id);
assert_eq!(prompts[0].1.prompt, "fix the tests");
}
#[tokio::test]
async fn send_to_an_unknown_session_fails_without_typing_anything() {
let mock = MockDaemon::start().await;
+10
View File
@@ -313,16 +313,19 @@ pub fn builtin_presets() -> Vec<SpawnPreset> {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
auto_approve: false,
},
SpawnPreset {
id: "opencode".into(),
label: "opencode".into(),
command: "opencode".into(),
auto_approve: false,
},
SpawnPreset {
id: "zsh".into(),
label: "Shell".into(),
command: "zsh".into(),
auto_approve: false,
},
]
}
@@ -334,6 +337,7 @@ pub fn custom_preset() -> SpawnPreset {
id: CUSTOM_PRESET_ID.into(),
label: "Custom…".into(),
command: String::new(),
auto_approve: false,
}
}
@@ -515,6 +519,7 @@ auto_install = false
id: "fish".into(),
label: "fish".into(),
command: "fish".into(),
auto_approve: false,
}]),
..Default::default()
};
@@ -551,6 +556,7 @@ auto_install = false
id: " ".into(),
label: "x".into(),
command: "x".into(),
auto_approve: false,
}]),
..Default::default()
};
@@ -561,6 +567,7 @@ auto_install = false
id: "x".into(),
label: "x".into(),
command: "".into(),
auto_approve: false,
}]),
..Default::default()
};
@@ -578,11 +585,13 @@ auto_install = false
id: "a".into(),
label: "a".into(),
command: "a".into(),
auto_approve: false,
},
SpawnPreset {
id: "a".into(),
label: "a2".into(),
command: "a2".into(),
auto_approve: false,
},
]),
..Default::default()
@@ -597,6 +606,7 @@ auto_install = false
id: "custom".into(),
label: "Custom".into(),
command: "whatever".into(),
auto_approve: false,
}]),
..Default::default()
};
+13 -4
View File
@@ -80,13 +80,22 @@ mod tests {
}
#[test]
fn non_repo_dir_is_none() {
// A temp dir with no `.git` anywhere up the chain. Use the temp root so
// the walk can't accidentally hit a real repo above the test's cwd.
fn non_repo_dir_does_not_find_root_inside_fixture() {
// The fixture has no `.git`, so `find_root` must not return a path
// inside it. On hosts where an ancestor of the temp dir *is* a repo
// (e.g. `/tmp` lives inside a checkout), the function legitimately
// returns that ancestor — the test only asserts the fixture itself
// was not mistaken for a repo.
let tmp = tempfile::tempdir().unwrap();
let sub = tmp.path().join("plain/dir");
fs::create_dir_all(&sub).unwrap();
assert_eq!(find_root(&sub), None);
match find_root(&sub) {
None => {} // expected on most hosts
Some(root) => assert!(
!root.starts_with(tmp.path()),
"found .git inside the fixture at {root:?}, but none was created"
),
}
}
#[test]
+4 -2
View File
@@ -180,7 +180,7 @@ fn dir_error_status(code: &str) -> StatusCode {
fn session_error_status(code: &str) -> StatusCode {
match code {
"not_found" => StatusCode::NOT_FOUND,
"invalid_env" => StatusCode::BAD_REQUEST,
"invalid_env" | "bad_request" => StatusCode::BAD_REQUEST,
// A guard refusal is the daemon doing its job, not a failure (#79).
"kill_guarded" => StatusCode::CONFLICT,
// The session outlived its pane: nothing to type into, and nothing
@@ -483,7 +483,9 @@ async fn structured_prompt(
Json(body): Json<StructuredPromptRequest>,
) -> Response {
match s.manager.queue_acp_prompt(id, body.prompt).await {
Ok(QueueAcpPrompt::Queued) => StatusCode::NO_CONTENT.into_response(),
Ok(QueueAcpPrompt::Queued) | Ok(QueueAcpPrompt::Pending) => {
StatusCode::NO_CONTENT.into_response()
}
Ok(QueueAcpPrompt::InFlight) => error_response(
StatusCode::CONFLICT,
"turn_in_flight",
+229 -161
View File
@@ -6,12 +6,9 @@
//! handler interact with tmux.
use std::collections::{HashMap, HashSet};
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Weak};
use std::task::{Context, Poll};
use std::time::Duration;
use chrono::Utc;
@@ -122,6 +119,12 @@ pub struct SessionRecord {
/// Completion signal for the task draining ACP notifications into the
/// event log. Shutdown waits for it before returning.
acp_event_pump: Option<watch::Receiver<bool>>,
/// When `true`, the permission pump auto-responds `AllowAlways` to every
/// ACP permission request without surfacing it to clients.
pub auto_approve: bool,
/// A prompt submitted while the adapter was still booting (`Starting`).
/// The background handshake task drains this on success.
pub pending_prompt: Option<String>,
}
impl SessionRecord {
@@ -131,6 +134,13 @@ impl SessionRecord {
&& self.acp.is_some()
&& self.acp_events.is_some()
}
/// The session is structured and still booting its adapter.
fn is_starting_structured(&self) -> bool {
self.session.view == SessionView::Structured
&& self.session.activity == ActivityState::Starting
&& self.acp.is_none()
}
}
#[derive(Debug)]
@@ -205,6 +215,8 @@ pub enum SessionError {
NotTerminal(Uuid),
#[error("daemon is shutting down")]
ShuttingDown,
#[error("bad request: {0}")]
BadRequest(String),
/// The kill was refused (#79): the session's worktree holds unsaved work.
/// Nothing happened — the session keeps running and the tree is untouched,
/// so it stays the operator's handle on their own unfinished work.
@@ -224,6 +236,8 @@ pub enum BeginAcpTurn {
#[derive(Debug)]
pub enum QueueAcpPrompt {
Queued,
/// The adapter is still booting; the prompt is held until it is ready.
Pending,
InFlight,
Unavailable,
Failed(String),
@@ -250,6 +264,7 @@ impl SessionError {
SessionError::NotRunning(_) => "not_running",
SessionError::NotTerminal(_) => "not_terminal",
SessionError::ShuttingDown => "shutting_down",
SessionError::BadRequest(_) => "bad_request",
SessionError::KillGuarded(_) => "kill_guarded",
};
let unsaved_work = match self {
@@ -317,18 +332,6 @@ struct StructuredLifecycle {
}
impl StructuredLifecycle {
fn admit<'a>(
&'a self,
shutting_down: &AtomicBool,
) -> Result<StructuredCreate<'a>, SessionError> {
let _gate = self.gate.lock().expect("structured lifecycle gate");
if shutting_down.load(Ordering::Acquire) {
return Err(SessionError::ShuttingDown);
}
self.in_flight.fetch_add(1, Ordering::AcqRel);
Ok(StructuredCreate { lifecycle: self })
}
async fn wait_until_settled(&self) {
loop {
let settled = self.settled.notified();
@@ -340,30 +343,18 @@ impl StructuredLifecycle {
}
}
struct StructuredCreate<'a> {
lifecycle: &'a StructuredLifecycle,
}
impl Drop for StructuredCreate<'_> {
fn drop(&mut self) {
if self.lifecycle.in_flight.fetch_sub(1, Ordering::AcqRel) == 1 {
self.lifecycle.settled.notify_one();
}
}
}
struct AdmittedStructuredCreate<'a> {
create: Pin<Box<dyn Future<Output = Result<Session, SessionError>> + Send + 'a>>,
/// Field order matters: dropping a cancelled create rolls back its inner
/// future before this permit releases shutdown's in-flight barrier.
_permit: StructuredCreate<'a>,
}
impl Future for AdmittedStructuredCreate<'_> {
type Output = Result<Session, SessionError>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
self.create.as_mut().poll(cx)
/// Release the structured-lifecycle in-flight counter that `create` acquires
/// before spawning the background ACP handshake task. Called when the task
/// finishes (success or failure) or when `create_inner` fails before the task
/// is spawned.
fn drop_structured_lifecycle_permit(inner: &Inner) {
if inner
.structured_lifecycle
.in_flight
.fetch_sub(1, Ordering::AcqRel)
== 1
{
inner.structured_lifecycle.settled.notify_one();
}
}
@@ -657,7 +648,9 @@ impl SessionManager {
let stream = {
let sessions = self.inner.sessions.lock().await;
let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?;
if !record.is_live_structured() {
// Allow connecting while the adapter is still booting
// (`Starting`) — the event stream exists, it is just empty.
if !record.is_live_structured() && !record.is_starting_structured() {
return Ok(None);
}
let Some(stream) = &record.acp_events else {
@@ -742,8 +735,14 @@ impl SessionManager {
text: String,
) -> Result<QueueAcpPrompt, SessionError> {
let (acp, stream) = {
let sessions = self.inner.sessions.lock().await;
let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?;
let mut sessions = self.inner.sessions.lock().await;
let record = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?;
// While the adapter is booting, stash the prompt for the
// background handshake task to drain on success.
if record.is_starting_structured() {
record.pending_prompt = Some(text);
return Ok(QueueAcpPrompt::Pending);
}
if !record.is_live_structured() {
return Ok(QueueAcpPrompt::Unavailable);
}
@@ -806,6 +805,10 @@ impl SessionManager {
});
drop(stream);
sync_structured_activity(&self.inner, id).await;
#[cfg(feature = "haiku-autoname")]
if let Ok(true) = self.claim_autoname(id).await {
crate::autoname::spawn_naming(self.clone(), id, text);
}
return Ok(QueueAcpPrompt::Queued);
}
Err(message) => {
@@ -1214,17 +1217,42 @@ impl SessionManager {
}
/// Spawn a new session. On success returns the freshly-created `Session`.
///
/// For structured sessions, the ACP handshake runs in a background task
/// after the session is registered — the HTTP caller gets an instant
/// response with `activity: Starting`. The `StructuredLifecycle` gate is
/// held across the background task so `shutdown()` waits for it.
pub async fn create(&self, req: CreateSessionRequest) -> Result<Session, SessionError> {
if req.view == SessionView::Structured {
let permit = self
// Acquire the lifecycle gate synchronously (rejects if shutting
// down). The background handshake task calls
// `drop_structured_lifecycle_permit` when it finishes; we must
// not use the RAII `StructuredCreate` because it would drop at
// the end of `create_inner`, before the handshake completes.
let _gate = self
.inner
.structured_lifecycle
.admit(&self.inner.shutting_down)?;
return AdmittedStructuredCreate {
create: Box::pin(self.create_inner(req)),
_permit: permit,
.gate
.lock()
.expect("structured lifecycle gate");
if self.inner.shutting_down.load(Ordering::Acquire) {
return Err(SessionError::ShuttingDown);
}
.await;
self.inner
.structured_lifecycle
.in_flight
.fetch_add(1, Ordering::AcqRel);
drop(_gate);
return match self.create_inner(req).await {
Ok(session) => Ok(session),
Err(e) => {
// create_inner failed before spawning the background
// task, so release the permit here.
drop_structured_lifecycle_permit(&self.inner);
Err(e)
}
};
}
self.create_inner(req).await
}
@@ -1286,32 +1314,39 @@ impl SessionManager {
{
session_env.insert("MAESTRO_URL".into(), url);
}
// A structured session (#134) gets no tmux pane: the agent is a
// child of the daemon, driven over ACP. An adapter that cannot come
// up still yields a session — Dead, with the reason on it — so the
// operator sees what to fix instead of a silently missing session.
let (acp, acp_failure) = match req.view {
SessionView::Structured => match structured_adapter(&req.command) {
Err(error) => (None, Some(error)),
Ok(adapter) => match maestro_acp::spawn(
SpawnConfig::new(
&effective_cwd,
session_env
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
)
.adapter(adapter),
)
.await
{
Ok(acp) => (Some(Arc::new(acp)), None),
Err(e) => {
tracing::warn!(%id, error = %e, "structured session: adapter failed to start");
(None, Some(e.to_string()))
// ── Structured path: validate adapter, open event log, register
// the session immediately with `Starting`, then handshake in the
// background so the HTTP caller gets an instant response.
//
// ── Terminal path: unchanged — tmux spawn is fast enough to stay
// synchronous.
// Structured: validate adapter, open event log eagerly. Terminal:
// spawn the tmux pane synchronously (it is fast).
let structured_spawn = match req.view {
SessionView::Structured => {
// Fail fast on an unsupported command before touching disk.
let adapter = structured_adapter(&req.command).map_err(SessionError::BadRequest)?;
// Open the event log eagerly so the structured WS can
// connect before the adapter is up.
let acp_events = match self.inner.sidecar.open_events(id) {
Ok(log) => {
let (tx, _) = broadcast::channel(EVENTS_CAPACITY);
let (projected_tx, _) = broadcast::channel(EVENTS_CAPACITY);
Arc::new(Mutex::new(AcpEventStream {
log,
live: tx,
projection: Projection::default(),
projected_live: projected_tx,
pending_approvals: HashMap::new(),
operation: Arc::new(Mutex::new(())),
dispatch: AcpDispatchState::default(),
activity_sequence: 0,
}))
}
},
},
Err(e) => return Err(SessionError::Sidecar(e)),
};
Some((adapter, acp_events))
}
SessionView::Terminal => {
let spawned = {
let tmux = self.inner.tmux.clone();
@@ -1325,53 +1360,10 @@ impl SessionManager {
if let Err(e) = spawned {
return Err(SessionError::Tmux(e));
}
(None, None)
None
}
};
let acp_events = if let Some(acp) = &acp {
let persisted =
match self
.inner
.sidecar
.read_events(id, EventCursor::Since(0), usize::MAX)
{
Ok(events) => events,
Err(error) => {
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
.await;
return Err(SessionError::Sidecar(error));
}
};
match self.inner.sidecar.open_events(id) {
Ok(log) => {
let (tx, _) = broadcast::channel(EVENTS_CAPACITY);
let (projected_tx, _) = broadcast::channel(EVENTS_CAPACITY);
let mut projection = Projection::default();
for event in &persisted {
apply_persisted_event(&mut projection, event);
}
Some(Arc::new(Mutex::new(AcpEventStream {
log,
live: tx,
projection,
projected_live: projected_tx,
pending_approvals: HashMap::new(),
operation: Arc::new(Mutex::new(())),
dispatch: AcpDispatchState::default(),
activity_sequence: 0,
})))
}
Err(e) => {
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
.await;
return Err(SessionError::Sidecar(e));
}
}
} else {
None
};
// Capture spawn-history inputs before `req` is partially moved into
// `session`. `history_cwd` is the *chosen* working dir (the request
// `cwd`), not a generated worktree checkout path — that's what the
@@ -1381,10 +1373,13 @@ impl SessionManager {
resolve_name(&mut metadata);
let worktree_branch = req.worktree.as_ref().map(|w| w.branch.clone());
let is_structured = structured_spawn.is_some();
let events_arc = structured_spawn.as_ref().map(|(_, e)| e.clone());
let session = Session {
id,
cwd: effective_cwd,
command: req.command,
cwd: effective_cwd.clone(),
command: req.command.clone(),
env: req.env,
metadata,
worktree: worktree_path.as_ref().zip(worktree_base_repo.as_ref()).map(
@@ -1393,67 +1388,41 @@ impl SessionManager {
base_repo: base_repo.clone(),
},
),
status: if acp_failure.is_some() {
SessionStatus::Dead
} else {
SessionStatus::Running
},
status: SessionStatus::Running,
created_at: Utc::now(),
// A terminal spawn begins active until its watcher sees otherwise;
// a structured spawn has no turn yet and begins idle.
foreground: None,
activity: match (req.view, acp_failure.is_some()) {
(_, true) => ActivityState::Error,
(SessionView::Structured, false) => ActivityState::Idle,
(SessionView::Terminal, false) => ActivityState::Active,
activity: if is_structured {
ActivityState::Starting
} else {
ActivityState::Active
},
activity_detail: acp_failure,
activity_detail: None,
last_activity_at: Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: None,
// The watcher fills this in on its first blocked-work pass; a
// fresh tree has nothing in the way anyway.
blocked_work: None,
last_focused_at: None,
acp_session_id: acp.as_ref().map(|a| a.session_id().to_string()),
acp_session_id: None,
view: req.view,
};
if let Err(e) = self.inner.sidecar.write(&session) {
// The process already started; end it so no ghost survives a
// failed sidecar write, then unwind the FIFO + worktree.
match &acp {
Some(acp) => {
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
.await
}
None => {
let tmux = self.inner.tmux.clone();
let _ = tmux_blocking(move || tmux.kill_session(id)).await;
}
if !is_structured {
let tmux = self.inner.tmux.clone();
let _ = tmux_blocking(move || tmux.kill_session(id)).await;
}
return Err(SessionError::Sidecar(e));
}
let event_pump = acp
.as_ref()
.zip(acp_events.as_ref())
.map(|(acp, events)| (acp.clone(), events.clone()));
{
let mut sessions = self.inner.sessions.lock().await;
let event_pump = event_pump.map(|(acp, events)| {
let done = start_acp_event_pump(&self.inner, id, &acp, events.clone());
start_acp_permission_pump(&self.inner, id, &acp, events);
done
});
sessions.insert(
id,
SessionRecord {
session: session.clone(),
fifo_path,
worktree_path,
worktree_base_repo,
worktree_path: worktree_path.clone(),
worktree_base_repo: worktree_base_repo.clone(),
bridge,
reported_activity: None,
settled: false,
@@ -1461,10 +1430,12 @@ impl SessionManager {
naming: false,
focused_since: None,
awaiting_suppressed: false,
acp,
acp_events,
acp: None,
acp_events: events_arc,
structured_activity_watermark: None,
acp_event_pump: event_pump,
acp_event_pump: None,
auto_approve: req.auto_approve,
pending_prompt: None,
},
);
}
@@ -1493,6 +1464,78 @@ impl SessionManager {
}
pending.commit();
// ── Background ACP handshake for structured sessions ──
//
// The session is registered and visible to clients. The adapter
// spawn + handshake runs in a background task; on completion it
// fills in `acp`, starts the event/permission pumps, and
// transitions activity from `Starting` to `Idle` (or to `Error`
// on failure). The `StructuredLifecycle` permit is held until the
// task finishes so `shutdown()` waits for it.
if let Some((adapter, events)) = structured_spawn {
let inner = self.inner.clone();
let env: HashMap<String, String> = session_env
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
let cwd = effective_cwd;
let auto_approve = req.auto_approve;
tokio::spawn(async move {
let result = maestro_acp::spawn(SpawnConfig::new(&cwd, env).adapter(adapter)).await;
match result {
Ok(acp_session) => {
let acp = Arc::new(acp_session);
let done = start_acp_event_pump(&inner, id, &acp, events.clone());
start_acp_permission_pump(&inner, id, &acp, events.clone(), auto_approve);
// Drain any prompt submitted while Starting.
let pending_prompt = {
let mut sessions = inner.sessions.lock().await;
let Some(record) = sessions.get_mut(&id) else {
// Session was killed while we were booting.
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
.await;
drop_structured_lifecycle_permit(&inner);
return;
};
record.acp = Some(acp.clone());
record.acp_event_pump = Some(done);
record.session.acp_session_id = Some(acp.session_id().to_string());
record.session.activity = ActivityState::Idle;
record.session.activity_since = Utc::now();
let _ = inner.sidecar.write(&record.session);
let _ = inner.events.send(CatalogEvent::SessionUpdated {
session: record.session.clone(),
});
record.pending_prompt.take()
};
if let Some(prompt) = pending_prompt {
let manager = SessionManager {
inner: inner.clone(),
};
let _ = manager.queue_acp_prompt(id, prompt).await;
}
}
Err(e) => {
tracing::warn!(%id, error = %e, "structured session: adapter failed to start");
let mut sessions = inner.sessions.lock().await;
if let Some(record) = sessions.get_mut(&id) {
record.session.status = SessionStatus::Dead;
record.session.activity = ActivityState::Error;
record.session.activity_detail = Some(e.to_string());
record.session.activity_since = Utc::now();
let _ = inner.sidecar.write(&record.session);
let _ = inner.events.send(CatalogEvent::SessionUpdated {
session: record.session.clone(),
});
}
}
}
drop_structured_lifecycle_permit(&inner);
});
}
Ok(session)
}
@@ -1677,6 +1720,8 @@ impl SessionManager {
acp_events: None,
structured_activity_watermark: None,
acp_event_pump: None,
auto_approve: false,
pending_prompt: None,
},
);
let _ = self
@@ -1854,11 +1899,15 @@ impl SessionManager {
acp_events,
structured_activity_watermark: None,
acp_event_pump: None,
// Recovery cannot know the original preset's flag; the
// operator re-enables it by restarting with the preset.
auto_approve: false,
pending_prompt: None,
},
);
if let Some((acp, events)) = event_pump {
let done = start_acp_event_pump(&self.inner, id, &acp, events.clone());
start_acp_permission_pump(&self.inner, id, &acp, events);
start_acp_permission_pump(&self.inner, id, &acp, events, false);
if let Some(record) = self.inner.sessions.lock().await.get_mut(&id) {
record.acp_event_pump = Some(done);
}
@@ -2313,6 +2362,11 @@ async fn apply_structured_activity(
if record.session.view != SessionView::Structured {
return;
}
// While the adapter is still booting, the background handshake task
// owns the activity state — the activity pass must not overwrite it.
if record.session.activity == ActivityState::Starting {
return;
}
let current_events = record.acp_events.as_ref();
match (expected_events, current_events) {
(Some((expected, sequence)), Some(current)) if Arc::ptr_eq(expected, current) => {
@@ -2357,6 +2411,7 @@ fn start_acp_permission_pump(
id: Uuid,
acp: &Arc<AcpSession>,
events: Arc<Mutex<AcpEventStream>>,
auto_approve: bool,
) {
let Some(mut requests) = acp.take_permission_requests() else {
return;
@@ -2370,6 +2425,12 @@ fn start_acp_permission_pump(
response,
}) = requests.recv().await
{
if auto_approve {
let outcome =
permission_outcome(ApprovalDecision::AllowForSession, &request.options);
let _ = response.send(outcome);
continue;
}
let mut events = events.lock().await;
if events.projection.active_turn() != Some(turn) {
let _ = response.send(maestro_acp::schema::RequestPermissionOutcome::Cancelled);
@@ -2571,6 +2632,12 @@ async fn run_liveness_pass(inner: &Arc<Inner>) {
let mut tmux_backed = Vec::new();
let mut acp_backed = Vec::new();
for (id, record) in running {
if record.is_starting_structured() {
// The adapter hasn't finished its handshake yet — no ACP
// handle and no tmux pane. Skip; the background task owns
// this session's lifecycle until the handshake settles.
continue;
}
match &record.acp {
Some(acp) => acp_backed.push((*id, acp.clone())),
None => tmux_backed.push(*id),
@@ -3090,6 +3157,7 @@ mod tests {
env: std::collections::HashMap::new(),
metadata: std::collections::HashMap::new(),
worktree: None,
auto_approve: false,
}
}
+138 -81
View File
@@ -112,6 +112,7 @@ fn structured_command(
env,
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
}
}
@@ -129,6 +130,25 @@ async fn wait_for_exit(pid: u32) {
assert!(!pid_alive(pid), "pid {pid} still alive");
}
/// Wait for a session's background ACP handshake to complete (activity
/// transitions out of `Starting`). Returns the refreshed session.
async fn wait_for_ready(manager: &SessionManager, id: uuid::Uuid) -> maestro_protocol::Session {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let session = manager
.snapshot()
.await
.into_iter()
.find(|s| s.id == id)
.expect("session exists");
if session.activity != ActivityState::Starting {
return session;
}
assert!(Instant::now() < deadline, "handshake timed out");
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
#[tokio::test]
async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
let fx = DaemonFixture::new();
@@ -137,7 +157,7 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
let mut env = fake.env();
env.insert("FAKE_ACP_ECHO_ENV".into(), "MAESTRO_SESSION_ID".into());
let session = fx
let created = fx
.manager
.create(structured_command(
cwd.path(),
@@ -147,8 +167,12 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
.await
.expect("create");
assert_eq!(session.view, SessionView::Structured);
assert_eq!(session.status, SessionStatus::Running);
assert_eq!(created.view, SessionView::Structured);
assert_eq!(created.status, SessionStatus::Running);
assert_eq!(created.activity, ActivityState::Starting);
assert!(created.acp_session_id.is_none());
let session = wait_for_ready(&fx.manager, created.id).await;
assert_eq!(session.activity, ActivityState::Idle);
let acp_id = session.acp_session_id.clone().expect("acp session id");
assert!(acp_id.starts_with("fake-"), "{acp_id}");
@@ -163,7 +187,7 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
"{log}"
);
assert!(
log.contains(&format!("env MAESTRO_SESSION_ID={}", session.id)),
log.contains(&format!("env MAESTRO_SESSION_ID={}", created.id)),
"{log}"
);
@@ -231,6 +255,7 @@ async fn structured_activity_tracks_turn_completion_and_failure() {
.create(structured_command(cwd.path(), env, "codex"))
.await
.unwrap();
let session = wait_for_ready(&fx.manager, session.id).await;
assert!(matches!(
fx.manager
@@ -294,11 +319,12 @@ async fn projection_snapshot_and_live_stream_share_one_fold() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let session = fx
let created = fx
.manager
.create(structured(cwd.path(), fake.env()))
.await
.expect("create");
let session = wait_for_ready(&fx.manager, created.id).await;
wait_for_async(Duration::from_secs(5), || async {
fx.manager
@@ -396,6 +422,7 @@ async fn queued_prompt_is_a_durable_user_transcript_row() {
.create(structured(cwd.path(), fake.env()))
.await
.expect("create");
let session = wait_for_ready(&fx.manager, session.id).await;
assert!(matches!(
fx.manager
@@ -441,6 +468,7 @@ async fn stale_turn_completion_cannot_end_the_next_turn() {
.create(structured(cwd.path(), fake.env()))
.await
.unwrap();
wait_for_ready(&fx.manager, session.id).await;
let maestro_daemon::session::BeginAcpTurn::Started(_, first) =
fx.manager.begin_acp_turn(session.id).await.unwrap()
@@ -484,6 +512,7 @@ async fn cancelled_prompt_that_never_settles_fails_the_session() {
.create(structured(cwd.path(), env))
.await
.unwrap();
wait_for_ready(&fx.manager, session.id).await;
assert!(matches!(
fx.manager
@@ -522,6 +551,7 @@ async fn cancellation_settlement_allows_the_next_prompt_permission() {
.create(structured(cwd.path(), env))
.await
.unwrap();
wait_for_ready(&fx.manager, session.id).await;
assert!(matches!(
fx.manager
@@ -581,11 +611,12 @@ async fn recovery_loads_the_acp_session_without_duplicating_history() {
let mut env = fake.env();
env.insert("FAKE_ACP_LOAD_REPLAY".into(), "2".into());
let session = fx
let created = fx
.manager
.create(structured_command(cwd.path(), env, "codex"))
.await
.expect("create");
let session = wait_for_ready(&fx.manager, created.id).await;
let acp_id = session.acp_session_id.clone().expect("acp id");
wait_for_async(Duration::from_secs(5), || async {
fx.manager
@@ -705,10 +736,12 @@ async fn shutdown_preserves_a_pristine_structured_worktree_for_recovery() {
branch: branch.into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
assert_eq!(session.cwd, worktree);
wait_for_ready(&fx.manager, session.id).await;
push_all_to_origin(repo.path(), origin.path());
fx.manager.shutdown().await;
@@ -757,6 +790,7 @@ async fn failed_recovery_cleans_its_fifo() {
.create(structured(cwd.path(), fake.env()))
.await
.expect("create");
wait_for_ready(&fx.manager, session.id).await;
let old_pid = fake.pid();
fx.manager.shutdown().await;
wait_for_exit(old_pid).await;
@@ -790,12 +824,15 @@ async fn missing_adapter_yields_a_failed_session_naming_the_install_command() {
empty.path().to_string_lossy().into_owned(),
)]);
let session = fx
let created = fx
.manager
.create(structured_command(cwd.path(), env, "codex"))
.await
.expect("a failed session, not an error");
.expect("create registers a Starting session");
assert_eq!(created.activity, ActivityState::Starting);
// The background handshake fails because the adapter binary is missing.
let session = wait_for_ready(&fx.manager, created.id).await;
assert_eq!(session.status, SessionStatus::Dead);
assert_eq!(session.activity, ActivityState::Error);
let detail = session.activity_detail.clone().expect("actionable detail");
@@ -808,13 +845,13 @@ async fn missing_adapter_yields_a_failed_session_naming_the_install_command() {
// It is a real session: listed, on disk, and dismissable like any other
// dead one.
assert!(fx.manager.has(session.id).await);
assert!(fx.manager.has(created.id).await);
assert!(fx
.sidecar_dir()
.join(format!("{}.json", session.id))
.join(format!("{}.json", created.id))
.exists());
fx.manager.kill(session.id, false).await.expect("dismiss");
assert!(!fx.manager.has(session.id).await);
fx.manager.kill(created.id, false).await.expect("dismiss");
assert!(!fx.manager.has(created.id).await);
}
#[tokio::test]
@@ -823,20 +860,20 @@ async fn unsupported_structured_command_fails_without_launching_claude() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let session = fx
let err = fx
.manager
.create(structured_command(cwd.path(), fake.env(), "opencode"))
.await
.expect("failed structured session is persisted");
.expect_err("unsupported command is rejected at request validation");
assert_eq!(session.status, SessionStatus::Dead);
let detail = session.activity_detail.expect("selection error");
assert!(detail.contains("Claude Code or Codex"), "{detail}");
assert!(
matches!(err, maestro_daemon::SessionError::BadRequest(ref msg) if msg.contains("Claude Code or Codex")),
"{err:?}"
);
assert!(
fake.log().is_empty(),
"unsupported command launched an adapter"
);
fx.manager.kill(session.id, false).await.expect("dismiss");
}
#[tokio::test]
@@ -851,6 +888,7 @@ async fn kill_sends_cancel_then_sigterm_and_removes_the_sidecar() {
.create(structured(cwd.path(), fake.env()))
.await
.expect("create");
wait_for_ready(&fx.manager, session.id).await;
let pid = fake.pid();
assert!(pid_alive(pid));
@@ -893,6 +931,8 @@ async fn shutdown_reaps_every_adapter_child() {
.create(structured(cwd.path(), b.env()))
.await
.expect("create b");
wait_for_ready(&fx.manager, sa.id).await;
wait_for_ready(&fx.manager, sb.id).await;
let (pa, pb) = (a.pid(), b.pid());
assert!(pid_alive(pa) && pid_alive(pb));
@@ -927,10 +967,13 @@ async fn dismissal_during_shutdown_does_not_restore_recovery_artifacts() {
.create(structured(cwd.path(), env))
.await
.expect("create");
fx.manager
let blocker_session = fx
.manager
.create(structured(cwd.path(), blocker.env()))
.await
.expect("create blocker");
wait_for_ready(&fx.manager, session.id).await;
wait_for_ready(&fx.manager, blocker_session.id).await;
let manager = fx.manager.clone();
let shutdown = tokio::spawn(async move { manager.shutdown().await });
@@ -982,6 +1025,7 @@ async fn shutdown_does_not_recover_an_adapter_that_already_exited() {
.create(structured(cwd.path(), env))
.await
.expect("create");
wait_for_ready(&fx.manager, session.id).await;
wait_for_exit(fake.pid()).await;
fx.manager.shutdown().await;
@@ -1015,23 +1059,31 @@ async fn shutdown_waits_for_structured_creation_and_rejects_new_creates() {
let mut env = fake.env();
env.insert("FAKE_ACP_DELAY_SESSION_NEW_MS".into(), "500".into());
let create_cwd = cwd.path().to_path_buf();
let manager = fx.manager.clone();
let create = tokio::spawn(async move { manager.create(structured(&create_cwd, env)).await });
// create() returns immediately; the handshake runs in the background
// and holds a lifecycle permit.
let session = fx
.manager
.create(structured(cwd.path(), env))
.await
.expect("create");
// Wait until the adapter has received session/new (handshake in progress).
wait_for_async(Duration::from_secs(5), || async {
fake.log().contains("recv session/new")
})
.await;
// Shutdown must wait for the in-flight background handshake.
let manager = fx.manager.clone();
let shutdown = tokio::spawn(async move { manager.shutdown().await });
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(
!shutdown.is_finished(),
"shutdown returned before create finished"
"shutdown returned before handshake finished"
);
let session = create.await.expect("create task").expect("create");
// The handshake completes in the background (500ms delay).
wait_for_ready(&fx.manager, session.id).await;
let pid = fake.pid();
shutdown.await.expect("shutdown task");
@@ -1064,27 +1116,37 @@ async fn structured_handshakes_run_independently() {
gate.to_string_lossy().into_owned(),
);
let first_cwd = cwd.path().to_path_buf();
let manager = fx.manager.clone();
let first =
tokio::spawn(async move { manager.create(structured(&first_cwd, blocked_env)).await });
wait_for_async(Duration::from_secs(5), || async {
blocked.log().contains("recv session/new")
})
.await;
// Both create() calls return immediately with Starting; the first
// adapter's handshake is gated while the second runs freely.
let first = fx
.manager
.create(structured(cwd.path(), blocked_env))
.await
.expect("first create");
let second = fx
.manager
.create(structured(cwd.path(), ready.env()))
.await
.expect("second create");
let second = tokio::time::timeout(
Duration::from_secs(5),
fx.manager.create(structured(cwd.path(), ready.env())),
)
.await
.expect("second handshake was blocked by the first")
.expect("second create");
assert_eq!(second.status, SessionStatus::Running);
assert!(!first.is_finished(), "the gate must still block the first");
// The second handshake completes independently of the first.
let second_ready = wait_for_ready(&fx.manager, second.id).await;
assert_eq!(second_ready.activity, ActivityState::Idle);
// The first handshake is still blocked.
let first_snap = fx
.manager
.snapshot()
.await
.into_iter()
.find(|s| s.id == first.id)
.unwrap();
assert_eq!(first_snap.activity, ActivityState::Starting);
std::fs::write(&gate, b"release").unwrap();
let first = first.await.expect("first task").expect("first create");
let first_ready = wait_for_ready(&fx.manager, first.id).await;
assert_eq!(first_ready.activity, ActivityState::Idle);
fx.manager.kill(first.id, false).await.expect("kill first");
fx.manager
.kill(second.id, false)
@@ -1112,16 +1174,18 @@ async fn shutdown_waits_for_every_admitted_structured_create() {
gate_b.to_string_lossy().into_owned(),
);
let create_a = {
let manager = fx.manager.clone();
let cwd = cwd.path().to_path_buf();
tokio::spawn(async move { manager.create(structured(&cwd, env_a)).await })
};
let create_b = {
let manager = fx.manager.clone();
let cwd = cwd.path().to_path_buf();
tokio::spawn(async move { manager.create(structured(&cwd, env_b)).await })
};
// Both create() calls return immediately; the background handshake
// tasks hold lifecycle permits and are blocked on their gates.
let session_a = fx
.manager
.create(structured(cwd.path(), env_a))
.await
.expect("create a");
let session_b = fx
.manager
.create(structured(cwd.path(), env_b))
.await
.expect("create b");
wait_for_async(Duration::from_secs(5), || async {
a.log().contains("recv session/new") && b.log().contains("recv session/new")
})
@@ -1141,14 +1205,14 @@ async fn shutdown_waits_for_every_admitted_structured_create() {
assert!(!shutdown.is_finished());
std::fs::write(&gate_a, b"release").unwrap();
let session_a = create_a.await.expect("create a task").expect("create a");
wait_for_ready(&fx.manager, session_a.id).await;
assert!(
!shutdown.is_finished(),
"shutdown ignored the second create"
);
std::fs::write(&gate_b, b"release").unwrap();
let session_b = create_b.await.expect("create b task").expect("create b");
wait_for_ready(&fx.manager, session_b.id).await;
shutdown.await.expect("shutdown task");
wait_for_exit(a.pid()).await;
@@ -1164,7 +1228,7 @@ async fn shutdown_waits_for_every_admitted_structured_create() {
}
#[tokio::test]
async fn cancelling_a_structured_handshake_rolls_back_and_settles_admission() {
async fn killing_a_starting_session_rolls_back_and_settles_admission() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let repo = TempDir::new().unwrap();
@@ -1187,42 +1251,33 @@ async fn cancelling_a_structured_handshake_rolls_back_and_settles_admission() {
branch: "cancelled-create".into(),
create_branch: true,
}),
auto_approve: false,
};
let create = {
let manager = fx.manager.clone();
tokio::spawn(async move { manager.create(request).await })
};
// create() returns immediately with Starting; the handshake is gated.
let session = fx.manager.create(request).await.expect("create");
assert_eq!(session.activity, ActivityState::Starting);
wait_for_async(Duration::from_secs(5), || async {
fake.log().contains("recv session/new")
})
.await;
let id = fake
.log()
.lines()
.find_map(|line| line.strip_prefix("env MAESTRO_SESSION_ID="))
.and_then(|id| uuid::Uuid::parse_str(id).ok())
.expect("session id in adapter log");
let pid = fake.pid();
create.abort();
let shutdown = {
let manager = fx.manager.clone();
tokio::spawn(async move { manager.shutdown().await })
};
assert!(create.await.unwrap_err().is_cancelled());
tokio::time::timeout(Duration::from_secs(5), shutdown)
// Kill the session while its handshake is still in progress.
// force=true because the worktree has an unpushed branch commit.
fx.manager.kill(session.id, true).await.expect("kill");
// Release the gate so the background task can finish and settle.
std::fs::write(&gate, b"release").unwrap();
// Shutdown must not hang — the lifecycle permit is settled.
tokio::time::timeout(Duration::from_secs(5), fx.manager.shutdown())
.await
.expect("cancelled admission kept shutdown waiting")
.expect("shutdown task");
.expect("shutdown hung on a settled permit");
wait_for_exit(pid).await;
wait_for_async(Duration::from_secs(5), || async {
!fx.fifo_dir().join(format!("{id}.fifo")).exists()
&& !repo.path().join("cancelled-create").exists()
})
.await;
assert!(fx.manager.snapshot().await.is_empty());
let id = id.to_string();
let id = session.id.to_string();
assert!(
std::fs::read_dir(fx.sidecar_dir())
.into_iter()
@@ -1232,7 +1287,7 @@ async fn cancelling_a_structured_handshake_rolls_back_and_settles_admission() {
.file_name()
.to_string_lossy()
.starts_with(&id)),
"cancelled create left a sidecar artifact"
"killed Starting session left a sidecar artifact"
);
}
@@ -1249,6 +1304,7 @@ async fn shutdown_drains_final_notifications_to_the_log() {
.create(structured(cwd.path(), env))
.await
.expect("create");
wait_for_ready(&fx.manager, session.id).await;
wait_for_async(Duration::from_secs(5), || async {
fx.manager
.read_acp_events(session.id, EventCursor::Since(0), 10)
@@ -1279,6 +1335,7 @@ async fn adapter_exit_marks_the_session_dead_and_keeps_it_listed() {
.create(structured(cwd.path(), fake.env()))
.await
.expect("create");
wait_for_ready(&fx.manager, session.id).await;
fx.manager.start_liveness_watcher(Duration::from_millis(50));
// The agent dies out from under the daemon: the liveness pass, not tmux,
@@ -48,6 +48,7 @@ async fn spawn_sleeping(manager: &SessionManager) -> Uuid {
env: Default::default(),
metadata: Default::default(),
worktree: None,
auto_approve: false,
})
.await
.unwrap()
+1
View File
@@ -81,6 +81,7 @@ async fn create_bash_session(h: &Harness) -> Uuid {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap();
+1
View File
@@ -56,6 +56,7 @@ async fn spawn(manager: &SessionManager, name: Option<&str>) -> Uuid {
env: Default::default(),
metadata,
worktree: None,
auto_approve: false,
})
.await
.unwrap()
@@ -29,6 +29,7 @@ fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> Creat
branch: branch.into(),
create_branch: true,
}),
auto_approve: false,
}
}
+37 -4
View File
@@ -154,7 +154,8 @@ async fn structured_session(h: &Harness, fake: &FakeAcp) -> Uuid {
}
async fn structured_session_with_env(h: &Harness, env: HashMap<String, String>) -> Uuid {
h.manager
let id = h
.manager
.create(CreateSessionRequest {
view: SessionView::Structured,
cwd: "/tmp".into(),
@@ -162,10 +163,13 @@ async fn structured_session_with_env(h: &Harness, env: HashMap<String, String>)
env,
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap()
.id
.id;
wait_for_session_activity(h, id, ActivityState::Idle).await;
id
}
async fn wait_for_session_activity(h: &Harness, id: Uuid, expected: ActivityState) {
@@ -547,6 +551,7 @@ async fn structured_prompt_rejects_unknown_and_unavailable_sessions() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap();
@@ -680,6 +685,7 @@ async fn structured_replay_rejects_unknown_terminal_and_dead_sessions() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap();
@@ -822,6 +828,7 @@ async fn list_sessions_starts_empty_and_reflects_creates() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
};
let created: Session = reqwest::Client::new()
.post(url(h.addr, "/api/sessions"))
@@ -996,7 +1003,9 @@ async fn names_honors_count_and_caps_it() {
#[tokio::test]
async fn gitroot_probes_path() {
// #26: resolves the query against the daemon home dir and walks up for a
// `.git`. A `/tmp`-rooted temp dir is not in a repo, so the root is null.
// `.git`. The tempdir itself has no `.git`, so the result must either be
// null (no repo anywhere above) or — in sandboxed environments where /tmp
// itself sits inside a repo — a path strictly above the tempdir.
let h = spawn("naps-laptop").await;
let tmp = tempfile::tempdir().unwrap();
let resp: serde_json::Value = reqwest::get(url(
@@ -1011,7 +1020,16 @@ async fn gitroot_probes_path() {
.json()
.await
.unwrap();
assert!(resp.get("root").is_none() || resp["root"].is_null());
// On hosts where an ancestor of the temp dir is a repo (e.g. `/tmp`
// inside a checkout), the root legitimately points there. The test only
// asserts no root was found *inside* the temp dir.
match resp.get("root").and_then(|v| v.as_str()) {
None => {}
Some(root) => assert!(
!root.starts_with(tmp.path().to_string_lossy().as_ref()),
"found a repo root inside the fixture at {root:?}"
),
}
}
#[tokio::test]
@@ -1039,6 +1057,7 @@ async fn patch_session_renames_it() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap();
@@ -1082,6 +1101,7 @@ async fn post_focus_stamps_last_focused_at() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap();
@@ -1116,6 +1136,7 @@ async fn delete_owned_session_kills_it() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap();
@@ -1138,6 +1159,7 @@ async fn spawn_via_manager(h: &Harness) -> Uuid {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap()
@@ -1252,6 +1274,7 @@ async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the
branch: "feat/guarded".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.unwrap();
@@ -1305,6 +1328,7 @@ async fn delete_with_force_ends_the_session_and_keeps_the_worktree() {
branch: "feat/guarded".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.unwrap();
@@ -1335,6 +1359,7 @@ async fn attach_ws_streams_pty_bytes() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap();
@@ -1437,6 +1462,7 @@ async fn events_ws_sends_a_snapshot_then_streams_deltas() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -1482,6 +1508,12 @@ async fn events_ws_streams_structured_activity_without_hook_overrides() {
let maestro_protocol::CatalogEvent::SessionAdded { session } = next_event(&mut ws).await else {
panic!("expected structured SessionAdded")
};
assert_eq!(session.activity, ActivityState::Starting);
// The background handshake completes and sends SessionUpdated → Idle.
let maestro_protocol::CatalogEvent::SessionUpdated { session } = next_event(&mut ws).await
else {
panic!("expected SessionUpdated after handshake")
};
assert_eq!(session.activity, ActivityState::Idle);
let client = reqwest::Client::new();
@@ -1549,6 +1581,7 @@ async fn every_events_subscriber_gets_its_own_snapshot() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
+1
View File
@@ -57,6 +57,7 @@ async fn spawn_named(manager: &SessionManager, name: Option<&str>) -> Uuid {
env: Default::default(),
metadata,
worktree: None,
auto_approve: false,
})
.await
.unwrap()
@@ -70,6 +70,7 @@ impl Fx {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
}
}
}
@@ -28,6 +28,7 @@ async fn create_then_kill_round_trip() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -70,6 +71,7 @@ async fn create_appends_to_spawn_history() {
env: HashMap::new(),
metadata: HashMap::from([("name".into(), serde_json::json!("neon-drifter"))]),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -100,6 +102,7 @@ async fn create_without_history_logs_nothing() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -123,6 +126,7 @@ async fn recover_adopts_existing_tmux_sessions() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -166,6 +170,7 @@ async fn create_captures_initial_output_in_replay_buffer() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -215,6 +220,7 @@ async fn attach_replay_is_the_current_screen_not_the_byte_tail() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -273,6 +279,7 @@ async fn attach_replay_carries_the_panes_mouse_modes() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -324,6 +331,7 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -387,6 +395,7 @@ async fn pty_broadcast_delivers_bytes_to_subscribers() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap();
@@ -438,6 +447,7 @@ async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -508,6 +518,7 @@ async fn watcher_refreshes_the_blocked_work_indicator() {
branch: "feat/indicator".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
@@ -601,6 +612,7 @@ async fn kill_removes_worktree_even_when_spec_says_keep() {
branch: "feat/keep".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
@@ -639,6 +651,7 @@ async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() {
branch: "feat/guarded".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
@@ -703,6 +716,7 @@ async fn killing_refuses_until_the_branch_is_pushed() {
branch: "feat/pushed".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
@@ -753,6 +767,7 @@ async fn force_killing_a_guarded_worktree_leaves_it_on_disk() {
branch: "feat/forced".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
@@ -799,6 +814,7 @@ async fn force_on_a_clear_worktree_removes_it_as_usual() {
branch: "feat/clear".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
@@ -840,6 +856,7 @@ async fn killing_an_ignored_only_worktree_succeeds() {
branch: "feat/rusty".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
@@ -882,6 +899,7 @@ async fn natural_exit_of_a_guarded_worktree_leaves_it_on_disk() {
branch: "feat/exited".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
@@ -944,6 +962,7 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() {
branch: "feat/clean".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
@@ -1001,6 +1020,7 @@ async fn liveness_watcher_leaves_running_sessions_alone() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -1040,6 +1060,7 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
branch: "feat/one".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create first");
@@ -1058,6 +1079,7 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
branch: "feat/two".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create second");
@@ -1093,6 +1115,7 @@ async fn spawned_sessions_export_maestro_session_id_and_url() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -1132,6 +1155,7 @@ async fn a_freshly_created_session_has_never_been_focused() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -1158,6 +1182,7 @@ async fn set_focused_true_stamps_last_focused_at() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -1207,6 +1232,7 @@ async fn attaching_stamps_last_focused_at() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
+8
View File
@@ -33,6 +33,7 @@ fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
}
}
@@ -111,6 +112,7 @@ fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> Cr
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(worktree_spec(base, branch, create_branch)),
auto_approve: false,
}
}
@@ -206,6 +208,7 @@ async fn kill_removes_clean_worktree() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(worktree_spec(base.path(), "feature/e2e", true)),
auto_approve: false,
};
let s = client.spawn_session(&req).await.expect("spawn worktree");
@@ -260,6 +263,7 @@ async fn kill_refuses_worktree_with_uncommitted_changes() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(worktree_spec(base.path(), "feature/unsaved", true)),
auto_approve: false,
};
let s = client.spawn_session(&req).await.expect("spawn worktree");
@@ -319,6 +323,7 @@ async fn spawn_on_existing_branch_checks_it_out() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(worktree_spec(base.path(), "feature/existing", false)),
auto_approve: false,
};
let s = client
@@ -827,6 +832,7 @@ async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(worktree_spec(dirty_repo.path(), "feature/messy", true)),
auto_approve: false,
})
.await
.expect("spawn dirty");
@@ -876,6 +882,7 @@ fn create_req_cmd(cwd: &std::path::Path, command: &str, branch: &str) -> CreateS
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(worktree_spec(cwd, branch, true)),
auto_approve: false,
}
}
@@ -1068,6 +1075,7 @@ async fn sigterm_stops_structured_child_before_events_ws_drains() {
env: fake.env.clone(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("spawn structured session");
+17 -1
View File
@@ -175,6 +175,10 @@ pub enum ActivityState {
AwaitingInput,
/// An agent reported a failure, or a structured session/turn failed.
Error,
/// The ACP adapter is booting; the session is registered but cannot accept
/// prompts yet. Older clients that do not know this variant degrade it to
/// `Idle` via the lenient deserializer — acceptable.
Starting,
}
impl<'de> Deserialize<'de> for ActivityState {
@@ -187,6 +191,7 @@ impl<'de> Deserialize<'de> for ActivityState {
"Active" => Self::Active,
"AwaitingInput" => Self::AwaitingInput,
"Error" => Self::Error,
"Starting" => Self::Starting,
// Known or not, any other spelling degrades to the neutral default
_ => Self::Idle,
})
@@ -225,9 +230,10 @@ where
"Idle" => Ok(ActivityState::Idle),
"AwaitingInput" => Ok(ActivityState::AwaitingInput),
"Error" => Ok(ActivityState::Error),
"Starting" => Ok(ActivityState::Starting),
other => Err(D::Error::unknown_variant(
other,
&["Active", "Idle", "AwaitingInput", "Error"],
&["Active", "Idle", "AwaitingInput", "Error", "Starting"],
)),
}
}
@@ -383,6 +389,10 @@ pub struct CreateSessionRequest {
/// untouched.
#[serde(default)]
pub view: SessionView,
/// When `true`, the daemon auto-approves every ACP permission request
/// for this structured session.
#[serde(default)]
pub auto_approve: bool,
}
/// Result of `GET /api/dirs`: the immediate subdirectories of one directory.
@@ -522,6 +532,11 @@ pub struct SpawnPreset {
pub id: String,
pub label: String,
pub command: String,
/// When `true`, the daemon auto-approves every ACP permission request
/// for structured sessions spawned from this preset instead of surfacing
/// them to the client.
#[serde(default)]
pub auto_approve: bool,
}
/// Build provenance of the running daemon: the commit it was compiled
@@ -1251,6 +1266,7 @@ mod tests {
create_branch: true,
}),
view: SessionView::Terminal,
auto_approve: false,
};
assert_eq!(req, round_trip(&req));
}
+35 -1
View File
@@ -22,7 +22,8 @@ use chrono::Utc;
use futures_util::{SinkExt, StreamExt};
use maestro_protocol::{
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing,
GitRoot, RecentDirs, SendInputRequest, Session, SessionStatus, PROTOCOL_VERSION,
GitRoot, RecentDirs, SendInputRequest, Session, SessionStatus, StructuredPromptRequest,
PROTOCOL_VERSION,
};
use tokio::net::TcpListener;
use tokio::sync::{broadcast, oneshot};
@@ -60,6 +61,8 @@ struct Inner {
forced_error: Option<(u16, ApiError)>,
/// Everything `POST /api/sessions/:id/input` was asked to type, in order.
inputs: Vec<(Uuid, SendInputRequest)>,
/// Every ACP prompt sent to a structured session, in order.
structured_prompts: Vec<(Uuid, StructuredPromptRequest)>,
}
#[derive(Clone)]
@@ -92,6 +95,7 @@ impl MockDaemon {
.route(routes::SESSIONS, get(list_sessions).post(create_session))
.route(routes::patterns::SESSION, delete(kill_session))
.route(routes::patterns::SESSION_INPUT, post(send_input))
.route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt))
.route(routes::INFO, get(daemon_info))
.route(routes::DIRS, get(list_dir))
.route(routes::RECENT_DIRS, get(recent_dirs))
@@ -178,6 +182,16 @@ impl MockDaemon {
self.state.inner.lock().expect("poisoned").inputs.clone()
}
/// Every ACP prompt this daemon received, oldest first.
pub fn structured_prompts(&self) -> Vec<(Uuid, StructuredPromptRequest)> {
self.state
.inner
.lock()
.expect("poisoned")
.structured_prompts
.clone()
}
pub fn session_count(&self) -> usize {
self.state.inner.lock().expect("poisoned").sessions.len()
}
@@ -266,6 +280,26 @@ async fn send_input(
StatusCode::NO_CONTENT.into_response()
}
async fn structured_prompt(
State(s): State<AppState>,
Path(id): Path<Uuid>,
Json(req): Json<StructuredPromptRequest>,
) -> Response {
if let Some(r) = forced(&s) {
return r;
}
let mut guard = s.inner.lock().expect("poisoned");
if !guard.sessions.iter().any(|x| x.id == id) {
return (
StatusCode::NOT_FOUND,
Json(ApiError::new("not_found", "no such session on this daemon")),
)
.into_response();
}
guard.structured_prompts.push((id, req));
StatusCode::NO_CONTENT.into_response()
}
async fn daemon_info(State(s): State<AppState>) -> Response {
if let Some(r) = forced(&s) {
return r;
+98
View File
@@ -11,6 +11,7 @@
//! `dispatch` / the overlay routing.
use std::collections::{HashMap, HashSet};
use std::io::Write;
use std::path::PathBuf;
use std::time::Duration;
@@ -54,6 +55,36 @@ enum OverlayDecision {
Pass,
}
fn copy_to_terminal_clipboard(text: &str) -> std::io::Result<()> {
let encoded = base64_encode(text.as_bytes());
let mut output = std::io::stdout();
write!(output, "\x1b]52;c;{encoded}\x07")?;
output.flush()
}
fn base64_encode(bytes: &[u8]) -> String {
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut output = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let bits = (u32::from(chunk[0]) << 16)
| (u32::from(*chunk.get(1).unwrap_or(&0)) << 8)
| u32::from(*chunk.get(2).unwrap_or(&0));
output.push(TABLE[((bits >> 18) & 0x3f) as usize] as char);
output.push(TABLE[((bits >> 12) & 0x3f) as usize] as char);
output.push(if chunk.len() > 1 {
TABLE[((bits >> 6) & 0x3f) as usize] as char
} else {
'='
});
output.push(if chunk.len() > 2 {
TABLE[(bits & 0x3f) as usize] as char
} else {
'='
});
}
output
}
pub struct App {
cfg: TuiConfig,
home_dir: String,
@@ -340,6 +371,10 @@ impl App {
return Ok(false);
}
if self.handle_structured_mouse(&key) {
return Ok(false);
}
if let Some(effects) = self.handle_structured_key(&key) {
self.perform(effects).await;
return Ok(false);
@@ -423,6 +458,61 @@ impl App {
true
}
/// Transcript blocks are pointer targets: click selects the whole block,
/// gives it the selection surface, and copies its complete text.
fn handle_structured_mouse(&mut self, key: &InputKey) -> bool {
let InputKey::Mouse {
kind,
region: Region::Pane,
row,
..
} = key
else {
return false;
};
if !matches!(kind, MouseKind::Click | MouseKind::Hover) {
return false;
}
let Some(sid) = self.state.active_session() else {
return false;
};
let Some(handle) = self.structured.get(&sid) else {
return false;
};
let Some(pane) = self.region.pane else {
return false;
};
let offset = usize::from(row.saturating_sub(pane.y));
let transcript_height = pane.height.saturating_sub(pane.height.min(3));
if offset >= usize::from(transcript_height) {
return true;
}
let mut state = handle.state.lock().expect("structured state mutex");
let rendered_line = state.scroll.saturating_add(offset);
if !state.select_rendered_line(rendered_line) {
return true;
}
let Some(id) = state.selected.as_deref() else {
return true;
};
let Some(transcript) = state.rows.iter().find(|item| item.id == id) else {
return true;
};
if *kind == MouseKind::Hover {
return true;
}
let text = crate::structured_pane::copy_text(transcript);
drop(state);
if text.is_empty() {
return true;
}
match copy_to_terminal_clipboard(&text) {
Ok(()) => self.state.toast("copied transcript block"),
Err(error) => self.state.toast(format!("copy failed: {error}")),
}
true
}
/// Carry out what `dispatch` decided. The only place in `App` that talks to
/// the daemon.
async fn perform(&mut self, effects: Vec<Effect>) {
@@ -1905,6 +1995,12 @@ async fn fetch_branches(
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clipboard_payload_uses_standard_base64() {
assert_eq!(base64_encode(b"hello"), "aGVsbG8=");
assert_eq!(base64_encode("✓".as_bytes()), "4pyT");
}
use crate::action::Action;
use crate::draft::DraftField;
use crate::keymap::Mode;
@@ -3009,6 +3105,7 @@ mod tests {
env: Default::default(),
metadata: Default::default(),
worktree: None,
auto_approve: false,
};
app.submit_spawn(req).await;
@@ -3057,6 +3154,7 @@ mod tests {
env: Default::default(),
metadata: Default::default(),
worktree: None,
auto_approve: false,
};
let structured_id = Uuid::from_u128(188);
+17
View File
@@ -734,6 +734,10 @@ impl DraftPane {
);
}
let worktree = self.build_worktree()?;
let auto_approve = self
.presets
.get(self.selected_preset)
.is_some_and(|p| p.auto_approve);
Ok(CreateSessionRequest {
view: if self.structured_view && self.supports_structured_view() {
SessionView::Structured
@@ -745,6 +749,7 @@ impl DraftPane {
env: Default::default(),
metadata,
worktree,
auto_approve,
})
}
@@ -1792,6 +1797,7 @@ mod tests {
id: "aider".into(),
label: "Aider".into(),
command: "aider".into(),
auto_approve: false,
}]);
assert_eq!(
draft.command(),
@@ -1814,11 +1820,13 @@ mod tests {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
auto_approve: false,
},
SpawnPreset {
id: "aider".into(),
label: "Aider".into(),
command: "aider".into(),
auto_approve: false,
},
]);
assert_eq!(draft.command(), "aider");
@@ -1838,6 +1846,7 @@ mod tests {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
auto_approve: false,
}]);
assert!(draft.is_custom());
assert_eq!(draft.command(), "bash -l");
@@ -1914,16 +1923,19 @@ mod tests {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
auto_approve: false,
},
SpawnPreset {
id: "codex".into(),
label: "Codex".into(),
command: "codex".into(),
auto_approve: false,
},
SpawnPreset {
id: "opencode".into(),
label: "OpenCode".into(),
command: "opencode".into(),
auto_approve: false,
},
]);
assert!(draft.structured_view, "on by default for a supported agent");
@@ -2066,16 +2078,19 @@ mod tests {
id: "claude-wrapped".into(),
label: "Claude (npx)".into(),
command: "npx claude-code --resume".into(),
auto_approve: false,
},
SpawnPreset {
id: "codex-wrapped".into(),
label: "Codex (npx)".into(),
command: "npx codex exec".into(),
auto_approve: false,
},
SpawnPreset {
id: "aider".into(),
label: "Aider".into(),
command: "aider".into(),
auto_approve: false,
},
]);
@@ -2115,11 +2130,13 @@ mod tests {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
auto_approve: false,
},
SpawnPreset {
id: "codex".into(),
label: "Codex".into(),
command: "codex".into(),
auto_approve: false,
},
]);
assert!(draft.structured_view);
+2
View File
@@ -121,6 +121,7 @@ impl FastForwardDefaults {
branch: handle.to_string(),
create_branch: true,
}),
auto_approve: false,
})
}
@@ -184,6 +185,7 @@ mod tests {
id: id.into(),
label: id.into(),
command: command.into(),
auto_approve: false,
}
}
+7
View File
@@ -55,6 +55,7 @@ pub enum MouseKind {
Click,
DblClick,
Drag,
Hover,
ScrollUp,
ScrollDown,
}
@@ -148,6 +149,7 @@ impl InputKey {
MouseKind::Click => "click",
MouseKind::DblClick => "dblclick",
MouseKind::Drag => "drag",
MouseKind::Hover => "hover",
MouseKind::ScrollUp => "scrollup",
MouseKind::ScrollDown => "scrolldown",
};
@@ -178,6 +180,7 @@ impl InputKey {
"click" => MouseKind::Click,
"dblclick" => MouseKind::DblClick,
"drag" => MouseKind::Drag,
"hover" => MouseKind::Hover,
"scrollup" => MouseKind::ScrollUp,
"scrolldown" => MouseKind::ScrollDown,
other => return Err(InputParseError::UnknownMouseKind(other.into())),
@@ -297,6 +300,7 @@ where
let mk = match kind {
MouseEventKind::Down(MouseButton::Left) => MouseKind::Click,
MouseEventKind::Drag(MouseButton::Left) => MouseKind::Drag,
MouseEventKind::Moved => MouseKind::Hover,
MouseEventKind::ScrollUp => MouseKind::ScrollUp,
MouseEventKind::ScrollDown => MouseKind::ScrollDown,
_ => return None,
@@ -681,6 +685,9 @@ mod tests {
let s = "mouse:dblclick(sidebar-row)";
let k = InputKey::parse(s).unwrap();
assert_eq!(k.canonical(), s);
let s = "mouse:hover(pane)";
let k = InputKey::parse(s).unwrap();
assert_eq!(k.canonical(), s);
}
#[test]
+3 -2
View File
@@ -82,7 +82,7 @@ impl Surface {
pub fn activity(&self, state: ActivityState) -> Color {
match state {
ActivityState::Active => self.active,
ActivityState::Idle => self.idle,
ActivityState::Idle | ActivityState::Starting => self.idle,
ActivityState::AwaitingInput => self.awaiting,
ActivityState::Error => self.error,
}
@@ -928,9 +928,10 @@ pub fn contrast_ratio(a: Color, b: Color) -> Option<f64> {
mod tests {
use super::*;
const STATES: [ActivityState; 4] = [
const STATES: [ActivityState; 5] = [
ActivityState::Active,
ActivityState::Idle,
ActivityState::Starting,
ActivityState::AwaitingInput,
ActivityState::Error,
];
+2
View File
@@ -979,6 +979,7 @@ mod tests {
for state in [
ActivityState::Active,
ActivityState::Idle,
ActivityState::Starting,
ActivityState::AwaitingInput,
ActivityState::Error,
] {
@@ -1009,6 +1010,7 @@ mod tests {
for state in [
ActivityState::Active,
ActivityState::Idle,
ActivityState::Starting,
ActivityState::AwaitingInput,
ActivityState::Error,
] {
+83 -6
View File
@@ -308,11 +308,12 @@ fn fallback_after_removal(
fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> {
let mut out = Vec::new();
for row in &state.rows {
for (index, row) in state.rows.iter().enumerate() {
if row.kind == TranscriptRowKind::Plan {
continue;
}
let row_width = width.saturating_sub(1).max(1);
let start = out.len();
match row.kind {
TranscriptRowKind::User => push_wrapped(
&mut out,
@@ -356,10 +357,53 @@ fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> {
),
TranscriptRowKind::Plan => {}
}
if out.len() > start
&& state.rows[index + 1..]
.iter()
.any(|next| next.kind != TranscriptRowKind::Plan)
{
// A blank surface line is the vertical padding that keeps adjacent
// transcript blocks readable at a glance.
out.push(VisualLine {
text: String::new(),
row_id: None,
rail: false,
tone: Tone::Normal,
});
}
}
out
}
/// Text supplied to the terminal clipboard when a transcript block is
/// clicked. It preserves the full tool detail rather than its compact preview.
pub fn copy_text(row: &TranscriptRow) -> String {
match row.kind {
TranscriptRowKind::User
| TranscriptRowKind::AgentText
| TranscriptRowKind::AgentThought => content_text(row),
TranscriptRowKind::ToolCall | TranscriptRowKind::ToolDiff => {
let (name, argument) = tool_title(row);
let mut lines = vec![if argument.is_empty() {
name
} else {
format!("{name} {argument}")
}];
lines.extend(tool_content_lines(row, None));
lines.join("\n")
}
TranscriptRowKind::Approval => row
.data
.get("toolCall")
.and_then(|tool| tool.get("title"))
.and_then(serde_json::Value::as_str)
.unwrap_or("permission requested")
.into(),
TranscriptRowKind::Notice => "[notice]".into(),
TranscriptRowKind::Plan => String::new(),
}
}
fn push_wrapped(
out: &mut Vec<VisualLine>,
row: &TranscriptRow,
@@ -1000,6 +1044,39 @@ mod tests {
);
}
#[test]
fn transcript_blocks_have_a_padding_line_between_them() {
let mut state = StructuredState::default();
state.rows = vec![
row(
"one",
TranscriptRowKind::User,
json!({"content":{"type":"text","text":"first"}}),
),
row(
"two",
TranscriptRowKind::AgentText,
json!({"content":{"type":"text","text":"second"}}),
),
];
let lines = build_lines(&state, 80);
assert_eq!(lines.len(), 3);
assert_eq!(lines[1].text, "");
assert!(lines[1].row_id.is_none());
}
#[test]
fn copying_a_tool_keeps_its_full_detail() {
let tool = row(
"t",
TranscriptRowKind::ToolCall,
json!({"title":"Bash(cargo test)","content":[{"type":"content","content":{"type":"text","text":"all green"}}]}),
);
assert_eq!(copy_text(&tool), "Bash cargo test\nall green");
}
#[test]
fn tool_is_compact_until_selected_row_expands() {
let mut state = StructuredState::default();
@@ -1420,7 +1497,7 @@ mod tests {
let mut state = StructuredState::default();
state.follow_selection = false;
numbered_rows(&mut state, 20);
state.scroll = 10;
state.scroll = 20;
let before = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(before[0].contains("line 10"), "{before:?}");
@@ -1439,7 +1516,7 @@ mod tests {
let mut state = StructuredState::default();
state.follow_selection = false;
numbered_rows(&mut state, 20);
state.scroll = 10;
state.scroll = 20;
let before = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(before[0].contains("line 10"), "{before:?}");
@@ -1459,7 +1536,7 @@ mod tests {
let mut state = StructuredState::default();
state.follow_selection = false;
numbered_rows(&mut state, 20);
state.scroll = 10;
state.scroll = 20;
let before = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(before[0].contains("line 10"), "{before:?}");
@@ -1478,7 +1555,7 @@ mod tests {
let mut state = StructuredState::default();
state.follow_selection = false;
numbered_rows(&mut state, 20);
state.scroll = 10;
state.scroll = 20;
let before = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(before[0].contains("line 10"), "{before:?}");
@@ -1497,7 +1574,7 @@ mod tests {
let mut state = StructuredState::default();
state.follow_selection = false;
numbered_rows(&mut state, 20);
state.scroll = 10;
state.scroll = 20;
render_transcript_lines(&pane, &mut state, 30, 5);
// Simulate a `TranscriptDelta::Patch` on a collapsed tool row above the
+31
View File
@@ -199,6 +199,18 @@ impl StructuredState {
self.follow_selection = true;
}
/// Select the transcript block under a rendered transcript line. Pointer
/// input uses the same row identity map as scroll reconciliation, so a
/// wrapped block behaves as one target.
pub fn select_rendered_line(&mut self, line: usize) -> bool {
let Some(id) = self.line_rows.get(line).and_then(Clone::clone) else {
return false;
};
self.selected = Some(id);
self.follow_selection = false;
true
}
pub fn toggle_selected(&mut self) {
let Some(id) = self.selected.clone() else {
return;
@@ -657,6 +669,25 @@ mod tests {
assert_eq!(state.composer.value(), "hi");
}
#[test]
fn selecting_a_rendered_line_selects_its_whole_block() {
let mut state = StructuredState {
line_rows: vec![
Some("one".into()),
Some("one".into()),
None,
Some("two".into()),
],
..Default::default()
};
assert!(state.select_rendered_line(1));
assert_eq!(state.selected.as_deref(), Some("one"));
assert!(!state.follow_selection);
assert!(!state.select_rendered_line(2));
assert_eq!(state.selected.as_deref(), Some("one"));
}
#[test]
fn authoritative_idle_snapshot_reconciles_a_wholly_missed_turn() {
let mut state = StructuredState::default();
+1
View File
@@ -160,6 +160,7 @@ The state word is never spelled out — the colour carries it.
| ---------------- | --------- | ---------------- |
| `Active` | `--live` | `active` |
| `Idle` | `--muted` | `idle` |
| `Starting` | `--muted` | `idle` |
| `AwaitingInput` | `--warn` | `awaiting` |
| `Error` | `--danger`| `error` |
+16 -5
View File
@@ -190,9 +190,16 @@ choices, not daemon ones.
from the transcript; the TUI may show the current plan in its status line,
while the web client deliberately has no stats or plan rail.
- The web client groups two or more tool rows after the same `User` row into
one collapsed turn activity block. Opening it reveals independently
expandable tool calls, including any raw command, input, output, and diff
content the adapter supplied.
one collapsed turn activity block. `AgentText` and `AgentThought` rows that
appear *between* tool rows in the same turn are absorbed into the group
(intermediate narration); the last `AgentText` with no subsequent tool stays
outside as the prominent final answer. While a turn is still active
(responding or thinking), the most recent intermediate `AgentText` or
`AgentThought` in the group is kept outside as visible narration — replaced
by the next text row when one arrives, and absorbed into the group when the
turn ends. The header still reads "N tool calls" (counting only tool rows).
Opening the group reveals tools and intermediate text in chronological
order, with the text rendered in a subdued style.
- The web client's user, agent, thought, tool-detail, and notice text blocks
copy their complete source text when clicked or keyboard-activated.
- The web client consumes `available_commands_update` notices as the live
@@ -346,11 +353,15 @@ thing a callback can be attributed to, so at most one turn may ever be
in flight, and a cancelled turn must fully settle before the next prompt is
allowed to claim the daemon's idea of "the active turn."
- A prompt is appended as a durable `User` row before it claims the turn and
- Web clients may collect a visible FIFO of operator messages while a turn is
active. They submit each queued message only after the projected turn becomes
idle; the queue is client-local and unsent messages do not survive a reload.
- A submitted prompt is appended as a durable `User` row before it claims the turn and
dispatches to ACP, so the transcript never depends on an adapter echoing the
operator's input. If that append fails, the prompt is not dispatched. While a
turn is active, a second prompt gets `409 turn_in_flight` rather than
queuing — one active turn per session, never a hidden queue of prompts.
queuing in the daemon — one active turn per session, never a hidden
server-side queue of prompts.
- Cancel sends `session/cancel`, clears any pending approval as `Cancelled`,
and ends the turn's client-visible lifecycle immediately. If the cancelled
turn was still *dispatched* to ACP (waiting on its response), the daemon
+1
View File
@@ -24,6 +24,7 @@
</head>
<body>
<div id="app"></div>
<div id="root"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+12 -2
View File
@@ -17,12 +17,22 @@
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^5.5.0",
"marked": "^18.0.11"
"marked": "^18.0.11",
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^4.7.0",
"happy-dom": "^20.11.6",
"tailwindcss": "^4.3.3",
"typescript": "^5.6.3",
"vite": "^5.4.10",
"vitest": "^2.1.9"
"vitest": "^2.1.9",
"zustand": "^5.0.15"
}
}
+984 -11
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
import { ThemeProvider } from "./contexts/ThemeContext";
export function App() {
return (
<ThemeProvider>
<div id="react-root" />
</ThemeProvider>
);
}
+4 -3
View File
@@ -663,8 +663,9 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
applyNav();
};
// Hover previews the row under the pointer (docs/PREVIEW.md). Touch is left
// out: there is no hover there, and a tap already opens the session.
// Pointer hover previews only while Ctrl is held — plain hover was too
// disruptive when sweeping the sidebar. Keyboard walk (j/k) still previews
// unconditionally via applyNav.
let hovered: string | null = null;
const setHovered = (id: string | null) => {
if (hovered === id) return;
@@ -672,7 +673,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
opts.onPreview?.(id);
};
treeHost.addEventListener("pointerover", (e) => {
if (e.pointerType === "touch") return;
if (e.pointerType === "touch" || !e.ctrlKey) return;
const row = (e.target as HTMLElement | null)?.closest<HTMLElement>(".tree-leaf");
setHovered(row?.dataset.sessionId ?? null);
});
@@ -0,0 +1,47 @@
// docs/SIDEBAR.md: a 5px bar pinned to the row's own left edge, square
// corners, full card height. It paints for every card, Idle included, and it
// carries the accessible name of the state so status is never colour-only.
import { activityColorSlug } from "../../activity.js";
import type { ActivityState } from "../../types.js";
export const STATE_WORDS: Record<string, string> = {
active: "Active",
idle: "Idle",
"awaiting-input": "Awaiting input",
error: "Error",
};
/** Rail fill per state, and the motion the state earns. */
const RAIL_CLASS: Record<string, string> = {
active: "bg-live",
idle: "bg-border-strong",
"awaiting-input": "bg-warn rail-blink",
error: "bg-danger rail-flash",
};
/** Duration text inherits the rail's colour (docs/SIDEBAR.md). */
export const DURATION_CLASS: Record<string, string> = {
active: "text-live",
idle: "text-muted",
"awaiting-input": "text-warn",
error: "text-danger",
};
export function stateWordFor(activity: ActivityState | string | undefined): string {
return STATE_WORDS[activityColorSlug(activity ?? "Idle")] ?? STATE_WORDS.idle;
}
export function ActivityRail({ activity }: { activity: ActivityState | string | undefined }) {
const slug = activityColorSlug(activity ?? "Idle");
const word = STATE_WORDS[slug] ?? STATE_WORDS.idle;
return (
<span
className={`absolute left-0 top-0 bottom-0 w-[5px] ${RAIL_CLASS[slug] ?? RAIL_CLASS.idle}`}
data-activity={slug}
role="img"
title={word}
aria-label={word}
/>
);
}
@@ -0,0 +1,64 @@
// Right-click menu on a session card (docs/SIDEBAR.md): rename, spawn similar,
// kill. Rendered into <body> so a scrolling sidebar cannot clip it.
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
export interface ContextMenuItem {
label: string;
danger?: boolean;
onSelect(): void;
}
export interface ContextMenuProps {
x: number;
y: number;
items: ContextMenuItem[];
onClose(): void;
}
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
const onDown = (e: Event) => {
if (!ref.current?.contains(e.target as Node)) onClose();
};
document.addEventListener("keydown", onKey);
document.addEventListener("pointerdown", onDown, true);
return () => {
document.removeEventListener("keydown", onKey);
document.removeEventListener("pointerdown", onDown, true);
};
}, [onClose]);
return createPortal(
<div
ref={ref}
role="menu"
className="context-menu fixed z-modal min-w-[160px] rounded-r-2 border border-border-strong bg-surface py-sp-1 shadow-2"
style={{ left: x, top: y }}
>
{items.map((item) => (
<button
key={item.label}
type="button"
role="menuitem"
className={`block w-full px-sp-3 py-sp-1 text-left text-fs-12 hover:bg-surface-2 ${
item.danger ? "text-danger" : "text-ink"
}`}
onClick={() => {
onClose();
item.onSelect();
}}
>
{item.label}
</button>
))}
</div>,
document.body,
);
}
+51
View File
@@ -0,0 +1,51 @@
// A collapsible directory in the sidebar tree. Only a *collapsed* header is a
// keyboard-walk stop (docs/SIDEBAR.md); an expanded one is decoration in front
// of the cards it holds, so it carries no highlight of its own.
import type { ReactNode } from "react";
import { splitFolderLabel } from "./model.js";
export interface FolderNodeProps {
name: string;
path: string;
depth: number;
collapsed: boolean;
highlighted: boolean;
onToggle(): void;
children: ReactNode;
}
export function FolderNode(props: FolderNodeProps) {
const { stem, last } = splitFolderLabel(props.name);
return (
<div className="tree-folder">
<button
type="button"
className={`tree-folder-header flex w-full items-center gap-sp-1 py-[2px] text-fs-11 text-muted ${
props.highlighted ? "ring-1 ring-accent-ring" : ""
}`}
style={{ paddingLeft: props.depth * 16 }}
aria-expanded={!props.collapsed}
data-nav-key={`folder:${props.path}`}
data-highlighted={props.highlighted ? "true" : undefined}
title={props.name}
onClick={props.onToggle}
>
{/* One glyph, rotated when collapsed, so the mark and the rotation
never disagree about which way the folder points. */}
<span
aria-hidden="true"
className={`inline-block transition-transform ${props.collapsed ? "-rotate-90" : ""}`}
>
▾
</span>
<span className="truncate">
{stem && <span className="text-faint">{stem}/</span>}
{last}
</span>
</button>
{!props.collapsed && props.children}
</div>
);
}
@@ -0,0 +1,63 @@
// docs/SIDEBAR.md "Killing a session": every kill confirms first, and the
// confirmation names what the card last saw in the way — the daemon refuses an
// unforced kill over such a tree, and saying so beforehand is cheaper than the
// refusal. Cancel holds the focus so a stray Enter kills nothing.
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import type { InTheWay } from "../../types.js";
import { formatInTheWay } from "../../types.js";
export interface KillConfirmProps {
name: string;
blockers: InTheWay | null;
/** Set when the daemon already refused an unforced kill: this pass forces. */
force?: boolean;
onConfirm(): void;
onCancel(): void;
}
export function KillConfirm(props: KillConfirmProps) {
const cancel = useRef<HTMLButtonElement>(null);
useEffect(() => cancel.current?.focus(), []);
return createPortal(
<div
role="dialog"
aria-modal="true"
aria-label={props.force ? "Force kill session" : "Kill session"}
className="kill-confirm fixed inset-0 z-modal flex items-center justify-center bg-bg/70"
onKeyDown={(e) => {
if (e.key === "Escape") props.onCancel();
}}
>
<div className="min-w-[280px] rounded-r-2 border border-border-strong bg-surface p-sp-4 shadow-2">
<p className="text-fs-13 text-ink">
{props.force ? "Force kill" : "Kill"} <strong>{props.name}</strong>?
</p>
{props.blockers && (
<p className="mt-sp-2 text-fs-12 text-warn">{formatInTheWay(props.blockers)}</p>
)}
<div className="mt-sp-3 flex justify-end gap-sp-2">
<button
ref={cancel}
type="button"
className="rounded-r-1 border border-border px-sp-3 py-sp-1 text-fs-12 text-ink"
onClick={props.onCancel}
>
Cancel
</button>
<button
type="button"
className="rounded-r-1 bg-danger px-sp-3 py-sp-1 text-fs-12 text-accent-ink"
onClick={props.onConfirm}
>
{props.force ? "Force kill" : "Kill"}
</button>
</div>
</div>
</div>,
document.body,
);
}
+264
View File
@@ -0,0 +1,264 @@
// docs/SIDEBAR.md: a session card is always two lines — the name, then the
// adaptive meta line — with the activity rail in the gutter beside them.
import { useEffect, useRef, type MouseEvent } from "react";
import {
activityDurationLabel,
activityHeldMs,
META_LINE_MIN_HOLD_MS,
} from "../../activity.js";
import { splitCommand, toolIconSvg } from "../../toolIcon.js";
import type { InTheWay, Session } from "../../types.js";
import {
blockerStats,
customSessionName,
formatInTheWay,
isProvisionalName,
MAX_SESSION_NAME_LEN,
normalizeSessionName,
sessionBlockers,
sessionName,
} from "../../types.js";
import { ActivityRail, DURATION_CLASS, stateWordFor } from "./ActivityRail.js";
import { activityColorSlug } from "../../activity.js";
export interface SessionCardProps {
session: Session;
/** Nesting depth; the row's padding (and so the rail) shifts outward with it. */
depth: number;
/** Clock the duration is measured against — the sidebar ticks it. */
now: number;
highlighted: boolean;
/** Showing in the focused pane. */
active: boolean;
/** Open in some pane, not the focused one. */
open: boolean;
previewing: boolean;
renaming: boolean;
onSelect(): void;
onOpenMenu(x: number, y: number): void;
onSpawnSimilar(): void;
onKill(): void;
onRenameCommit(name: string): void;
onRenameCancel(): void;
}
/** The fill ladder from docs/SIDEBAR.md "Which row is filled", furthest down
* wins. Written as one ordered run so the precedence is the order it reads in. */
function rowFill(p: SessionCardProps): string {
if (p.active) return "bg-accent-fill";
if (p.previewing) return "bg-preview-tint";
if (p.open) return "bg-accent/[0.06]";
if (p.highlighted) return "bg-preview-tint";
return "";
}
export function SessionCard(props: SessionCardProps) {
const { session: s, depth, now, renaming } = props;
const name = sessionName(s);
const running = s.status === "Running";
const word = stateWordFor(s.activity);
return (
<a
className={`session-card tree-leaf relative flex flex-col gap-[1px] py-sp-1 pr-sp-2 no-underline ${rowFill(props)} ${
running ? "" : "opacity-60"
}`}
href={`#/attach/${encodeURIComponent(s.id)}`}
style={{ paddingLeft: depth * 16 + 8 }}
data-session-id={s.id}
data-nav-key={`session:${s.id}`}
data-status={running ? "running" : "dead"}
data-highlighted={props.highlighted ? "true" : undefined}
aria-label={`${name}, ${running ? "running" : "exited"}, ${word}`}
onClick={(e) => {
if (renaming) return;
e.preventDefault();
props.onSelect();
}}
onContextMenu={(e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
props.onOpenMenu(e.clientX, e.clientY);
}}
>
<ActivityRail activity={s.activity} />
<span className="flex min-w-0 items-center gap-sp-2">
{renaming ? (
<RenameField
session={s}
onCommit={props.onRenameCommit}
onCancel={props.onRenameCancel}
/>
) : (
<span
className={`min-w-0 flex-1 truncate text-fs-13 ${
isProvisionalName(s) ? "text-muted" : "text-ink"
}`}
>
{name}
</span>
)}
<span className="flex flex-none items-center gap-[2px]">
<button
type="button"
className="leaf-clone text-faint hover:text-ink"
title="Spawn similar"
aria-label={`Spawn similar to ${name}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
props.onSpawnSimilar();
}}
>
⧉
</button>
<button
type="button"
className="leaf-kill text-faint hover:text-danger"
title="Kill session"
aria-label={`Kill ${name}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
props.onKill();
}}
>
✕
</button>
</span>
</span>
<MetaLine session={s} now={now} />
</a>
);
}
/** Line 2, left to right: `[duration] [detail] command [diff stat | exited]`.
* The line itself is unconditional; only its fragments are adaptive. */
export function MetaLine({ session: s, now }: { session: Session; now: number }) {
const slug = activityColorSlug(s.activity ?? "Idle");
const held = activityHeldMs(s.activity_since, now);
const duration =
held !== null && held >= META_LINE_MIN_HOLD_MS ? activityDurationLabel(held) : null;
const blockers = sessionBlockers(s);
return (
<span
className="card-line2 flex min-w-0 items-center gap-sp-2 text-fs-11"
data-activity={slug}
>
{duration && (
<span className={`activity-duration flex-none ${DURATION_CLASS[slug] ?? "text-muted"}`}>
{duration}
</span>
)}
{s.activity_detail && (
<span className="activity-detail min-w-0 flex-1 truncate text-faint">
{s.activity_detail}
</span>
)}
<CommandFragment command={s.command} />
{blockers && <BlockedFragment blockers={blockers} />}
{s.status === "Dead" && <span className="flex-none text-faint">exited</span>}
</span>
);
}
/** docs/SIDEBAR.md: the card names the program only, as its tool mark when we
* have one. The full command line stays in the tooltip either way. */
function CommandFragment({ command }: { command: string }) {
const { tool, program } = splitCommand(command);
const icon = tool ? toolIconSvg(tool) : null;
return (
<span
className="leaf-cmd flex min-w-0 flex-none items-center gap-[4px] truncate text-faint"
title={command}
>
{icon ? (
<span className="contents" dangerouslySetInnerHTML={{ __html: icon }} />
) : (
<span className="truncate">{program}</span>
)}
</span>
);
}
/** Blocked work in diff notation — `+124 -18 ?3 ↑2`. Only `+` and `-` take
* colour; the spelled-out breakdown survives as the tooltip. */
function BlockedFragment({ blockers }: { blockers: InTheWay }) {
const stats = blockerStats(blockers);
return (
<span
className="leaf-blocked flex flex-none items-center gap-[4px] text-faint"
title={`Kill will be refused unless forced — ${formatInTheWay(blockers)}`}
>
{stats.map((st) => (
<span
key={st.kind}
data-stat={st.kind}
className={
st.kind === "add" ? "text-live" : st.kind === "del" ? "text-danger" : "text-muted"
}
>
{st.text}
</span>
))}
</span>
);
}
/** Inline rename (#125): Enter commits, Escape and blur cancel, an emptied
* field clears the name so the command fallback resumes. It starts from the
* session's own name, never from the command it falls back to. */
function RenameField({
session: s,
onCommit,
onCancel,
}: {
session: Session;
onCommit(name: string): void;
onCancel(): void;
}) {
const ref = useRef<HTMLInputElement>(null);
const settled = useRef(false);
useEffect(() => {
ref.current?.focus();
ref.current?.select();
}, []);
return (
<input
ref={ref}
type="text"
className="leaf-rename min-w-0 flex-1 bg-surface-2 text-fs-13 text-ink"
defaultValue={customSessionName(s) ?? ""}
maxLength={MAX_SESSION_NAME_LEN}
aria-label={`Rename ${sessionName(s)}`}
// The card is an <a> the tree also drags from: without these, clicking
// into the field navigates away or starts a drag.
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Enter") {
e.preventDefault();
if (settled.current) return;
settled.current = true;
onCommit(normalizeSessionName(e.currentTarget.value));
} else if (e.key === "Escape") {
e.preventDefault();
if (settled.current) return;
settled.current = true;
onCancel();
}
}}
onBlur={() => {
if (settled.current) return;
settled.current = true;
onCancel();
}}
/>
);
}
+227
View File
@@ -0,0 +1,227 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Session } from "../../types.js";
import { Sidebar } from "./Sidebar.js";
afterEach(() => {
cleanup();
vi.useRealTimers();
});
function session(id: string, over: Partial<Session> = {}): Session {
return {
id,
cwd: "/home/n/alpha",
command: "hx src/",
env: {},
metadata: { name: id },
status: "Running",
created_at: "2026-08-24T10:00:00Z",
...over,
} as Session;
}
/** The card element for a session, by the `data-session-id` the row carries. */
function card(id: string): HTMLElement {
const el = document.querySelector<HTMLElement>(`[data-session-id="${id}"]`);
if (!el) throw new Error(`no card for ${id}`);
return el;
}
function tree(): HTMLElement {
return document.querySelector<HTMLElement>(".tree")!;
}
describe("SessionCard layout", () => {
it("renders two lines: the name, then a meta line naming the program", () => {
render(<Sidebar sessions={[session("alpha")]} />);
const row = card("alpha");
expect(row.textContent).toContain("alpha");
const meta = row.querySelector(".card-line2")!;
// The program only — never its arguments.
expect(meta.textContent).toContain("hx");
expect(meta.textContent).not.toContain("src/");
});
it("shows no duration under the 30s hold, and one above it", () => {
const now = Date.parse("2026-08-24T10:00:00Z");
vi.setSystemTime(now);
const fresh = session("fresh", { activity: "Active", activity_since: "2026-08-24T09:59:50Z" });
const held = session("held", { activity: "Active", activity_since: "2026-08-24T09:56:00Z" });
render(<Sidebar sessions={[fresh, held]} />);
expect(card("fresh").querySelector(".activity-duration")).toBeNull();
expect(card("held").querySelector(".activity-duration")!.textContent).toBe("4m");
});
it("paints a rail for every state, blinking on AwaitingInput and flashing on Error", () => {
render(
<Sidebar
sessions={[
session("idle"),
session("await", { activity: "AwaitingInput" }),
session("err", { activity: "Error" }),
]}
/>,
);
const rail = (id: string) => card(id).querySelector<HTMLElement>("[data-activity]")!;
expect(rail("idle").dataset.activity).toBe("idle");
expect(rail("idle").className).not.toContain("rail-");
expect(rail("await").className).toContain("rail-blink");
expect(rail("err").className).toContain("rail-flash");
// Status never rests on colour alone.
expect(rail("await").getAttribute("aria-label")).toBe("Awaiting input");
});
it("renders blocked work as diff notation with the spelled-out tooltip", () => {
render(
<Sidebar
sessions={[
session("alpha", {
blocked_work: { uncommitted_files: 2, insertions: 124, deletions: 18, untracked_files: 3 },
}),
]}
/>,
);
const blocked = card("alpha").querySelector<HTMLElement>(".leaf-blocked")!;
expect(blocked.textContent).toBe("+124-18?3");
expect(blocked.title).toContain("2 uncommitted files");
});
it("dims a provisional name and leaves a settled one alone", () => {
render(
<Sidebar
sessions={[
session("gen", { metadata: { name: "gen", name_source: "Generated" } }),
session("user", { metadata: { name: "user", name_source: "User" } }),
]}
/>,
);
expect(card("gen").querySelector(".text-muted")).not.toBeNull();
expect(card("user").textContent).toContain("user");
});
});
describe("keyboard walk", () => {
const two = [session("a", { cwd: "/one" }), session("b", { cwd: "/two" })];
it("j and k step the highlight through the cards", () => {
render(<Sidebar sessions={two} />);
fireEvent.keyDown(tree(), { key: "j" });
expect(card("a").dataset.highlighted).toBe("true");
fireEvent.keyDown(tree(), { key: "j" });
expect(card("b").dataset.highlighted).toBe("true");
fireEvent.keyDown(tree(), { key: "k" });
expect(card("a").dataset.highlighted).toBe("true");
});
it("stops on a collapsed folder header and Enter expands onto its first card", () => {
render(<Sidebar sessions={two} />);
const header = document.querySelector<HTMLElement>('[data-nav-key="folder:one"]')!;
fireEvent.click(header);
expect(header.getAttribute("aria-expanded")).toBe("false");
fireEvent.keyDown(tree(), { key: "j" });
expect(
document.querySelector<HTMLElement>('[data-nav-key="folder:one"]')!.dataset.highlighted,
).toBe("true");
fireEvent.keyDown(tree(), { key: "Enter" });
expect(card("a").dataset.highlighted).toBe("true");
});
it("Enter on a card opens it, and Tab hands the keyboard to the pane", () => {
const onSelect = vi.fn();
const onFocusPane = vi.fn();
render(<Sidebar sessions={two} onSelect={onSelect} onFocusPane={onFocusPane} />);
fireEvent.keyDown(tree(), { key: "j" });
fireEvent.keyDown(tree(), { key: "Enter" });
expect(onSelect).toHaveBeenCalledWith("a");
fireEvent.keyDown(tree(), { key: "Tab" });
expect(onFocusPane).toHaveBeenCalled();
});
it("previews the session the walk lands on", () => {
const onPreview = vi.fn();
render(<Sidebar sessions={two} onPreview={onPreview} />);
fireEvent.keyDown(tree(), { key: "j" });
expect(onPreview).toHaveBeenCalledWith("a");
});
it("x asks to kill the highlighted session and r renames it", () => {
const onKill = vi.fn();
render(<Sidebar sessions={two} onKill={onKill} />);
fireEvent.keyDown(tree(), { key: "j" });
fireEvent.keyDown(tree(), { key: "x" });
expect(onKill).toHaveBeenCalledWith(expect.objectContaining({ id: "a" }));
fireEvent.keyDown(tree(), { key: "r" });
expect(card("a").querySelector(".leaf-rename")).not.toBeNull();
});
});
describe("context menu", () => {
it("offers rename, spawn similar and kill", () => {
const onSpawnSimilar = vi.fn();
const onKill = vi.fn();
render(
<Sidebar sessions={[session("alpha")]} onSpawnSimilar={onSpawnSimilar} onKill={onKill} />,
);
fireEvent.contextMenu(card("alpha"));
const labels = [...document.querySelectorAll('[role="menuitem"]')].map((n) => n.textContent);
expect(labels).toEqual(["Rename…", "Spawn similar", "Kill session"]);
fireEvent.click(screen.getByText("Spawn similar"));
expect(onSpawnSimilar).toHaveBeenCalledWith(expect.objectContaining({ id: "alpha" }));
});
it("renames in place: Enter commits the field, Escape cancels", () => {
const onRename = vi.fn();
render(<Sidebar sessions={[session("alpha")]} onRename={onRename} />);
fireEvent.contextMenu(card("alpha"));
fireEvent.click(screen.getByText("Rename…"));
const input = card("alpha").querySelector<HTMLInputElement>(".leaf-rename")!;
expect(input.value).toBe("alpha");
fireEvent.change(input, { target: { value: " renamed " } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onRename).toHaveBeenCalledWith("alpha", "renamed");
expect(card("alpha").querySelector(".leaf-rename")).toBeNull();
});
});
describe("search", () => {
it("filters the tree down to matching sessions", () => {
render(<Sidebar sessions={[session("alpha"), session("beta", { cwd: "/home/n/beta" })]} />);
fireEvent.change(screen.getByLabelText("Search sessions"), { target: { value: "beta" } });
expect(document.querySelector('[data-session-id="alpha"]')).toBeNull();
expect(document.querySelector('[data-session-id="beta"]')).not.toBeNull();
});
});
describe("empty state", () => {
it("offers a spawn when there are no sessions at all", () => {
const onSpawn = vi.fn();
render(<Sidebar sessions={[]} onSpawn={onSpawn} />);
fireEvent.click(screen.getByText("+ Spawn session"));
expect(onSpawn).toHaveBeenCalled();
});
});
+368
View File
@@ -0,0 +1,368 @@
// React port of the sidebar tree (catalog.ts). The contract it renders is
// docs/SIDEBAR.md: two-line cards behind an activity rail, folder-grouped by
// repository, walked with j/k.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useStore } from "zustand";
import { ApiRequestError, killSession, renameSession } from "../../api.js";
import type { CatalogStore, CatalogStoreApi } from "../../stores/catalogStore.js";
import type { InTheWay, Session } from "../../types.js";
import { sessionBlockers, sessionName } from "../../types.js";
import { buildTree, type TreeNode } from "../../tree.js";
import { ContextMenu, type ContextMenuItem } from "./ContextMenu.js";
import { FolderNode } from "./FolderNode.js";
import { KillConfirm } from "./KillConfirm.js";
import { SessionCard } from "./SessionCard.js";
import { SidebarSearch } from "./SidebarSearch.js";
import { filterSessions, firstRowUnder, navRows, sessionKey } from "./model.js";
import { useNow } from "./useNow.js";
import "./sidebar.css";
export interface SidebarProps {
/** Live catalog. Either this or `sessions` supplies the tree. */
store?: CatalogStoreApi;
/** Static session list — what tests and the design kit render from. */
sessions?: Session[];
/** The daemon's home dir, so cwds abbreviate to `~/…`. */
homeDir?: string;
/** Showing in the focused pane. */
activeId?: string | null;
/** Open in some pane, focused one included. */
openIds?: ReadonlySet<string>;
/** Previewing (docs/PREVIEW.md) — a view, never durable state. */
previewId?: string | null;
onSelect?(id: string): void;
/** The highlight moved onto a session, or off every session. */
onPreview?(id: string | null): void;
onSpawnSimilar?(session: Session): void;
onSpawn?(): void;
/** Fast-forward spawn (`N`). */
onSpawnDefaults?(): void;
/** Tab: hand the keyboard back to the pane. */
onFocusPane?(): void;
onHelp?(): void;
/** Overrides the built-in `DELETE /api/sessions/:id` flow (tests, kit). */
onKill?(session: Session): void;
/** Overrides the built-in `PATCH /api/sessions/:id` (tests, kit). */
onRename?(id: string, name: string): void;
}
interface MenuState {
x: number;
y: number;
session: Session;
}
interface KillState {
session: Session;
blockers: InTheWay | null;
force: boolean;
}
export function Sidebar(props: SidebarProps) {
const sessions = useSessions(props);
const now = useNow();
const [query, setQuery] = useState("");
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set<string>());
const [navKey, setNavKey] = useState<string | null>(null);
const [renamingId, setRenamingId] = useState<string | null>(null);
const [menu, setMenu] = useState<MenuState | null>(null);
const [kill, setKill] = useState<KillState | null>(null);
const treeRef = useRef<HTMLDivElement>(null);
const tree = useMemo(
() => buildTree(filterSessions(sessions, query), props.homeDir),
[sessions, query, props.homeDir],
);
const rows = useMemo(() => navRows(tree, collapsed), [tree, collapsed]);
// A row that went away — session killed, folder collapsed over it — drops
// the highlight rather than leaving it pointing at nothing.
useEffect(() => {
if (navKey !== null && !rows.some((r) => r.key === navKey)) setNavKey(null);
}, [rows, navKey]);
const preview = props.onPreview;
const previewOf = useCallback(
(key: string | null) => {
preview?.(key?.startsWith("session:") ? key.slice("session:".length) : null);
},
[preview],
);
const moveNav = useCallback(
(delta: number) => {
if (rows.length === 0) return;
const cur = rows.findIndex((r) => r.key === navKey);
// Nothing highlighted: start one off the end being walked from, so the
// first step lands on the first (or last) row.
const from = cur === -1 ? (delta > 0 ? -1 : 0) : cur;
const next = rows[(((from + delta) % rows.length) + rows.length) % rows.length];
setNavKey(next.key);
previewOf(next.key);
},
[rows, navKey, previewOf],
);
const toggleFolder = useCallback((path: string) => {
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(path)) next.delete(path);
else next.add(path);
return next;
});
}, []);
/** Enter: open the highlighted session, or expand the highlighted folder and
* land on the first row it was hiding. */
const activateNav = useCallback(() => {
const row = rows.find((r) => r.key === navKey);
if (!row) return;
if (row.kind === "session") {
props.onSelect?.(row.id);
return;
}
const expanded = new Set(collapsed);
expanded.delete(row.path);
setCollapsed(expanded);
const revealed = firstRowUnder(tree, expanded, row.path);
setNavKey(revealed?.key ?? null);
}, [rows, navKey, collapsed, tree, props]);
const beginKill = useCallback(
(s: Session) => {
if (props.onKill) {
props.onKill(s);
return;
}
setKill({ session: s, blockers: sessionBlockers(s), force: false });
},
[props],
);
const commitRename = useCallback(
(id: string, name: string) => {
setRenamingId(null);
if (props.onRename) props.onRename(id, name);
else void renameSession(id, name).catch(() => {});
},
[props],
);
/** The session behind the highlighted row, when the highlight is on a card. */
const highlightedSession = useCallback((): Session | undefined => {
const row = rows.find((r) => r.key === navKey);
if (row?.kind !== "session") return undefined;
return sessions.find((s) => s.id === row.id);
}, [rows, navKey, sessions]);
const onKeyDown = (e: React.KeyboardEvent) => {
// The inline rename field owns its own keys; the walk stays out of it.
if (e.target instanceof HTMLInputElement) return;
switch (e.key) {
case "j":
case "ArrowDown":
e.preventDefault();
moveNav(1);
return;
case "k":
case "ArrowUp":
e.preventDefault();
moveNav(-1);
return;
case "Enter":
e.preventDefault();
activateNav();
return;
case "Tab":
e.preventDefault();
props.onFocusPane?.();
return;
case "c": {
const row = rows.find((r) => r.key === navKey);
if (row?.kind !== "folder") return;
e.preventDefault();
toggleFolder(row.path);
return;
}
case "r": {
const s = highlightedSession();
if (!s) return;
e.preventDefault();
setRenamingId(s.id);
return;
}
case "x": {
const s = highlightedSession();
if (!s) return;
e.preventDefault();
beginKill(s);
return;
}
case "n":
e.preventDefault();
props.onSpawn?.();
return;
case "N":
e.preventDefault();
props.onSpawnDefaults?.();
return;
case "?":
e.preventDefault();
props.onHelp?.();
return;
}
};
const menuItems = (s: Session): ContextMenuItem[] => [
{ label: "Rename…", onSelect: () => setRenamingId(s.id) },
{ label: "Spawn similar", onSelect: () => props.onSpawnSimilar?.(s) },
{ label: "Kill session", danger: true, onSelect: () => beginKill(s) },
];
const renderNode = (node: TreeNode, depth: number): React.ReactNode => {
if (node.kind === "leaf") {
const s = node.session;
return (
<SessionCard
key={s.id}
session={s}
depth={depth}
now={now}
highlighted={navKey === sessionKey(s.id)}
active={props.activeId === s.id}
open={(props.openIds?.has(s.id) ?? false) && props.activeId !== s.id}
previewing={props.previewId === s.id && props.activeId !== s.id}
renaming={renamingId === s.id}
onSelect={() => props.onSelect?.(s.id)}
onOpenMenu={(x, y) => setMenu({ x, y, session: s })}
onSpawnSimilar={() => props.onSpawnSimilar?.(s)}
onKill={() => beginKill(s)}
onRenameCommit={(name) => commitRename(s.id, name)}
onRenameCancel={() => setRenamingId(null)}
/>
);
}
if (!node.name) {
return node.children.map((c) => renderNode(c, depth));
}
return (
<FolderNode
key={node.path}
name={node.name}
path={node.path}
depth={depth}
collapsed={collapsed.has(node.path)}
highlighted={navKey === `folder:${node.path}`}
onToggle={() => toggleFolder(node.path)}
>
{node.children.map((c) => renderNode(c, depth + 1))}
</FolderNode>
);
};
return (
<aside className="sidebar flex h-full min-h-0 flex-col bg-surface">
<SidebarSearch
value={query}
onChange={setQuery}
onDismiss={() => treeRef.current?.focus()}
/>
<div
ref={treeRef}
className="tree min-h-0 flex-1 overflow-y-auto outline-none"
tabIndex={-1}
role="tree"
aria-label="Sessions"
onKeyDown={onKeyDown}
// The keyboard walk's preview belongs to the list holding the
// keyboard: once it doesn't, the pane goes back to what was opened.
onBlur={() => preview?.(null)}
>
{sessions.length === 0 ? (
<EmptyState onSpawn={() => props.onSpawn?.()} />
) : (
renderNode(tree, 0)
)}
</div>
{menu && (
<ContextMenu
x={menu.x}
y={menu.y}
items={menuItems(menu.session)}
onClose={() => setMenu(null)}
/>
)}
{kill && (
<KillConfirm
name={sessionName(kill.session)}
blockers={kill.blockers}
force={kill.force}
onCancel={() => setKill(null)}
onConfirm={() => {
const { session: s, force } = kill;
setKill(null);
void killSession(s.id, force).catch((e: unknown) => {
// The daemon refuses an unforced kill over a worktree holding
// unsaved work; that refusal needs its own prompt
// (docs/WORKTREES.md).
if (e instanceof ApiRequestError && e.code === "kill_guarded") {
setKill({
session: s,
blockers: e.unsavedWork ?? sessionBlockers(s),
force: true,
});
}
});
}}
/>
)}
</aside>
);
}
/** The tree's one empty state: no sessions at all. */
function EmptyState({ onSpawn }: { onSpawn(): void }) {
return (
<div className="empty flex flex-col items-center gap-sp-2 p-sp-6 text-center">
<div aria-hidden="true" className="text-fs-20 text-faint">
+
</div>
<div className="text-fs-13 text-ink">No sessions yet</div>
<p className="text-fs-12 text-muted">Start your first session — it'll show up here.</p>
<button
type="button"
className="rounded-r-1 bg-accent px-sp-3 py-sp-1 text-fs-12 text-accent-ink"
onClick={onSpawn}
>
+ Spawn session
</button>
</div>
);
}
/** Sessions from the store when one is wired, else the static prop. */
function useSessions(props: SidebarProps): Session[] {
const fromStore = useStore(
// `useStore` needs a store every render; the no-store case is served by a
// stable empty stand-in whose result is thrown away below.
props.store ?? EMPTY_STORE,
(s: CatalogStore) => s.sessions,
);
return useMemo(
() => (props.store ? [...fromStore.values()] : (props.sessions ?? [])),
[props.store, props.sessions, fromStore],
);
}
// One frozen state object, not a fresh one per call: `useStore` reads the
// snapshot on every render and a new Map each time would never settle.
const EMPTY_STATE = { sessions: new Map<string, Session>() };
const EMPTY_STORE = {
getState: () => EMPTY_STATE,
getInitialState: () => EMPTY_STATE,
setState: () => {},
subscribe: () => () => {},
} as unknown as CatalogStoreApi;
@@ -0,0 +1,34 @@
// The sidebar's search field: a substring filter over the tree (name, command
// and cwd). Escape clears it and hands the keyboard back to the list.
export interface SidebarSearchProps {
value: string;
onChange(value: string): void;
/** Escape on an already-empty field — the list wants the keyboard back. */
onDismiss?(): void;
}
export function SidebarSearch({ value, onChange, onDismiss }: SidebarSearchProps) {
return (
<header className="sidebar-search p-sp-2">
<input
type="search"
className="search w-full rounded-r-1 border border-border bg-surface-2 px-sp-2 py-sp-1 text-fs-12 text-ink"
placeholder="Search sessions…"
aria-label="Search sessions"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
value={value}
onChange={(e) => onChange(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key !== "Escape") return;
e.preventDefault();
if (value) onChange("");
else onDismiss?.();
}}
/>
</header>
);
}
+8
View File
@@ -0,0 +1,8 @@
export { Sidebar, type SidebarProps } from "./Sidebar.js";
export { SessionCard, type SessionCardProps } from "./SessionCard.js";
export { FolderNode, type FolderNodeProps } from "./FolderNode.js";
export { SidebarSearch, type SidebarSearchProps } from "./SidebarSearch.js";
export { ActivityRail } from "./ActivityRail.js";
export { ContextMenu, type ContextMenuItem } from "./ContextMenu.js";
export { KillConfirm } from "./KillConfirm.js";
export * from "./model.js";
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { buildTree } from "../../tree.js";
import type { Session } from "../../types.js";
import { filterSessions, firstRowUnder, navRows, splitFolderLabel } from "./model.js";
function session(id: string, cwd: string, command = "claude"): Session {
return {
id,
cwd,
command,
env: {},
metadata: {},
status: "Running",
created_at: "2026-08-24T10:00:00Z",
} as Session;
}
describe("navRows", () => {
const tree = buildTree([session("a", "/one"), session("b", "/two")], undefined);
it("stops on every card and skips expanded folder headers", () => {
expect(navRows(tree, new Set()).map((r) => r.key)).toEqual(["session:a", "session:b"]);
});
it("stops on a collapsed folder header instead of the cards it hides", () => {
expect(navRows(tree, new Set(["one"])).map((r) => r.key)).toEqual([
"folder:one",
"session:b",
]);
});
it("names the first row an expanded folder reveals", () => {
expect(firstRowUnder(tree, new Set(), "one")?.key).toBe("session:a");
});
});
describe("splitFolderLabel", () => {
it("keeps a shallow path whole", () => {
expect(splitFolderLabel("~/maestro")).toEqual({ stem: null, last: "~/maestro" });
});
it("initials the leading segments of a deep path", () => {
expect(splitFolderLabel("~/git.naps.pt/naps62/maestro")).toEqual({
stem: "~/g/n",
last: "maestro",
});
});
});
describe("filterSessions", () => {
const all = [session("a", "/home/n/alpha", "claude"), session("b", "/home/n/beta", "bash")];
it("returns everything for a blank query", () => {
expect(filterSessions(all, " ")).toHaveLength(2);
});
it("matches the cwd and the command, case-insensitively", () => {
expect(filterSessions(all, "ALPHA").map((s) => s.id)).toEqual(["a"]);
expect(filterSessions(all, "bash").map((s) => s.id)).toEqual(["b"]);
});
});
+90
View File
@@ -0,0 +1,90 @@
// Pure model helpers behind the React sidebar. Kept out of the components so
// the walk order and the folder label can be tested without a DOM.
import type { Session } from "../../types.js";
import { sessionCwd, sessionName } from "../../types.js";
import type { TreeNode } from "../../tree.js";
/** A row the keyboard walk can land on (docs/SIDEBAR.md "Keyboard walk"):
* every session card, and a folder header only while it is collapsed. */
export type NavRow =
| { key: string; kind: "folder"; path: string }
| { key: string; kind: "session"; id: string };
export function folderKey(path: string): string {
return `folder:${path}`;
}
export function sessionKey(id: string): string {
return `session:${id}`;
}
/** The walkable rows in visual order. An expanded folder contributes nothing
* itself — its cards are one row away — while a collapsed one is the only row
* it has, so skipping it would leave the folder unreachable. */
export function navRows(root: TreeNode, collapsed: ReadonlySet<string>): NavRow[] {
const out: NavRow[] = [];
const walk = (node: TreeNode): void => {
if (node.kind === "leaf") {
out.push({ key: sessionKey(node.session.id), kind: "session", id: node.session.id });
return;
}
if (node.name && collapsed.has(node.path)) {
out.push({ key: folderKey(node.path), kind: "folder", path: node.path });
return;
}
for (const child of node.children) walk(child);
};
for (const child of root.kind === "folder" ? root.children : [root]) walk(child);
return out;
}
/** The first walkable row inside `path`, i.e. where the highlight lands when
* Enter expands that folder. */
export function firstRowUnder(
root: TreeNode,
collapsed: ReadonlySet<string>,
path: string,
): NavRow | undefined {
const found = findFolder(root, path);
if (!found) return undefined;
return navRows({ ...found, name: "" }, collapsed)[0];
}
function findFolder(node: TreeNode, path: string): Extract<TreeNode, { kind: "folder" }> | null {
if (node.kind === "leaf") return null;
if (node.path === path && node.name) return node;
for (const child of node.children) {
const hit = findFolder(child, path);
if (hit) return hit;
}
return null;
}
/** Structured form of `abbreviateFolderName`: a deep path shows its leading
* segments as initials so the tail stays readable in a narrow sidebar. */
export function splitFolderLabel(name: string): { stem: string | null; last: string } {
const parts = name.split("/");
if (parts.length <= 2) return { stem: null, last: name };
const last = parts[parts.length - 1];
const stem = parts
.slice(0, -1)
.map((p) => (p === "~" ? "~" : (Array.from(p)[0] ?? "")))
.join("/");
return { stem, last };
}
/** Case-insensitive substring filter over a session's name, command and cwd —
* what the sidebar's own search field narrows the tree with. */
export function filterSessions(sessions: Session[], query: string): Session[] {
const q = query.trim().toLowerCase();
if (!q) return sessions;
return sessions.filter((s) => {
const cwd = sessionCwd(s) ?? "";
return (
sessionName(s).toLowerCase().includes(q) ||
s.command.toLowerCase().includes(q) ||
cwd.toLowerCase().includes(q)
);
});
}
+49
View File
@@ -0,0 +1,49 @@
/* Motion for the activity rail (docs/SIDEBAR.md): a hard blink while
AwaitingInput, a fast flash while Error. Active and Idle are solid and
still — colour alone is the signal. Kept as real CSS rather than Tailwind
utilities because both need @keyframes of their own. */
@keyframes maestro-rail-blink {
0%,
50% {
opacity: 1;
}
50.01%,
100% {
opacity: 0;
}
}
@keyframes maestro-rail-flash {
0%,
100% {
opacity: 1;
}
15% {
opacity: 0.25;
}
30% {
opacity: 1;
}
45% {
opacity: 0.25;
}
60% {
opacity: 1;
}
}
.rail-blink {
animation: maestro-rail-blink 1.2s steps(1) infinite;
}
.rail-flash {
animation: maestro-rail-flash 1.4s ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.rail-blink,
.rail-flash {
animation: none;
}
}
+16
View File
@@ -0,0 +1,16 @@
import { useEffect, useState } from "react";
/** A clock that ticks at least every `intervalMs` while the page is visible.
* docs/SIDEBAR.md asks for a re-render at least every 30s so the duration's
* cutover points land within one tick. */
export function useNow(intervalMs = 30_000): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => {
if (document.visibilityState !== "visible") return;
setNow(Date.now());
}, intervalMs);
return () => clearInterval(id);
}, [intervalMs]);
return now;
}
@@ -0,0 +1,84 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ApprovalView } from "./ApprovalView.js";
afterEach(cleanup);
const ALL_OPTIONS = [
{ optionId: "a1", name: "Allow once", kind: "allow_once" as const },
{ optionId: "a2", name: "Allow always", kind: "allow_always" as const },
{ optionId: "r1", name: "Reject", kind: "reject_once" as const },
];
function buttons(): string[] {
return [...document.querySelectorAll(".st-approval-acts button")].map((b) => b.textContent ?? "");
}
describe("ApprovalView", () => {
it("asks the operator with the three decisions the daemon exposes", () => {
render(
<ApprovalView
request={{ toolCall: { title: "Write(a.ts)", locations: [{ path: "/repo/a.ts" }] }, options: ALL_OPTIONS }}
pending
onDecide={() => Promise.resolve()}
/>,
);
expect(buttons()).toEqual(["Allow once", "Allow for session", "Deny"]);
expect(screen.getByText("Write(a.ts)")).toBeTruthy();
expect(screen.getByText("/repo/a.ts")).toBeTruthy();
});
it("submits the decision the operator picked", async () => {
const onDecide = vi.fn().mockResolvedValue(undefined);
render(<ApprovalView request={{ options: ALL_OPTIONS }} pending onDecide={onDecide} />);
fireEvent.click(screen.getByText("Deny"));
await waitFor(() => expect(onDecide).toHaveBeenCalledWith("deny"));
});
it("disables every button once one is pressed, so a decision is sent once", () => {
render(<ApprovalView request={{ options: ALL_OPTIONS }} pending onDecide={() => new Promise(() => {})} />);
fireEvent.click(screen.getByText("Allow once"));
const all = [...document.querySelectorAll<HTMLButtonElement>(".st-approval-acts button")];
expect(all.every((b) => b.disabled)).toBe(true);
});
it("restores the controls when the decision failed and this approval is still pending", async () => {
const onDecide = vi.fn().mockRejectedValue(new Error("nope"));
render(<ApprovalView request={{ options: ALL_OPTIONS }} pending onDecide={onDecide} />);
fireEvent.click(screen.getByText("Allow once"));
await waitFor(() => {
const all = [...document.querySelectorAll<HTMLButtonElement>(".st-approval-acts button")];
expect(all.every((b) => !b.disabled)).toBe(true);
});
});
it("offers no button for a decision the request cannot back", () => {
render(
<ApprovalView
request={{ options: [{ optionId: "a1", name: "Allow once", kind: "allow_once" }] }}
pending
onDecide={() => Promise.resolve()}
/>,
);
expect(buttons()).toEqual(["Allow once"]);
});
it("says so when the request offers no decision at all", () => {
render(<ApprovalView request={{ options: [] }} pending onDecide={() => Promise.resolve()} />);
expect(screen.getByText("no decision offered")).toBeTruthy();
});
it("renders a resolved approval as a one-liner, with no controls", () => {
render(
<ApprovalView
request={{ toolCall: { title: "Write(a.ts)" }, options: ALL_OPTIONS }}
pending={false}
onDecide={() => Promise.resolve()}
/>,
);
expect(screen.getByText("Write(a.ts) — approved")).toBeTruthy();
expect(document.querySelector(".st-approval-acts")).toBeNull();
});
});
@@ -0,0 +1,80 @@
// An `Approval` row. The daemon exposes exactly three decisions
// (docs/STRUCTURED-VIEW.md) and mints the nonce; this only solicits one of
// them. A decision the request offers no matching ACP option for is not shown
// — its button would "allow" something that silently resolves `Cancelled`.
import { useState } from "react";
import type { AcpPermissionOption, AcpToolCallLike, StructuredApprovalDecision } from "../../types.js";
import { offeredDecisions, toolKindLabel } from "./model.js";
const SPECS: [StructuredApprovalDecision, string, string][] = [
["allow_once", "Allow once", "primary"],
["allow_for_session", "Allow for session", ""],
["deny", "Deny", "deny"],
];
export interface ApprovalViewProps {
/** The `RequestPermissionRequest` the row carries — serialized without its
* nonce, which never leaves the daemon's live wire. */
request: { toolCall?: AcpToolCallLike; options?: AcpPermissionOption[] };
/** Whether this is the one live approval. A restored/answered row is not. */
pending: boolean;
/** Resolves once the decision reached the daemon; rejects only while this
* approval is still the pending one, so the buttons can come back. */
onDecide: (decision: StructuredApprovalDecision) => Promise<void>;
}
export function ApprovalView({ request, pending, onDecide }: ApprovalViewProps) {
const [busy, setBusy] = useState(false);
const toolCall = request.toolCall ?? {};
const what = toolCall.title ?? toolKindLabel(toolCall.kind);
// Resolved approvals render as a compact one-liner inside a tool group (or
// are dropped entirely when standalone — see model.ts's transcriptItems).
if (!pending) {
return <div className="st-row st-row-approval st-approval-compact">{`${what} — approved`}</div>;
}
const offered = offeredDecisions(request.options ?? []);
const path = toolCall.locations?.[0]?.path;
const decide = (decision: StructuredApprovalDecision) => {
setBusy(true);
onDecide(decision).catch(() => {
// Rejects only while this approval is still pending — restore the
// controls so the operator can retry. The error itself is surfaced by
// the pane's banner.
setBusy(false);
});
};
return (
<div className="st-row st-row-approval">
<div className="st-approval">
<div className="st-approval-title">
<span aria-hidden="true">⚠</span> permission requested
</div>
<div className="st-approval-what">{what}</div>
{path && <div className="st-approval-path">{path}</div>}
{offered.length > 0 ? (
<div className="st-approval-acts">
{SPECS.filter(([decision]) => offered.includes(decision)).map(([decision, label, cls]) => (
<button
key={decision}
type="button"
className={cls ? `act ${cls}` : "act"}
disabled={busy}
onClick={() => decide(decision)}
>
{label}
</button>
))}
</div>
) : (
<div className="st-approval-resolved">no decision offered</div>
)}
</div>
</div>
);
}
+391
View File
@@ -0,0 +1,391 @@
// The composer: draft, attachments, `/` command autocomplete off the live
// `available_commands_update` notices, the session-settings menu, and the
// send/cancel pair. Sending is the pane's job — this only hands it a draft and
// the files that go with it.
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type ChangeEvent,
type ClipboardEvent,
type DragEvent,
type FormEvent,
type KeyboardEvent,
} from "react";
import type { AcpSessionConfigChoice, AcpSessionConfigOption } from "../../types.js";
import {
applyCommand,
commandTokenAt,
filterCommands,
orderConfigOptions,
type AvailableCommand,
} from "./model.js";
/** Per-message ceilings the daemon enforces on uploads
* (docs/STRUCTURED-VIEW.md, "Prompt and cancel"). */
export const MAX_ATTACHMENTS = 10;
export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
/** Pasted text at or above either bound becomes a `.txt` attachment instead of
* going inline. */
const PASTE_TO_FILE_CHARS = 4_000;
const PASTE_TO_FILE_LINES = 40;
export interface ComposerProps {
/** Files chosen but not yet sent. Owned by the pane so a failed send can put
* them back. */
attachments: File[];
onAttachmentsChange(files: File[]): void;
commands: AvailableCommand[];
configOptions: AcpSessionConfigOption[];
/** A turn is in flight — the cancel control replaces nothing, it appears
* beside send (a queued message is still allowed). */
turnActive: boolean;
/** An upload/POST is in flight: the attachment controls are frozen. */
uploading: boolean;
configBusy: boolean;
/** Restored into the box when a send fails. */
draft: string;
onDraftChange(draft: string): void;
onSubmit(text: string, attachments: File[]): void;
onCancel(): void;
onConfigChange(option: AcpSessionConfigOption, value: string | boolean): void;
onError(message: string): void;
menuId: string;
}
function configChoices(option: AcpSessionConfigOption): AcpSessionConfigChoice[] {
return (option.options ?? []).flatMap((choice) => ("options" in choice ? choice.options : [choice]));
}
function currentConfigLabel(option: AcpSessionConfigOption): string {
if (typeof option.currentValue === "boolean") return option.currentValue ? "On" : "Off";
return configChoices(option).find((choice) => choice.value === option.currentValue)?.name
?? String(option.currentValue);
}
export function Composer({
attachments,
onAttachmentsChange,
commands,
configOptions,
turnActive,
uploading,
configBusy,
draft,
onDraftChange,
onSubmit,
onCancel,
onConfigChange,
onError,
menuId,
}: ComposerProps) {
const inputRef = useRef<HTMLTextAreaElement | null>(null);
const fileRef = useRef<HTMLInputElement | null>(null);
const configRef = useRef<HTMLDivElement | null>(null);
const [caret, setCaret] = useState(0);
const [activeCommand, setActiveCommand] = useState(0);
const [configOpen, setConfigOpen] = useState(false);
/** Escape closes the menu until the draft or caret moves again. */
const [menuDismissed, setMenuDismissed] = useState(false);
const pasteCount = useRef(0);
// Grow with the draft, up to the CSS max-height; past that it scrolls.
useLayoutEffect(() => {
const input = inputRef.current;
if (!input) return;
input.style.height = "auto";
input.style.overflowY = "hidden";
input.style.height = `${Math.min(input.scrollHeight, 160)}px`;
if (input.scrollHeight > 160) input.style.overflowY = "auto";
}, [draft]);
useEffect(() => {
if (!configOpen) return;
const onPointerDown = (event: PointerEvent) => {
if (!configRef.current?.contains(event.target as Node)) setConfigOpen(false);
};
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [configOpen]);
const token = commandTokenAt(draft, caret, caret);
const matches = token && commands.length > 0 ? filterCommands(commands, token.query) : [];
const menuOpen = matches.length > 0 && !menuDismissed;
const activeIndex = menuOpen ? Math.min(Math.max(activeCommand, 0), matches.length - 1) : -1;
const addFiles = useCallback(
(files: File[]) => {
if (uploading) return;
const next = [...attachments];
for (const file of files) {
if (file.size > MAX_ATTACHMENT_BYTES) {
onError(`${file.name} exceeds 20 MiB. Choose a smaller file.`);
} else if (next.length >= MAX_ATTACHMENTS) {
onError(`Attach up to ${MAX_ATTACHMENTS} files per message.`);
} else {
next.push(file);
}
}
onAttachmentsChange(next);
},
[attachments, onAttachmentsChange, onError, uploading],
);
const syncCaret = () => {
setMenuDismissed(false);
setCaret(inputRef.current?.selectionStart ?? 0);
};
const chooseCommand = (command: AvailableCommand) => {
const input = inputRef.current;
if (!input) return;
const current = commandTokenAt(draft, input.selectionStart, input.selectionEnd);
if (!current) return;
const applied = applyCommand(draft, current, command.name);
onDraftChange(applied.value);
setActiveCommand(0);
// The caret has to move after React has written the new value.
requestAnimationFrame(() => {
input.focus();
input.setSelectionRange(applied.cursor, applied.cursor);
setCaret(applied.cursor);
});
};
const submit = () => {
if ((!draft.trim() && attachments.length === 0) || uploading) return;
onSubmit(draft, attachments);
};
const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (menuOpen) {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
const direction = event.key === "ArrowDown" ? 1 : -1;
setActiveCommand((index) => (index + direction + matches.length) % matches.length);
return;
}
if ((event.key === "Enter" && !event.shiftKey) || event.key === "Tab") {
event.preventDefault();
chooseCommand(matches[activeIndex]);
return;
}
if (event.key === "Escape") {
event.preventDefault();
setMenuDismissed(true);
return;
}
}
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
submit();
}
};
const onPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
const clipboard = event.clipboardData;
if (!clipboard || uploading) return;
if (clipboard.files.length) {
event.preventDefault();
addFiles(Array.from(clipboard.files));
return;
}
const text = clipboard.getData("text/plain");
if (text.length < PASTE_TO_FILE_CHARS && text.split("\n").length < PASTE_TO_FILE_LINES) return;
// No slot for it, or too big to upload: leave it inline rather than
// dropping it on the floor.
if (attachments.length >= MAX_ATTACHMENTS || new Blob([text]).size > MAX_ATTACHMENT_BYTES) return;
event.preventDefault();
addFiles([new File([text], `pasted-${++pasteCount.current}.txt`, { type: "text/plain" })]);
};
const ordered = orderConfigOptions(configOptions);
const summary = ordered.map((option) => `${option.name}: ${currentConfigLabel(option)}`).join("\n");
return (
<form
className="structured-composer"
onSubmit={(event: FormEvent) => {
event.preventDefault();
submit();
}}
onDragOver={(event: DragEvent) => {
if (event.dataTransfer?.types.includes("Files")) event.preventDefault();
}}
onDrop={(event: DragEvent) => {
if (!event.dataTransfer?.files.length) return;
event.preventDefault();
addFiles(Array.from(event.dataTransfer.files));
}}
>
<button
type="button"
className="structured-attach"
aria-label="Attach files"
title="Attach files (up to 20 MiB each)"
disabled={uploading}
onClick={() => fileRef.current?.click()}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<path d="m8 12 7-7a3 3 0 0 1 4 4L9 19a5 5 0 0 1-7-7L12 2M5 14l9-9" />
</svg>
</button>
<input
ref={fileRef}
className="structured-file-input"
type="file"
multiple
hidden
disabled={uploading}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
addFiles(Array.from(event.target.files ?? []));
event.target.value = "";
}}
/>
<div className="structured-input-wrap">
<div className="structured-attachments" aria-label="Attachments" aria-live="polite" hidden={attachments.length === 0}>
{attachments.map((file, index) => (
<span key={`${file.name}-${index}`} className="structured-attachment">
<span title={`${file.name} (${file.size.toLocaleString()} bytes)`}>{file.name}</span>
<button
type="button"
aria-label={`Remove ${file.name}`}
disabled={uploading}
onClick={() => onAttachmentsChange(attachments.filter((_, i) => i !== index))}
>
×
</button>
</span>
))}
</div>
<div id={menuId} className="structured-command-menu" role="listbox" hidden={!menuOpen}>
{matches.map((command, index) => (
<div
key={command.name}
id={`${menuId}-option-${index}`}
className="structured-command-option"
role="option"
aria-selected={index === activeIndex}
onPointerDown={(event) => event.preventDefault()}
onClick={() => chooseCommand(command)}
>
<div className="structured-command-title">
<span className="structured-command-name">{`/${command.name}`}</span>
<span className="structured-command-hint">{command.input?.hint ?? ""}</span>
</div>
<div className="structured-command-description">
{command.description.replace(/\s+/g, " ").trim()}
</div>
</div>
))}
</div>
<textarea
ref={inputRef}
className="structured-input"
rows={1}
placeholder="Reply, or / for commands…"
autoComplete="off"
aria-autocomplete="list"
aria-expanded={menuOpen}
aria-controls={menuId}
aria-activedescendant={activeIndex >= 0 ? `${menuId}-option-${activeIndex}` : undefined}
value={draft}
onChange={(event) => {
onDraftChange(event.target.value);
setActiveCommand(0);
setMenuDismissed(false);
setCaret(event.target.selectionStart ?? event.target.value.length);
}}
onClick={syncCaret}
onSelect={syncCaret}
onKeyUp={(event) => {
if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) syncCaret();
}}
onKeyDown={onKeyDown}
onPaste={onPaste}
/>
</div>
<div className="structured-config" ref={configRef} hidden={ordered.length === 0}>
<button
type="button"
className="structured-config-trigger"
aria-haspopup="true"
aria-expanded={configOpen}
aria-label={`Session settings. ${summary.replaceAll("\n", ". ")}`}
title={summary}
disabled={configBusy}
onClick={() => setConfigOpen((open) => !open)}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 7h10M18 7h2M4 17h2M10 17h10M14 4v6M6 14v6" />
</svg>
</button>
<div
className="structured-config-menu"
role="group"
aria-label="Session settings"
hidden={!configOpen}
onKeyDown={(event) => {
if (event.key !== "Escape") return;
setConfigOpen(false);
configRef.current?.querySelector<HTMLButtonElement>(".structured-config-trigger")?.focus();
}}
>
{ordered.map((option) => (
<label key={option.id} className="structured-config-row" title={option.description ?? undefined}>
<span className="structured-config-name">{option.name}</span>
{option.type === "select" ? (
<select
className="structured-config-select"
disabled={configBusy}
value={String(option.currentValue)}
onChange={(event) => onConfigChange(option, event.target.value)}
>
{configChoices(option).map((choice) => (
<option key={choice.value} value={choice.value} title={choice.description ?? ""}>
{choice.name}
</option>
))}
</select>
) : option.type === "boolean" ? (
<input
type="checkbox"
className="structured-config-toggle"
disabled={configBusy}
checked={option.currentValue === true}
onChange={(event) => onConfigChange(option, event.target.checked)}
/>
) : null}
</label>
))}
</div>
</div>
<button type="submit" className="structured-send">
<span>Send</span>
<svg viewBox="0 0 20 20" aria-hidden="true">
<path d="m7 5 5 5-5 5M12 10H3" />
</svg>
</button>
{turnActive && (
<button
type="button"
className="structured-cancel"
aria-label="Cancel turn"
title="Cancel turn"
onClick={onCancel}
>
<svg className="structured-cancel-spinner" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" strokeWidth="2" strokeDasharray="50 14" strokeLinecap="round" />
</svg>
<svg className="structured-cancel-stop" viewBox="0 0 24 24" aria-hidden="true">
<rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor" />
</svg>
</button>
)}
</form>
);
}
+101
View File
@@ -0,0 +1,101 @@
// docs/STRUCTURED-VIEW.md: the web client's user, agent, thought, tool-detail
// and notice text blocks copy their complete source text when clicked or
// keyboard-activated. The visible node is whatever it wraps; this only adds
// the affordance and the "copied"/"error" state the CSS reads.
import { useCallback, useEffect, useRef, useState, type ElementType, type ReactNode } from "react";
async function writeClipboard(text: string): Promise<void> {
try {
await navigator.clipboard.writeText(text);
return;
} catch {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
const copied = document.execCommand?.("copy") ?? false;
textarea.remove();
if (!copied) throw new Error("clipboard unavailable");
}
}
export interface CopyableProps {
/** The complete source text — not whatever the DOM happens to show. */
text: string;
/** Names the thing in the "Copy …" label ("message", "diff", "path", …). */
label: string;
as?: ElementType;
className?: string;
title?: string;
children?: ReactNode;
/** Extra attributes the row builders set (`data-row-id`, `lang`, …). */
[key: string]: unknown;
}
/** Wrapping a node in a copy affordance. Empty `text` renders the node plain —
* a control that copies nothing is worse than no control. */
export function Copyable({
text,
label,
as: Tag = "div",
className,
children,
...rest
}: CopyableProps) {
const [copyState, setCopyState] = useState<"copied" | "error" | null>(null);
const timer = useRef<number | null>(null);
useEffect(
() => () => {
if (timer.current !== null) window.clearTimeout(timer.current);
},
[],
);
const copy = useCallback(() => {
void writeClipboard(text).then(
() => {
setCopyState("copied");
if (timer.current !== null) window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setCopyState(null), 1200);
},
() => setCopyState("error"),
);
}, [text]);
if (!text) {
return (
<Tag className={className} {...rest}>
{children}
</Tag>
);
}
const ariaLabel = copyState === "copied" ? `${label} copied` : `Copy ${label}`;
return (
<Tag
className={className ? `${className} st-copyable` : "st-copyable"}
tabIndex={0}
role="button"
aria-label={ariaLabel}
title={`Copy ${label}`}
data-copy-state={copyState ?? undefined}
onClick={(event: React.MouseEvent) => {
// A link inside the block is a link, not a copy target.
if ((event.target as Element | null)?.closest("a")) return;
copy();
}}
onKeyDown={(event: React.KeyboardEvent) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
copy();
}}
{...rest}
>
{children}
</Tag>
);
}
@@ -0,0 +1,50 @@
// @vitest-environment happy-dom
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { DiffView } from "./DiffView.js";
import { toolDiffMeta } from "./model.js";
afterEach(cleanup);
function lines(kind: string): string[] {
return [...document.querySelectorAll(`.st-ln-${kind}`)].map((el) => el.textContent ?? "");
}
describe("DiffView", () => {
it("marks insertions and deletions with their column prefixes", () => {
render(<DiffView diff={{ path: "src/a.ts", oldText: "one\ntwo\n", newText: "one\nTWO\nthree\n" }} />);
expect(lines("del")).toEqual(["- two"]);
expect(lines("add")).toEqual(["+ TWO", "+ three"]);
expect(lines("ctx")).toEqual([" one"]);
expect(screen.getByText("src/a.ts")).toBeTruthy();
});
it("carries the +added −removed counts the tool head shows", () => {
render(<DiffView diff={{ path: "a.ts", oldText: "one\ntwo\n", newText: "one\nTWO\nthree\n" }} />);
const body = document.querySelector<HTMLElement>(".st-diff")!;
expect(body.dataset.added).toBe("2");
expect(body.dataset.removed).toBe("1");
expect(toolDiffMeta([{ type: "diff", path: "a.ts", oldText: "one\ntwo\n", newText: "one\nTWO\nthree\n" }]))
.toBe("+2 −1");
});
it("renders every line of a new file as added", () => {
render(<DiffView diff={{ path: "new.ts", newText: "alpha\nbeta\n" }} />);
expect(lines("add")).toEqual(["+ alpha", "+ beta"]);
expect(lines("del")).toEqual([]);
});
it("collapses a long unchanged run to a hunk summary", () => {
const filler = Array.from({ length: 20 }, (_, i) => `line ${i}`).join("\n");
render(<DiffView diff={{ path: "a.ts", oldText: `head\n${filler}\n`, newText: `HEAD\n${filler}\n` }} />);
expect(lines("hunk")).toEqual(["⋯ 17 unchanged lines ⋯"]);
});
it("copies the whole diff, prefixes included", () => {
render(<DiffView diff={{ path: "a.ts", oldText: "one\n", newText: "two\n" }} />);
const body = document.querySelector<HTMLElement>(".st-diff")!;
expect(body.getAttribute("aria-label")).toBe("Copy diff");
expect(body.textContent).toBe("- one+ two");
});
});
@@ -0,0 +1,56 @@
// A `Diff` content block, rendered as coloured hunks. ACP hands over whole
// `oldText`/`newText`, so the lines and their `+added −removed` counts are
// computed here (structuredDiff.ts) rather than parsed out of a patch.
import { useMemo } from "react";
import { computeDiffLines, diffStats, type DiffLine } from "../../structuredDiff.js";
import type { AcpDiff } from "../../types.js";
import { Copyable } from "./Copyable.js";
/** Column prefix by line kind. A `hunk` summary and an `eof` marker are their
* own complete text, so neither takes one. */
const PREFIX: Record<DiffLine["kind"], string> = {
add: "+ ",
del: "- ",
ctx: " ",
hunk: "",
eof: "",
};
export function diffLineText(line: DiffLine): string {
return PREFIX[line.kind] + line.text;
}
export interface DiffViewProps {
diff: AcpDiff;
}
export function DiffView({ diff }: DiffViewProps) {
const lines = useMemo(() => computeDiffLines(diff.oldText, diff.newText), [diff.oldText, diff.newText]);
const stats = useMemo(() => diffStats(lines), [lines]);
const plain = useMemo(() => lines.map(diffLineText).join("\n"), [lines]);
return (
<div className="st-diff-block">
<Copyable className="st-diff-path" text={diff.path} label="path" title={diff.path}>
{diff.path}
</Copyable>
<Copyable
className="st-diff"
text={plain}
label="diff"
data-added={stats.added}
data-removed={stats.removed}
>
<pre>
{lines.map((line, index) => (
<span key={index} className={`st-ln st-ln-${line.kind}`}>
{diffLineText(line)}
</span>
))}
</pre>
</Copyable>
</div>
);
}
@@ -0,0 +1,403 @@
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../api.js", async () => {
const actual = await vi.importActual<typeof import("../../api.js")>("../../api.js");
return {
...actual,
sendStructuredPrompt: vi.fn().mockResolvedValue(undefined),
uploadStructuredFile: vi.fn().mockResolvedValue({ path: "/uploads/pasted.txt" }),
cancelStructuredTurn: vi.fn().mockResolvedValue(undefined),
resolveStructuredApproval: vi.fn().mockResolvedValue(undefined),
setStructuredConfig: vi.fn().mockResolvedValue(undefined),
};
});
import {
cancelStructuredTurn,
resolveStructuredApproval,
sendStructuredPrompt,
uploadStructuredFile,
} from "../../api.js";
import { INITIAL_RECONNECT_MS } from "../../reconnect.js";
import type { ReducedState, StructuredEvent, TranscriptRow } from "../../types.js";
import { StructuredPane } from "./StructuredPane.js";
/** Just enough of a WebSocket for the pane to drive (mirrors structured.test.ts's
* own fake, which the pre-React connection used). */
class FakeSocket {
onopen: (() => void) | null = null;
onmessage: ((ev: { data: string }) => void) | null = null;
onerror: (() => void) | null = null;
onclose: ((ev: { code: number; reason: string }) => void) | null = null;
closed = false;
open(): void {
act(() => this.onopen?.());
}
emit(event: StructuredEvent): void {
act(() => this.onmessage?.({ data: JSON.stringify(event) }));
}
drop(code = 1006, reason = ""): void {
act(() => this.onclose?.({ code, reason }));
}
close(): void {
this.closed = true;
}
}
const idle: ReducedState = { turn: { responding: false, thinking: false, active_tool_calls: [] } };
const busy: ReducedState = { turn: { responding: true, thinking: false, active_tool_calls: [] } };
function row(id: string, kind: TranscriptRow["kind"], data: unknown = {}): TranscriptRow {
return { id, kind, data };
}
function chunk(id: string, kind: "user" | "agent_text" | "agent_thought", text: string): TranscriptRow {
return row(id, kind, { content: { type: "text", text } });
}
const APPROVAL_REQUEST = {
toolCall: { title: "Write(secrets.txt)", kind: "edit", locations: [{ path: "/repo/secrets.txt" }] },
options: [
{ optionId: "a1", name: "Allow once", kind: "allow_once" },
{ optionId: "a2", name: "Allow always", kind: "allow_always" },
{ optionId: "r1", name: "Reject", kind: "reject_once" },
],
};
function mount(): { socket: FakeSocket; sockets: FakeSocket[] } {
const sockets: FakeSocket[] = [];
render(
<StructuredPane
sessionId="s1"
socketFactory={() => {
const socket = new FakeSocket();
sockets.push(socket);
return socket as unknown as WebSocket;
}}
/>,
);
sockets[0].open();
return { socket: sockets[0], sockets };
}
function snapshot(socket: FakeSocket, rows: TranscriptRow[], state: ReducedState = idle): void {
socket.emit({ type: "transcript_snapshot", snapshot: { cursor: rows.length, rows } });
socket.emit({ type: "reduced_state", state });
}
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
describe("the eight row kinds", () => {
it("renders user, agent, thought, tool, diff, approval and notice rows; never a plan", () => {
const { socket } = mount();
snapshot(
socket,
[
chunk("user:1", "user", "please edit it"),
chunk("agent:1", "agent_text", "**bold** answer"),
chunk("thought:1", "agent_thought", "considering"),
row("tool:1", "tool_call", { title: "Read(src/main.ts)", kind: "read", status: "completed" }),
row("tool:2", "tool_diff", {
title: "Edit(a.ts)",
kind: "edit",
status: "completed",
content: [{ type: "diff", path: "a.ts", oldText: "one\n", newText: "one\ntwo\n" }],
}),
row("approval:1", "approval", APPROVAL_REQUEST),
row("plan", "plan", { entries: [{ content: "step", priority: "high", status: "pending" }] }),
row("notice:1", "notice", { sessionUpdate: "mystery_update" }),
],
{ ...idle, pending_approval: { nonce: "nonce-1", request: APPROVAL_REQUEST as never } },
);
expect(screen.getByText("please edit it")).toBeTruthy();
expect(document.querySelector(".st-row-agent strong")?.textContent).toBe("bold");
expect(document.querySelector(".st-row-thought")?.textContent).toContain("considering");
expect(screen.getByText("src/main.ts")).toBeTruthy();
// The diff row's head carries the line counts, not a fabricated duration.
expect(document.querySelector('[data-row-id="tool:2"] .st-tool-meta')?.textContent).toBe("+1 −0");
expect(screen.getByText("permission requested")).toBeTruthy();
expect(screen.getByText("[notice]")).toBeTruthy();
expect(document.querySelector('[data-row-id="plan"]')).toBeNull();
});
it("omits the state-only notices it consumes for `/` autocomplete", () => {
const { socket } = mount();
snapshot(socket, [
row("notice:1", "notice", {
sessionUpdate: "available_commands_update",
availableCommands: [{ name: "compact", description: "Compact the conversation" }],
}),
row("notice:2", "notice", { sessionUpdate: "session_info_update" }),
]);
expect(screen.queryByText("[notice]")).toBeNull();
// …and offers them in the composer instead.
const input = document.querySelector<HTMLTextAreaElement>(".structured-input")!;
fireEvent.change(input, { target: { value: "/comp" } });
expect(screen.getByText("/compact")).toBeTruthy();
});
it("renders a completed turn's footer inline, and retires it once the next turn starts", () => {
const { socket } = mount();
const footer = row("turn_meta:1", "notice", {
maestroTurnMeta: { elapsedMs: 65_000, outcome: "completed", model: "sonnet", contextDelta: 1200 },
});
snapshot(socket, [chunk("user:1", "user", "go"), footer]);
expect(screen.getByText("1m 5s · +1.2k context tokens · sonnet")).toBeTruthy();
socket.emit({
type: "projection_update",
transcript: { type: "append", row: chunk("user:2", "user", "again") },
state: idle,
});
expect(screen.queryByText(/1m 5s/)).toBeNull();
});
});
describe("tool rows and activity groups", () => {
it("collapses two or more tools in a turn into one group, opened on click", () => {
const { socket } = mount();
snapshot(socket, [
chunk("user:1", "user", "go"),
row("tool:1", "tool_call", { title: "Read(a.ts)", kind: "read", status: "completed" }),
chunk("agent:1", "agent_text", "still working"),
row("tool:2", "tool_call", { title: "Read(b.ts)", kind: "read", status: "completed" }),
chunk("agent:2", "agent_text", "the answer"),
]);
const head = screen.getByText("2 tool calls").closest("button")!;
expect(head.getAttribute("aria-expanded")).toBe("false");
// The final answer stays outside the group; the intermediate text is in it.
expect(document.querySelector(".st-tool-group")!.contains(screen.getByText("the answer"))).toBe(false);
expect(document.querySelector(".st-tool-group")!.contains(screen.getByText("still working"))).toBe(true);
fireEvent.click(head);
expect(head.getAttribute("aria-expanded")).toBe("true");
});
it("discloses a tool's output only once its head is opened", () => {
const { socket } = mount();
snapshot(socket, [
row("tool:1", "tool_call", {
title: "Execute(ls)",
kind: "execute",
status: "completed",
content: [{ type: "content", content: { type: "text", text: "a.ts\nb.ts" } }],
rawInput: { command: "ls" },
rawOutput: { exit_code: 0 },
}),
]);
expect(document.querySelector(".st-tool-text")).toBeNull();
fireEvent.click(document.querySelector<HTMLButtonElement>(".st-tool-head")!);
expect(document.querySelector(".st-tool-text")?.textContent).toBe("a.ts\nb.ts");
expect(screen.getByText("Exit code")).toBeTruthy();
expect(document.querySelector(".st-tool-exit-ok")?.textContent).toBe("0");
});
it("patches a tool row in place rather than appending a second one", () => {
const { socket } = mount();
snapshot(socket, [row("tool:1", "tool_call", { title: "Read(a.ts)", kind: "read", status: "in_progress" })]);
expect(document.querySelector(".st-tool-meta")?.textContent).toBe("running");
socket.emit({
type: "projection_update",
transcript: {
type: "patch",
row: row("tool:1", "tool_call", { title: "Read(a.ts)", kind: "read", status: "completed" }),
},
state: idle,
});
expect(document.querySelectorAll('[data-row-id="tool:1"]')).toHaveLength(1);
expect(document.querySelector(".st-tool-meta")).toBeNull();
});
});
describe("approvals", () => {
it("answers with the live nonce, offering only the decisions the request backs", async () => {
const { socket } = mount();
snapshot(socket, [row("approval:1", "approval", APPROVAL_REQUEST)], {
...idle,
pending_approval: { nonce: "nonce-1", request: APPROVAL_REQUEST as never },
});
expect(screen.getByText("/repo/secrets.txt")).toBeTruthy();
fireEvent.click(screen.getByText("Allow for session"));
await waitFor(() =>
expect(resolveStructuredApproval).toHaveBeenCalledWith("s1", "nonce-1", "allow_for_session"),
);
});
it("hides a decision the request offers no ACP option for", () => {
const { socket } = mount();
const request = { toolCall: { title: "Read(a.ts)" }, options: [{ optionId: "r", name: "No", kind: "reject_once" }] };
snapshot(socket, [row("approval:1", "approval", request)], {
...idle,
pending_approval: { nonce: "nonce-2", request: request as never },
});
expect(screen.getByText("Deny")).toBeTruthy();
expect(screen.queryByText("Allow once")).toBeNull();
});
it("treats only the newest approval as pending, and hides resolved standalone ones", () => {
const { socket } = mount();
snapshot(
socket,
[row("approval:1", "approval", APPROVAL_REQUEST), row("approval:2", "approval", APPROVAL_REQUEST)],
{ ...idle, pending_approval: { nonce: "nonce-3", request: APPROVAL_REQUEST as never } },
);
expect(screen.getAllByText("permission requested")).toHaveLength(1);
expect(screen.queryByText(/approved/)).toBeNull();
});
it("drops the controls once the approval is no longer pending", () => {
const { socket } = mount();
snapshot(socket, [row("approval:1", "approval", APPROVAL_REQUEST)], {
...idle,
pending_approval: { nonce: "nonce-4", request: APPROVAL_REQUEST as never },
});
expect(screen.getByText("Allow once")).toBeTruthy();
socket.emit({ type: "reduced_state", state: idle });
expect(screen.queryByText("Allow once")).toBeNull();
});
});
describe("prompt and cancel", () => {
it("sends a draft, clearing the composer", async () => {
const { socket } = mount();
snapshot(socket, []);
const input = document.querySelector<HTMLTextAreaElement>(".structured-input")!;
fireEvent.change(input, { target: { value: "do the thing" } });
fireEvent.submit(document.querySelector("form.structured-composer")!);
await waitFor(() => expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "do the thing"));
expect(input.value).toBe("");
});
it("uploads attachments and names them in the prompt", async () => {
const { socket } = mount();
snapshot(socket, []);
const file = new File(["hello"], "notes.txt", { type: "text/plain" });
fireEvent.change(document.querySelector<HTMLInputElement>(".structured-file-input")!, {
target: { files: [file] },
});
expect(screen.getByText("notes.txt")).toBeTruthy();
fireEvent.change(document.querySelector<HTMLTextAreaElement>(".structured-input")!, {
target: { value: "read this" },
});
fireEvent.submit(document.querySelector("form.structured-composer")!);
await waitFor(() => expect(uploadStructuredFile).toHaveBeenCalled());
await waitFor(() =>
expect(sendStructuredPrompt).toHaveBeenCalledWith(
"s1",
'read this\n\nAttached file "notes.txt": "/uploads/pasted.txt"',
),
);
});
it("queues a second message while a turn is in flight, and sends it when the turn ends", async () => {
const { socket } = mount();
snapshot(socket, [], busy);
fireEvent.change(document.querySelector<HTMLTextAreaElement>(".structured-input")!, {
target: { value: "next up" },
});
fireEvent.submit(document.querySelector("form.structured-composer")!);
expect(sendStructuredPrompt).not.toHaveBeenCalled();
expect(document.querySelector(".st-row-queued")?.textContent).toBe("next up");
socket.emit({ type: "reduced_state", state: idle });
await waitFor(() => expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "next up"));
});
it("hands a failed send's draft back to the operator", async () => {
vi.mocked(sendStructuredPrompt).mockRejectedValueOnce(new Error("boom"));
const { socket } = mount();
snapshot(socket, []);
const input = document.querySelector<HTMLTextAreaElement>(".structured-input")!;
fireEvent.change(input, { target: { value: "keep me" } });
fireEvent.submit(document.querySelector("form.structured-composer")!);
await waitFor(() => expect(document.querySelector(".structured-error")?.textContent).toContain("boom"));
expect(input.value).toBe("keep me");
});
it("shows the cancel control only while a turn is live", () => {
const { socket } = mount();
snapshot(socket, []);
expect(document.querySelector(".structured-cancel")).toBeNull();
socket.emit({ type: "reduced_state", state: busy });
fireEvent.click(document.querySelector<HTMLButtonElement>(".structured-cancel")!);
expect(cancelStructuredTurn).toHaveBeenCalledWith("s1");
});
it("reports live turn activity above the composer", () => {
const { socket } = mount();
snapshot(socket, [], {
turn: {
responding: true,
thinking: false,
active_tool_calls: ["t1", "t2"],
started_at: new Date(Date.now() - 5_000).toISOString(),
},
usage: { used: 12_000, size: 200_000 },
});
const activity = document.querySelector<HTMLElement>(".structured-activity")!;
expect(activity.hidden).toBe(false);
expect(activity.textContent).toContain("Running 2 tools");
expect(activity.textContent).toContain("12k context / 200k context");
});
});
describe("the socket", () => {
it("replaces the transcript wholesale on every snapshot, never merging", async () => {
const { socket, sockets } = mount();
snapshot(socket, [chunk("user:1", "user", "first")]);
socket.drop(1006);
// The retry is on reconnect.ts's backoff schedule, not immediate.
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, INITIAL_RECONNECT_MS + 20));
});
const replacement = sockets[1];
expect(replacement).toBeDefined();
replacement.open();
snapshot(replacement, [chunk("user:2", "user", "second")]);
expect(screen.queryByText("first")).toBeNull();
expect(screen.getByText("second")).toBeTruthy();
});
it("reports a daemon rejection instead of retrying it", () => {
const status = vi.fn();
const sockets: FakeSocket[] = [];
render(
<StructuredPane
sessionId="s1"
onStatusChange={status}
socketFactory={() => {
const socket = new FakeSocket();
sockets.push(socket);
return socket as unknown as WebSocket;
}}
/>,
);
sockets[0].open();
sockets[0].drop(4004, "not a structured session");
expect(sockets).toHaveLength(1);
expect(status).toHaveBeenCalledWith("error", "not a structured session");
});
});
@@ -0,0 +1,400 @@
// React port of structured.ts (#246): a live structured session — the
// transcript from `WS /api/structured/:id`, the composer that drives
// `.../prompt` and `.../cancel`, and the approval controls that answer
// `.../approvals/:nonce`. docs/STRUCTURED-VIEW.md is the contract.
//
// Like TerminalPane, the pane draws the body only: pane chrome (title,
// disconnect, sessions drawer) belongs to whatever composes it, which reads
// the connection state through `onStatusChange`.
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import {
cancelStructuredTurn,
resolveStructuredApproval,
sendStructuredPrompt,
setStructuredConfig,
uploadStructuredFile,
} from "../../api.js";
import { formatContextTokens, formatElapsed } from "../../structuredRender.js";
import type {
AcpSessionConfigOption,
StructuredApprovalDecision,
TranscriptRow as Row,
Uuid,
} from "../../types.js";
import { Composer } from "./Composer.js";
import {
commandsFromTranscript,
pendingApprovalRowId as pendingApprovalRowIdOf,
transcriptItems,
turnIsActive,
} from "./model.js";
import { ToolGroup } from "./ToolGroup.js";
import { TranscriptRow } from "./TranscriptRow.js";
import { useStructuredSession, type StructuredStatus, type StructuredSocketFactory } from "./useStructuredSession.js";
import "./structured.css";
/** Within this much of the bottom, wheel and touch imprecision should still
* count as following. */
const FOLLOW_BOTTOM_THRESHOLD_PX = 64;
interface QueuedPrompt {
text: string;
attachments: File[];
}
export interface StructuredPaneProps {
sessionId: Uuid;
/** Grabs the composer once mounted. A sidebar preview passes `false`. */
focus?: boolean;
shouldReconnect?: (code: number) => boolean;
onClosed?: () => void;
onStatusChange?: (status: StructuredStatus, detail?: string) => void;
/** Test seam: drive the socket without a daemon. */
socketFactory?: StructuredSocketFactory;
className?: string;
}
export interface StructuredPaneHandle {
/** Put the keyboard in the composer — the structured pane's terminal. */
focusComposer(): void;
}
export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPaneProps>(function StructuredPane(
{ sessionId, focus = true, shouldReconnect, onClosed, onStatusChange, socketFactory, className },
ref,
) {
const session = useStructuredSession(sessionId, { shouldReconnect, onClosed, socketFactory });
const { rows, state, status, detail, socketGeneration } = session;
const scrollRef = useRef<HTMLDivElement | null>(null);
const composerRef = useRef<HTMLDivElement | null>(null);
const followingBottom = useRef(true);
const [expandedRows, setExpandedRows] = useState<ReadonlySet<string>>(new Set());
const [expandedGroups, setExpandedGroups] = useState<ReadonlySet<string>>(new Set());
const [draft, setDraft] = useState("");
const [attachments, setAttachments] = useState<File[]>([]);
const [queue, setQueue] = useState<QueuedPrompt[]>([]);
const [uploading, setUploading] = useState(false);
const [configBusy, setConfigBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [now, setNow] = useState(() => Date.now());
/** Server path → object URL, so a user row can preview an image it uploaded. */
const imageUrls = useRef(new Map<string, string>());
// Submission bookkeeping, ported from structured.ts: a prompt is "sending"
// from the POST until the projection shows the turn it claimed, and an
// acceptance that straddles a reconnect forces one more reconnect so the
// snapshot cannot predate the 204.
const submission = useRef(0);
const accepted = useRef<{ generation: number; socketGeneration: number } | null>(null);
const optimisticStart = useRef<number | null>(null);
const promptPending = useRef(false);
const [awaitingBegin, setAwaitingBegin] = useState<number | null>(null);
useImperativeHandle(
ref,
() => ({
focusComposer: () => composerRef.current?.querySelector<HTMLTextAreaElement>(".structured-input")?.focus(),
}),
[],
);
useEffect(() => {
onStatusChange?.(status, detail ?? undefined);
}, [status, detail, onStatusChange]);
useEffect(() => {
if (!focus) return;
composerRef.current?.querySelector<HTMLTextAreaElement>(".structured-input")?.focus();
}, [focus]);
const urls = imageUrls.current;
useEffect(
() => () => {
for (const url of urls.values()) URL.revokeObjectURL(url);
urls.clear();
},
[urls],
);
const turnActive = turnIsActive(state);
const pendingRowId = useMemo(() => pendingApprovalRowIdOf(rows, state), [rows, state]);
const items = useMemo(
() => transcriptItems(rows, pendingRowId, turnActive),
[rows, pendingRowId, turnActive],
);
const commands = useMemo(() => commandsFromTranscript(rows), [rows]);
const configOptions: AcpSessionConfigOption[] = state.config_options?.configOptions ?? [];
const active = turnActive || awaitingBegin !== null;
// Stop saying "Sending" once the turn the submission was waiting for has
// begun — or, when it was accepted but a replacement socket has
// re-snapshotted since, once that newer snapshot has landed. Without the
// second case a prompt accepted across a reconnect would say "Sending"
// forever.
useEffect(() => {
if (turnActive) {
setAwaitingBegin(null);
} else if (
awaitingBegin !== null &&
accepted.current?.generation === awaitingBegin &&
socketGeneration > accepted.current.socketGeneration
) {
setAwaitingBegin(null);
}
}, [turnActive, awaitingBegin, socketGeneration]);
useEffect(() => {
if (!active) optimisticStart.current = null;
}, [active]);
// Keep the pane pinned to the newest row unless the operator scrolled away.
useLayoutEffect(() => {
const el = scrollRef.current;
if (!el || !followingBottom.current) return;
el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight);
}, [items, active, queue]);
useEffect(() => {
if (!active) return;
const timer = window.setInterval(() => setNow(Date.now()), 1_000);
return () => window.clearInterval(timer);
}, [active]);
const onScroll = () => {
const el = scrollRef.current;
if (!el) return;
followingBottom.current = el.scrollHeight - el.clientHeight - el.scrollTop <= FOLLOW_BOTTOM_THRESHOLD_PX;
};
const decide = useCallback(
async (rowId: string, decision: StructuredApprovalDecision): Promise<void> => {
const nonce = state.pending_approval?.nonce;
// Already resolved elsewhere between the click and here. A stale nonce
// is a no-op, not an error (docs/STRUCTURED-VIEW.md: any nonce answers
// 204), and the row's buttons are already gone — resolve quietly.
if (!nonce || rowId !== pendingRowId) return;
try {
await resolveStructuredApproval(sessionId, nonce, decision);
} catch (e: unknown) {
// Propagate — so the row re-enables its buttons — only while this is
// still the pending approval. If it moved on in flight, reviving the
// buttons would revive a decision that no longer applies.
setError(String(e));
throw e;
}
},
[sessionId, state.pending_approval?.nonce, pendingRowId],
);
const dispatchPrompt = useCallback(
(text: string, files: File[]) => {
const generation = ++submission.current;
const submissionSocket = socketGeneration;
setAwaitingBegin(generation);
optimisticStart.current = Date.now();
accepted.current = null;
promptPending.current = true;
setUploading(files.length > 0);
setDraft("");
setError(null);
const send = async () => {
const references: string[] = [];
for (const file of files) {
const { path } = await uploadStructuredFile(sessionId, file);
if (file.type.startsWith("image/")) imageUrls.current.set(path, URL.createObjectURL(file));
references.push(`Attached file ${JSON.stringify(file.name)}: ${JSON.stringify(path)}`);
}
await sendStructuredPrompt(sessionId, [text, ...references].filter(Boolean).join("\n\n"));
};
void send().then(
() => {
promptPending.current = false;
if (generation !== submission.current) return;
setUploading(false);
setAttachments((current) => (current === files ? [] : current));
accepted.current = { generation, socketGeneration };
// A replacement socket may have captured idle before the POST
// claimed the prompt. Reconnect so its snapshot cannot predate the
// 204.
if (socketGeneration > submissionSocket) session.reconnect();
},
(e: unknown) => {
promptPending.current = false;
if (generation !== submission.current) return;
setAwaitingBegin((current) => (current === generation ? null : current));
optimisticStart.current = null;
setUploading(false);
// The draft and its attachments are the operator's work — a failed
// send hands both back rather than eating them.
setAttachments((current) => (current.length === 0 ? files : current));
setDraft((current) => (current === "" ? text : current));
setError(String(e));
},
);
},
[sessionId, socketGeneration, session],
);
// A queued message goes out as soon as the projected turn is idle — the
// daemon answers a second prompt `409 turn_in_flight`, so the queue is
// client-local (docs/STRUCTURED-VIEW.md).
useEffect(() => {
if (active || promptPending.current || queue.length === 0) return;
const [next, ...rest] = queue;
setQueue(rest);
dispatchPrompt(next.text, next.attachments);
}, [active, queue, dispatchPrompt]);
const submit = (text: string, files: File[]) => {
if (active || queue.length > 0) {
setQueue((current) => [...current, { text, attachments: files }]);
setAttachments([]);
setDraft("");
setError(null);
return;
}
dispatchPrompt(text, files);
};
const cancel = () => {
setError(null);
cancelStructuredTurn(sessionId).catch((e: unknown) => setError(String(e)));
};
const changeConfig = (option: AcpSessionConfigOption, value: string | boolean) => {
setConfigBusy(true);
setError(null);
setStructuredConfig(sessionId, { config_id: option.id, value })
.catch((e: unknown) =>
setError(e instanceof Error ? e.message : "Could not change the session setting"),
)
.finally(() => setConfigBusy(false));
};
const elapsedMs = () => {
const authoritative = state.turn.started_at ? Date.parse(state.turn.started_at) : Number.NaN;
const startedAt = Number.isFinite(authoritative) ? authoritative : optimisticStart.current;
return startedAt === null ? 0 : Math.max(0, now - startedAt);
};
const activityLabel = () => {
const tools = state.turn.active_tool_calls.length;
const label = awaitingBegin !== null
? "Sending"
: state.turn.thinking
? "Thinking"
: tools > 0
? `Running ${tools} ${tools === 1 ? "tool" : "tools"}`
: "Responding";
const parts = [label, formatElapsed(elapsedMs())];
if (typeof state.usage?.used === "number") {
const usage = formatContextTokens(state.usage.used);
parts.push(
typeof state.usage.size === "number" ? `${usage} / ${formatContextTokens(state.usage.size)}` : usage,
);
}
return parts.join(" · ");
};
const setRowExpanded = (id: string, expanded: boolean) =>
setExpandedRows((current) => {
const next = new Set(current);
if (expanded) next.add(id);
else next.delete(id);
return next;
});
const setGroupExpanded = (id: string, expanded: boolean) =>
setExpandedGroups((current) => {
const next = new Set(current);
if (expanded) next.add(id);
else next.delete(id);
return next;
});
const renderRow = (row: Row) => (
<TranscriptRow
key={row.id}
row={row}
pendingApproval={row.id === pendingRowId}
onDecide={(decision) => decide(row.id, decision)}
expanded={expandedRows.has(row.id)}
onExpandedChange={(expanded) => setRowExpanded(row.id, expanded)}
imageUrls={imageUrls.current}
/>
);
return (
<div className={className ? `structured-pane ${className}` : "structured-pane"}>
<div className="structured-body">
<div className="structured-main">
<div className="structured-scroll" ref={scrollRef} onScroll={onScroll}>
<div className="structured-rows">
{items.map((item) =>
item.type === "row" ? (
renderRow(item.row)
) : (
<ToolGroup
key={item.id}
id={item.id}
toolCount={item.group.toolCount}
expanded={expandedGroups.has(item.id)}
onExpandedChange={(expanded) => setGroupExpanded(item.id, expanded)}
>
{item.group.rows.map(renderRow)}
</ToolGroup>
),
)}
{queue.map((queued, index) => (
<div key={`queued-${index}`} className="st-row st-row-queued">
<div className="st-body">
<div className="st-user-text">{queued.text}</div>
</div>
</div>
))}
</div>
<div className="structured-activity" role="status" aria-live="polite" hidden={!active}>
<span className="structured-activity-pulse" aria-hidden="true" />
<span className="structured-activity-text">{active ? activityLabel() : ""}</span>
</div>
</div>
{error !== null && <div className="structured-error">{error}</div>}
<div ref={composerRef}>
<Composer
attachments={attachments}
onAttachmentsChange={setAttachments}
commands={commands}
configOptions={configOptions}
turnActive={active}
uploading={uploading}
configBusy={configBusy}
draft={draft}
onDraftChange={setDraft}
onSubmit={submit}
onCancel={cancel}
onConfigChange={changeConfig}
onError={setError}
menuId={`structured-command-menu-${sessionId}`}
/>
</div>
</div>
</div>
</div>
);
});
@@ -0,0 +1,43 @@
// A turn's collapsed activity block: two or more tool rows after the same
// `User` row, plus the narration absorbed between them. The header counts tool
// rows only (docs/STRUCTURED-VIEW.md); opening it reveals tools and text in
// chronological order.
import type { ReactNode } from "react";
export interface ToolGroupProps {
id: string;
toolCount: number;
expanded: boolean;
onExpandedChange(expanded: boolean): void;
children: ReactNode;
}
export function ToolGroup({ id, toolCount, expanded, onExpandedChange, children }: ToolGroupProps) {
return (
<div className="st-tool-group" data-tool-group-id={id}>
<button
type="button"
className="st-tool-group-head"
aria-expanded={expanded}
onClick={() => onExpandedChange(!expanded)}
>
<span className="st-tool-group-glyph" aria-hidden="true">
›
</span>
<span className="st-tool-group-title">{`${toolCount} tool calls`}</span>
</button>
<div
className={
expanded ? "st-tool-group-body st-disclosure is-expanded" : "st-tool-group-body st-disclosure"
}
aria-hidden={!expanded}
inert={!expanded}
>
<div className="st-disclosure-clip">
<div className="st-disclosure-inner">{children}</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,35 @@
// The glyph column of a tool row's head. Recognition is narrow on purpose: an
// unrecognised or absent `ToolKind` gets the generic bullet, never a blank
// column and never a mark that stands for the wrong kind of work.
import type { AcpToolKind } from "../../types.js";
/** Glyph shown in a tool row's head, by `ToolKind`. */
const KIND_GLYPH: Record<string, string> = {
read: "≡",
edit: "±",
delete: "✕",
move: "→",
search: "⌕",
execute: "$",
think: "…",
fetch: "⇣",
switch_mode: "⇄",
};
export function toolGlyph(kind: string | undefined): string {
return (kind && KIND_GLYPH[kind]) || "•";
}
export interface ToolIconProps {
kind: AcpToolKind | string | undefined;
className?: string;
}
export function ToolIcon({ kind, className = "st-tool-glyph" }: ToolIconProps) {
return (
<span className={className} aria-hidden="true">
{toolGlyph(kind)}
</span>
);
}
@@ -0,0 +1,387 @@
// One transcript row, by kind. docs/STRUCTURED-VIEW.md fixes the eight kinds
// and leaves every rendering decision here: markdown for AgentText, an inline
// italic stream for AgentThought, a tool card for ToolCall/ToolDiff, a diff
// view for ToolDiff's content, an approval card for Approval, and an opaque
// collapsed line for a Notice nobody has taught the client about. `Plan` is
// deliberately not drawn — the web client has no plan rail.
import { useEffect, useRef, type ReactNode } from "react";
import { renderInlineMarkdown, renderMarkdown, formatElapsed } from "../../structuredRender.js";
import type {
AcpContentBlock,
AcpPermissionOption,
AcpToolCallContent,
AcpToolCallLike,
StructuredApprovalDecision,
TranscriptRow as Row,
TurnMeta,
} from "../../types.js";
import { ApprovalView } from "./ApprovalView.js";
import { Copyable } from "./Copyable.js";
import { DiffView } from "./DiffView.js";
import {
contentBlockText,
isStateOnlyNotice,
splitToolTitle,
statusMeta,
toolDiffMeta,
turnMeta,
} from "./model.js";
import { safeMediaSource, toolPayload } from "./toolPayload.js";
import { ToolIcon } from "./ToolIcon.js";
// ---------------------------------------------------------------------------
// content rows: user / agent / thought
// ---------------------------------------------------------------------------
/** Model-authored markdown, sanitized by structuredRender.ts before it enters
* the DOM. Tables get their own scroller and links open out of the app. */
function Prose({ html }: { html: string }) {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const container = ref.current;
if (!container) return;
for (const table of Array.from(container.querySelectorAll("table"))) {
if (table.parentElement?.classList.contains("st-markdown-table-wrap")) continue;
const scroller = document.createElement("div");
scroller.className = "st-markdown-table-wrap";
table.replaceWith(scroller);
scroller.appendChild(table);
}
for (const link of container.querySelectorAll("a")) {
link.target = "_blank";
link.rel = "noopener noreferrer";
}
}, [html]);
return <div ref={ref} className="st-body" dangerouslySetInnerHTML={{ __html: html }} />;
}
export interface ContentRowProps {
row: Row;
variant: "user" | "agent" | "thought";
/** Object URLs for images this prompt uploaded, keyed by the server path the
* row's text names. */
imageUrls?: Map<string, string>;
}
/** `Attached file "name": "path"` references the composer appends on send. */
const ATTACHED_FILE_RE = /Attached file "(?:[^"\\]|\\.)*": "((?:[^"\\]|\\.)*)"/g;
function attachedImages(text: string, imageUrls: Map<string, string>): string[] {
const urls: string[] = [];
ATTACHED_FILE_RE.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = ATTACHED_FILE_RE.exec(text)) !== null) {
let serverPath: string;
try {
serverPath = JSON.parse(`"${match[1]}"`) as string;
} catch {
continue;
}
const url = imageUrls.get(serverPath);
if (url) urls.push(url);
}
return urls;
}
export function ContentRow({ row, variant, imageUrls }: ContentRowProps) {
const data = row.data as { content?: AcpContentBlock } | undefined;
const text = contentBlockText(data?.content);
const displayText = variant === "thought" ? text.trim() : text;
const label = variant === "user" ? "message" : variant === "agent" ? "response" : "thought";
let body: ReactNode;
if (variant === "user") {
const images = imageUrls && imageUrls.size > 0 ? attachedImages(displayText, imageUrls) : [];
// With previews in hand the path references are noise — the image is the
// attachment. Without them the text is all the operator has, so it stays.
const shown =
images.length > 0
? displayText.replace(/\n\nAttached file "[^"]*": "[^"]*"/g, "").trim()
: displayText;
body = (
<div className="st-body">
{shown && <div className="st-user-text">{shown}</div>}
{images.map((src) => (
<img key={src} className="st-user-image" src={src} alt="Uploaded image" loading="lazy" />
))}
</div>
);
} else if (variant === "agent") {
body = <Prose html={renderMarkdown(displayText)} />;
} else {
body = <div className="st-body" dangerouslySetInnerHTML={{ __html: renderInlineMarkdown(displayText) }} />;
}
return (
<Copyable className={`st-row st-row-${variant}`} text={displayText} label={label} data-row-id={row.id}>
{body}
</Copyable>
);
}
// ---------------------------------------------------------------------------
// tool rows
// ---------------------------------------------------------------------------
function ToolDetailBlock({ label, text }: { label: string; text: string }) {
return (
<div className="st-tool-detail">
<div className="st-tool-detail-label">{label}</div>
<Copyable as="pre" text={text} label={label.toLowerCase()}>
{text}
</Copyable>
</div>
);
}
function ContentBlockView({ block }: { block: AcpContentBlock }) {
if (block.type === "text") {
if (!block.text) return null;
return (
<Copyable className="st-tool-text" text={block.text} label="output">
{block.text}
</Copyable>
);
}
if (block.type === "image") {
const source = safeMediaSource(block.mimeType, block.data, "image");
if (source) return <img className="st-tool-image" src={source} alt="Tool output" loading="lazy" />;
return <ToolDetailBlock label="Image" text={block.uri ?? block.mimeType ?? "Unavailable"} />;
}
if (block.type === "audio") {
const source = safeMediaSource(block.mimeType, block.data, "audio");
if (source) return <audio className="st-tool-audio" controls src={source} />;
return <ToolDetailBlock label="Audio" text={block.mimeType ?? "Unavailable"} />;
}
if (block.type === "resource_link") {
return (
<div className="st-tool-resource">
<span className="st-tool-resource-name">{block.title ?? block.name ?? "Resource"}</span>
{block.uri && (
<Copyable as="code" className="st-tool-resource-uri" text={block.uri} label="resource URI">
{block.uri}
</Copyable>
)}
{block.description && <span className="st-tool-resource-description">{block.description}</span>}
</div>
);
}
const resource = block.resource;
if (typeof resource?.text === "string") {
return (
<ToolDetailBlock label={resource.uri ? `Resource · ${resource.uri}` : "Resource"} text={resource.text} />
);
}
const printed = resource === undefined ? "" : JSON.stringify(resource, null, 2);
return printed ? <ToolDetailBlock label="Resource" text={printed} /> : null;
}
function ToolBody({ tool, content }: { tool: AcpToolCallLike; content: AcpToolCallContent[] }) {
const payload = toolPayload(tool, content);
const hasRaw = payload.raw.input !== null || payload.raw.output !== null;
return (
<>
{content.map((block, index) => {
if (block.type === "content") return <ContentBlockView key={index} block={block.content} />;
if (block.type === "diff") return <DiffView key={index} diff={block} />;
return (
<div key={index} className="st-tool-terminal">
{block.terminalId ? `Terminal · ${block.terminalId}` : "Embedded terminal"}
</div>
);
})}
{payload.details.map((detail, index) => (
<ToolDetailBlock key={`${detail.label}-${index}`} label={detail.label} text={detail.text} />
))}
{payload.exitCode !== null && (
<div className="st-tool-fact">
<span className="st-tool-fact-label">Exit code</span>
<code className={payload.exitCode === 0 ? "st-tool-exit-ok" : "st-tool-exit-failed"}>
{payload.exitCode}
</code>
</div>
)}
{hasRaw && (
<details className="st-tool-raw">
<summary>Raw JSON</summary>
<div className="st-tool-raw-body">
{payload.raw.input !== null && <ToolDetailBlock label="Input" text={payload.raw.input} />}
{payload.raw.output !== null && <ToolDetailBlock label="Output" text={payload.raw.output} />}
</div>
</details>
)}
</>
);
}
/** True when a tool call has anything at all to disclose — a head with nothing
* behind it stays a plain line rather than an empty disclosure. */
function hasToolBody(tool: AcpToolCallLike, content: AcpToolCallContent[]): boolean {
if (content.length > 0) return true;
const payload = toolPayload(tool, content);
return payload.details.length > 0 || payload.exitCode !== null || payload.raw.input !== null || payload.raw.output !== null;
}
export interface ToolRowProps {
row: Row;
expanded: boolean;
onExpandedChange(expanded: boolean): void;
}
export function ToolRow({ row, expanded, onExpandedChange }: ToolRowProps) {
const tool = (row.data ?? {}) as AcpToolCallLike;
const content = tool.content ?? [];
const hasDiff = content.some((c) => c.type === "diff");
const { label, arg } = splitToolTitle(tool.title, tool.name, tool.kind);
const meta = statusMeta(tool.status, hasDiff);
const metaParts: string[] = [];
if (hasDiff) metaParts.push(toolDiffMeta(content));
if (meta) metaParts.push(meta.text);
const disclosable = hasToolBody(tool, content);
const open = expanded && disclosable;
return (
<div className="st-row st-row-tool" data-row-id={row.id}>
<div className="st-tool">
<button
type="button"
className="st-tool-head"
aria-expanded={open}
onClick={() => disclosable && onExpandedChange(!open)}
>
<ToolIcon kind={tool.kind} />
<span className="st-tool-name">{label}</span>
<span className="st-tool-arg" title={arg}>
{arg}
</span>
{metaParts.length > 0 && (
<span className={meta ? `st-tool-meta st-tool-meta-${meta.kind}` : "st-tool-meta"}>
{metaParts.join(" · ")}
</span>
)}
</button>
{disclosable && (
<div
className={open ? "st-tool-out st-disclosure is-expanded" : "st-tool-out st-disclosure"}
aria-hidden={!open}
inert={!open}
>
<div className="st-disclosure-clip">
<div className="st-disclosure-inner">
{/* Built only once opened: a long transcript would otherwise
render every tool's whole output up front. */}
{open && <ToolBody tool={tool} content={content} />}
</div>
</div>
</div>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// notices and turn footers
// ---------------------------------------------------------------------------
function compactNumber(value: number): string {
if (Math.abs(value) < 1_000) return String(value);
if (Math.abs(value) < 1_000_000) return `${(value / 1_000).toFixed(value % 1_000 === 0 ? 0 : 1)}k`;
return `${(value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 1)}m`;
}
export function TurnMetaRow({ meta }: { meta: TurnMeta }) {
const parts = [formatElapsed(meta.elapsedMs)];
if (typeof meta.contextDelta === "number") {
const sign = meta.contextDelta > 0 ? "+" : "";
parts.push(`${sign}${compactNumber(meta.contextDelta)} context tokens`);
} else if (typeof meta.contextTokens === "number") {
parts.push(`${compactNumber(meta.contextTokens)} context`);
}
if (meta.model) parts.push(meta.model);
if (meta.outcome !== "completed") parts.push(meta.outcome);
const text = parts.join(" · ");
return (
<Copyable
className={`st-turn-meta st-turn-meta-${meta.outcome}`}
text={text}
label="turn details"
aria-label={`Turn ${meta.outcome}`}
>
<span className="st-turn-meta-dot" aria-hidden="true" />
<span>{text}</span>
</Copyable>
);
}
/** The floor presentation docs/STRUCTURED-VIEW.md allows for a `Notice` the
* client has nothing better to do with: an opaque, collapsed line. */
export function NoticeRow({ row }: { row: Row }) {
const text = `[${row.kind}]`;
return (
<Copyable className="st-row st-row-notice" text={text} label="notice" data-row-id={row.id}>
<div className="st-body">{text}</div>
</Copyable>
);
}
// ---------------------------------------------------------------------------
// dispatch
// ---------------------------------------------------------------------------
export interface TranscriptRowProps {
row: Row;
/** This row is the one live approval (docs/STRUCTURED-VIEW.md's nonce
* rules). Every other approval row is history. */
pendingApproval?: boolean;
onDecide?: (decision: StructuredApprovalDecision) => Promise<void>;
expanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
imageUrls?: Map<string, string>;
}
/** One transcript row, or `null` for the rows the web client omits (`plan`,
* and the state-only notices it consumes for `/` autocomplete). */
export function TranscriptRow({
row,
pendingApproval = false,
onDecide,
expanded = false,
onExpandedChange,
imageUrls,
}: TranscriptRowProps) {
switch (row.kind) {
case "user":
return <ContentRow row={row} variant="user" imageUrls={imageUrls} />;
case "agent_text":
return <ContentRow row={row} variant="agent" />;
case "agent_thought":
return <ContentRow row={row} variant="thought" />;
case "tool_call":
case "tool_diff":
return (
<ToolRow row={row} expanded={expanded} onExpandedChange={(next) => onExpandedChange?.(next)} />
);
case "approval": {
const request = (row.data ?? {}) as { toolCall?: AcpToolCallLike; options?: AcpPermissionOption[] };
return (
<ApprovalView
request={request}
pending={pendingApproval}
onDecide={onDecide ?? (() => Promise.resolve())}
/>
);
}
case "plan":
return null;
case "notice": {
if (isStateOnlyNotice(row)) return null;
const meta = turnMeta(row);
return meta ? <TurnMetaRow meta={meta} /> : <NoticeRow row={row} />;
}
default:
return <NoticeRow row={row} />;
}
}
+13
View File
@@ -0,0 +1,13 @@
export { StructuredPane, type StructuredPaneHandle, type StructuredPaneProps } from "./StructuredPane.js";
export { TranscriptRow, type TranscriptRowProps } from "./TranscriptRow.js";
export { DiffView, type DiffViewProps } from "./DiffView.js";
export { ApprovalView, type ApprovalViewProps } from "./ApprovalView.js";
export { ToolGroup, type ToolGroupProps } from "./ToolGroup.js";
export { ToolIcon, toolGlyph, type ToolIconProps } from "./ToolIcon.js";
export { Composer, type ComposerProps } from "./Composer.js";
export {
useStructuredSession,
type StructuredSession,
type StructuredSocketFactory,
type StructuredStatus,
} from "./useStructuredSession.js";
+252
View File
@@ -0,0 +1,252 @@
import { describe, expect, it } from "vitest";
import type { ReducedState, TranscriptRow } from "../../types.js";
import {
activityGroupsByTurn,
applyCommand,
commandsFromTranscript,
commandTokenAt,
contentBlockText,
filterCommands,
isStateOnlyNotice,
offeredDecisions,
orderConfigOptions,
pendingApprovalRowId,
splitToolTitle,
statusMeta,
toolDiffMeta,
toolKindLabel,
transcriptItems,
turnMeta,
} from "./model.js";
function row(id: string, kind: TranscriptRow["kind"], data: unknown = {}): TranscriptRow {
return { id, kind, data };
}
function tool(id: string): TranscriptRow {
return row(id, "tool_call", { title: `Read(${id})`, status: "completed" });
}
function text(id: string, kind: "user" | "agent_text" | "agent_thought", body: string): TranscriptRow {
return row(id, kind, { content: { type: "text", text: body } });
}
const idle: ReducedState = { turn: { responding: false, thinking: false, active_tool_calls: [] } };
describe("row taxonomy", () => {
it("splits an adapter's Name(argument) title, and falls back to the kind", () => {
expect(splitToolTitle("Read(src/main.ts)", null, "read")).toEqual({ label: "Read", arg: "src/main.ts" });
expect(splitToolTitle("something opaque", null, "search")).toEqual({
label: "Search",
arg: "something opaque",
});
expect(splitToolTitle(undefined, null, undefined)).toEqual({ label: "Tool", arg: "" });
});
it("labels an unknown tool kind rather than leaving it blank", () => {
expect(toolKindLabel("execute")).toBe("Execute");
expect(toolKindLabel("no_such_kind")).toBe("Tool");
expect(toolKindLabel(undefined)).toBe("Tool");
});
it("names non-text content blocks instead of dropping them", () => {
expect(contentBlockText({ type: "text", text: "hi" })).toBe("hi");
expect(contentBlockText({ type: "image" })).toBe("[image]");
expect(contentBlockText({ type: "resource_link", name: "spec.md" })).toBe("[resource: spec.md]");
expect(contentBlockText(undefined)).toBe("");
});
it("counts a tool call's diff lines", () => {
expect(toolDiffMeta([{ type: "diff", path: "a.ts", oldText: "one\ntwo\n", newText: "one\nTWO\nthree\n" }]))
.toBe("+2 −1");
expect(toolDiffMeta([])).toBe("+0 −0");
});
it("shows a status only while it is worth showing", () => {
expect(statusMeta("in_progress", false)).toEqual({ text: "running", kind: "running" });
expect(statusMeta("failed", true)).toEqual({ text: "failed", kind: "failed" });
expect(statusMeta("pending", true)).toBeNull(); // the diff counts already say it
expect(statusMeta("completed", false)).toBeNull();
});
});
describe("notices", () => {
it("reads a maestroTurnMeta footer, and only a well-formed one", () => {
const meta = { elapsedMs: 4200, outcome: "completed", model: "sonnet" };
expect(turnMeta(row("n1", "notice", { maestroTurnMeta: meta }))).toEqual(meta);
expect(turnMeta(row("n2", "notice", { maestroTurnMeta: { outcome: "completed" } }))).toBeNull();
expect(turnMeta(row("n3", "notice", { sessionUpdate: "session_info_update" }))).toBeNull();
});
it("marks the state-only notices the client consumes rather than shows", () => {
expect(isStateOnlyNotice(row("n1", "notice", { sessionUpdate: "available_commands_update" }))).toBe(true);
expect(isStateOnlyNotice(row("n2", "notice", { sessionUpdate: "session_info_update" }))).toBe(true);
expect(isStateOnlyNotice(row("n3", "notice", { sessionUpdate: "something_else" }))).toBe(false);
});
});
describe("approvals", () => {
it("offers only decisions the request actually backs", () => {
expect(
offeredDecisions([
{ optionId: "a", name: "Allow once", kind: "allow_once" },
{ optionId: "r", name: "Reject", kind: "reject_once" },
]),
).toEqual(["allow_once", "deny"]);
expect(offeredDecisions([{ optionId: "a", name: "Always", kind: "allow_always" }])).toEqual([
"allow_for_session",
]);
expect(offeredDecisions([])).toEqual([]);
});
it("points a live pending approval at the newest approval row", () => {
const rows = [row("approval:1", "approval"), tool("tool:1"), row("approval:2", "approval")];
const pending: ReducedState = {
...idle,
pending_approval: { nonce: "n", request: {} as never },
};
expect(pendingApprovalRowId(rows, pending)).toBe("approval:2");
expect(pendingApprovalRowId(rows, idle)).toBeNull();
});
});
describe("activity grouping", () => {
it("groups a turn's tools once there are two of them", () => {
const rows = [text("user:1", "user", "go"), tool("tool:1"), tool("tool:2")];
const groups = activityGroupsByTurn(rows, null, false);
expect([...groups.keys()]).toEqual(["turn:user:1"]);
expect(groups.get("turn:user:1")).toMatchObject({ toolCount: 2 });
});
it("leaves a single tool call standalone", () => {
const rows = [text("user:1", "user", "go"), tool("tool:1")];
expect(activityGroupsByTurn(rows, null, false).size).toBe(0);
});
it("absorbs intermediate narration but keeps the final answer outside", () => {
const rows = [
text("user:1", "user", "go"),
tool("tool:1"),
text("agent:1", "agent_text", "working"),
tool("tool:2"),
text("agent:2", "agent_text", "done"),
];
const group = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "agent:1", "tool:2"]);
expect(group.toolCount).toBe(2);
});
it("keeps the newest thought outside while the turn is still live", () => {
const rows = [
text("user:1", "user", "go"),
tool("tool:1"),
tool("tool:2"),
text("thought:1", "agent_thought", "hmm"),
text("thought:2", "agent_thought", "aha"),
];
const live = activityGroupsByTurn(rows, null, true).get("turn:user:1")!;
expect(live.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2", "thought:1"]);
const ended = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
expect(ended.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2", "thought:1", "thought:2"]);
});
it("absorbs a resolved approval, never the pending one", () => {
const rows = [
text("user:1", "user", "go"),
tool("tool:1"),
row("approval:1", "approval"),
tool("tool:2"),
row("approval:2", "approval"),
tool("tool:3"),
];
const group = activityGroupsByTurn(rows, "approval:2", false).get("turn:user:1")!;
expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "approval:1", "tool:2", "tool:3"]);
});
});
describe("transcript render list", () => {
it("renders a group once, at its first member's position", () => {
const rows = [text("user:1", "user", "go"), tool("tool:1"), tool("tool:2"), text("agent:1", "agent_text", "done")];
const items = transcriptItems(rows, null, false);
expect(items.map((item) => (item.type === "row" ? item.row.id : item.id))).toEqual([
"user:1",
"turn:user:1",
"agent:1",
]);
});
it("omits plan rows, state-only notices and standalone resolved approvals", () => {
const rows = [
text("user:1", "user", "go"),
row("plan", "plan", { entries: [] }),
row("notice:1", "notice", { sessionUpdate: "available_commands_update", availableCommands: [] }),
row("approval:1", "approval"),
text("agent:1", "agent_text", "done"),
];
const items = transcriptItems(rows, null, false);
expect(items.map((item) => (item.type === "row" ? item.row.id : item.id))).toEqual(["user:1", "agent:1"]);
});
it("keeps a turn footer only while it is the final item", () => {
const footer = row("turn_meta:1", "notice", { maestroTurnMeta: { elapsedMs: 10, outcome: "completed" } });
expect(transcriptItems([text("user:1", "user", "go"), footer], null, false)).toHaveLength(2);
const later = [text("user:1", "user", "go"), footer, text("user:2", "user", "again")];
expect(transcriptItems(later, null, false).map((i) => (i.type === "row" ? i.row.id : i.id))).toEqual([
"user:1",
"user:2",
]);
});
});
describe("composer commands", () => {
const notice = row("notice:9", "notice", {
sessionUpdate: "available_commands_update",
availableCommands: [
{ name: "compact", description: "Compact the\n conversation" },
{ name: "cost", description: "Show cost", input: { hint: "[period]" } },
{ name: 42 },
],
});
it("takes the newest available_commands_update, skipping malformed entries", () => {
const stale = row("notice:1", "notice", {
sessionUpdate: "available_commands_update",
availableCommands: [{ name: "old", description: "gone" }],
});
expect(commandsFromTranscript([stale, notice]).map((c) => c.name)).toEqual(["compact", "cost"]);
expect(commandsFromTranscript([])).toEqual([]);
});
it("only matches a /token under the caret", () => {
expect(commandTokenAt("/comp", 5, 5)).toEqual({ start: 0, end: 5, query: "comp" });
expect(commandTokenAt("hello /co there", 9, 9)).toEqual({ start: 6, end: 9, query: "co" });
expect(commandTokenAt("no slash", 3, 3)).toBeNull();
expect(commandTokenAt("/comp", 2, 5)).toBeNull(); // a selection is not a caret
});
it("ranks prefix matches ahead of substring matches", () => {
const commands = commandsFromTranscript([notice]);
expect(filterCommands(commands, "co").map((c) => c.name)).toEqual(["compact", "cost"]);
expect(filterCommands(commands, "act").map((c) => c.name)).toEqual(["compact"]);
});
it("replaces only the token under the caret", () => {
const token = commandTokenAt("say /co now", 7, 7)!;
expect(applyCommand("say /co now", token, "compact")).toEqual({ value: "say /compact now", cursor: 13 });
const trailing = commandTokenAt("/co", 3, 3)!;
expect(applyCommand("/co", trailing, "compact")).toEqual({ value: "/compact ", cursor: 9 });
});
});
describe("session config", () => {
it("orders the menu by category, unknown categories last", () => {
const options = [
{ id: "d", category: null },
{ id: "b", category: "thought_level" },
{ id: "a", category: "model" },
{ id: "c", category: "mode" },
];
expect(orderConfigOptions(options).map((o) => o.id)).toEqual(["a", "b", "c", "d"]);
});
});
+369
View File
@@ -0,0 +1,369 @@
// Pure transcript logic for the structured pane (#246) — the decisions
// docs/STRUCTURED-VIEW.md leaves to a client, kept out of the components so
// they can be tested without a DOM or a socket. Ported from structured.ts's
// private helpers and the parts of structuredRender.ts that were data, not
// markup.
import type {
AcpContentBlock,
AcpPermissionOption,
AcpToolCallContent,
ReducedState,
StructuredApprovalDecision,
TranscriptRow,
TranscriptRowKind,
TurnMeta,
} from "../../types.js";
import { computeDiffLines, diffStats } from "../../structuredDiff.js";
/** Human label behind a `ToolKind`. An unrecognised/absent kind is "Tool" —
* never a blank label. */
const KIND_LABEL: Record<string, string> = {
read: "Read",
edit: "Edit",
delete: "Delete",
move: "Move",
search: "Search",
execute: "Execute",
think: "Think",
fetch: "Fetch",
switch_mode: "Switch mode",
};
export function toolKindLabel(kind: string | undefined): string {
return (kind && KIND_LABEL[kind]) || "Tool";
}
/** Split a title into `{label, arg}` when it follows the adapter's own
* `Name(argument)` convention; otherwise the whole title is the arg and the
* tool's `kind` supplies a generic label. Never guesses at a shape the data
* doesn't actually show. */
export function splitToolTitle(
title: string | undefined,
name: string | null | undefined,
kind: string | undefined,
): { label: string; arg: string } {
const label = name ?? toolKindLabel(kind);
if (!title) return { label, arg: "" };
const m = /^(\S+)\(([\s\S]*)\)$/.exec(title);
if (m) return { label: name ?? m[1], arg: m[2] };
return { label, arg: title };
}
/** Content-block text, or a labelled placeholder for anything that isn't
* text — never dropped silently, never fabricated. */
export function contentBlockText(block: AcpContentBlock | undefined): string {
if (!block) return "";
switch (block.type) {
case "text":
return block.text;
case "image":
return "[image]";
case "audio":
return "[audio]";
case "resource_link":
return `[resource: ${block.name ?? block.uri ?? "linked"}]`;
case "resource":
return "[resource]";
default:
return "";
}
}
/** `+added −removed` across every diff block in a tool call's content — real
* derived data, never a fabricated duration. */
export function toolDiffMeta(content: AcpToolCallContent[]): string {
let added = 0;
let removed = 0;
for (const block of content) {
if (block.type !== "diff") continue;
const stats = diffStats(computeDiffLines(block.oldText, block.newText));
added += stats.added;
removed += stats.removed;
}
return `+${added} −${removed}`;
}
export function statusMeta(
status: string | undefined,
hasDiff: boolean,
): { text: string; kind: string } | null {
if (status === "in_progress") return { text: "running", kind: "running" };
if (status === "failed") return { text: "failed", kind: "failed" };
if (status === "pending" && !hasDiff) return { text: "pending", kind: "pending" };
return null;
}
/** The `maestroTurnMeta` footer a `Notice` row carries when it is a completed,
* cancelled or failed turn's durable footer — `null` for every other notice. */
export function turnMeta(row: TranscriptRow): TurnMeta | null {
if (row.kind !== "notice" || !row.data || typeof row.data !== "object") return null;
const value = (row.data as { maestroTurnMeta?: unknown }).maestroTurnMeta;
if (!value || typeof value !== "object") return null;
const meta = value as Partial<TurnMeta>;
if (typeof meta.elapsedMs !== "number" || typeof meta.outcome !== "string") return null;
return meta as TurnMeta;
}
/** Notices the web client consumes as state and omits from the visible
* transcript (docs/STRUCTURED-VIEW.md, "A client decides"). */
export function isStateOnlyNotice(row: TranscriptRow): boolean {
if (row.kind !== "notice" || !row.data || typeof row.data !== "object") return false;
const update = (row.data as { sessionUpdate?: string }).sessionUpdate;
return update === "available_commands_update" || update === "session_info_update";
}
/** Which decisions a permission request actually backs, by the
* `PermissionOptionKind` values it offers (docs/STRUCTURED-VIEW.md's
* "Decision mapping"). A decision with no matching option resolves
* `Cancelled` server-side regardless of what the client sent — offering its
* button would let the operator "allow" something that silently no-ops. */
export function offeredDecisions(options: AcpPermissionOption[]): StructuredApprovalDecision[] {
const kinds = new Set(options.map((o) => o.kind));
const decisions: StructuredApprovalDecision[] = [];
if (kinds.has("allow_once")) decisions.push("allow_once");
if (kinds.has("allow_always")) decisions.push("allow_for_session");
if (kinds.has("reject_once") || kinds.has("reject_always")) decisions.push("deny");
return decisions;
}
/** The row id a live `pending_approval` names: the newest `Approval` row.
* Only one approval is ever live (docs/STRUCTURED-VIEW.md, "One live
* approval at a time"), so the last one is the pending one. */
export function pendingApprovalRowId(rows: TranscriptRow[], state: ReducedState): string | null {
return state.pending_approval ? lastRowIdOfKind(rows, "approval") : null;
}
export function lastRowIdOfKind(rows: TranscriptRow[], kind: TranscriptRowKind): string | null {
for (let i = rows.length - 1; i >= 0; i--) if (rows[i].kind === kind) return rows[i].id;
return null;
}
export function turnIsActive(state: ReducedState): boolean {
return state.turn.responding || state.turn.thinking;
}
export interface ActivityGroup {
/** Header label count — tool rows only, never absorbed text. */
toolCount: number;
/** Tools plus absorbed intermediate text/approvals, in transcript order. */
rows: TranscriptRow[];
}
/**
* Group tool rows (and intermediate agent text / resolved approvals between
* them) by turn. An agent_text/agent_thought row is "intermediate" when a tool
* row follows it in the same turn — it gets absorbed into the group so the
* collapsed block preserves chronological order. Resolved approval rows (not
* the currently pending one) are absorbed the same way. The *last* agent text
* with no subsequent tool stays standalone (the prominent final answer).
*
* Returns groups keyed by `turn:<userId>`.
*/
export function activityGroupsByTurn(
rows: TranscriptRow[],
pendingApprovalId: string | null,
turnActive: boolean,
): Map<string, ActivityGroup> {
// First: count tools per turn to know which turns qualify for grouping.
const toolCounts = new Map<string, number>();
let turnId = "before-first-message";
for (const row of rows) {
if (row.kind === "user") turnId = row.id;
if (row.kind !== "tool_call" && row.kind !== "tool_diff") continue;
const gid = `turn:${turnId}`;
toolCounts.set(gid, (toolCounts.get(gid) ?? 0) + 1);
}
// Second: build groups with interleaved text for turns with 2+ tools.
const groups = new Map<string, ActivityGroup>();
turnId = "before-first-message";
// Pending rows that might be intermediate (not yet confirmed by a following
// tool). If no tool follows, they stay standalone.
let pending: TranscriptRow[] = [];
let currentGid = `turn:${turnId}`;
for (const row of rows) {
if (row.kind === "user") {
turnId = row.id;
currentGid = `turn:${turnId}`;
pending = [];
continue;
}
const count = toolCounts.get(currentGid) ?? 0;
if (count < 2) continue;
const isIntermediateText = row.kind === "agent_text" || row.kind === "agent_thought";
const isResolvedApproval = row.kind === "approval" && row.id !== pendingApprovalId;
if (isIntermediateText || isResolvedApproval) {
pending.push(row);
continue;
}
if (row.kind === "tool_call" || row.kind === "tool_diff") {
const group = groups.get(currentGid) ?? { toolCount: count, rows: [] };
// Flush any pending rows — a tool follows, so they're intermediate.
group.rows.push(...pending);
pending = [];
group.rows.push(row);
groups.set(currentGid, group);
}
}
// A final agent text is the answer, so it stays outside. Thought rows are
// working commentary, though: when a turn already has a disclosure they
// belong with its activity even if the next tool call has not arrived yet.
// While the turn is still live the most recent one stays outside as visible
// narration (docs/STRUCTURED-VIEW.md), and is absorbed when the turn ends.
const lastGroup = groups.get(currentGid);
if (lastGroup && pending.length > 0 && pending.every((row) => row.kind === "agent_thought")) {
const absorb = turnActive ? pending.slice(0, -1) : pending;
lastGroup.rows.push(...absorb);
}
return groups;
}
/** One entry of the flattened render list: either a standalone row or a whole
* activity group. */
export type TranscriptItem =
| { type: "row"; row: TranscriptRow }
| { type: "group"; id: string; group: ActivityGroup };
/**
* The transcript in render order: rows, with each turn's tool activity folded
* into a single group item where one exists. Resolved approvals not absorbed
* into a group are dropped — the tool row itself already identifies the
* action. `plan` rows and state-only notices are dropped too (both clients
* omit `Plan` from the transcript). A turn footer only survives as the final
* item, so old footers stop reading like transcript content.
*/
export function transcriptItems(
rows: TranscriptRow[],
pendingApprovalId: string | null,
turnActive: boolean,
): TranscriptItem[] {
const groups = activityGroupsByTurn(rows, pendingApprovalId, turnActive);
const grouped = new Set<string>();
for (const group of groups.values()) for (const row of group.rows) grouped.add(row.id);
const items: TranscriptItem[] = [];
let turnId = "before-first-message";
const emitted = new Set<string>();
for (const row of rows) {
if (row.kind === "user") turnId = row.id;
const groupId = `turn:${turnId}`;
const group = groups.get(groupId);
if (group && grouped.has(row.id)) {
// The group renders once, at its first member's position.
if (!emitted.has(groupId)) {
emitted.add(groupId);
items.push({ type: "group", id: groupId, group });
}
continue;
}
if (row.kind === "plan") continue;
if (row.kind === "notice" && isStateOnlyNotice(row)) continue;
// A resolved approval outside a group adds nothing the tool row does not
// already say. A pending one always renders.
if (row.kind === "approval" && row.id !== pendingApprovalId) continue;
items.push({ type: "row", row });
}
return items.filter(
(item, index) =>
!(item.type === "row" && turnMeta(item.row) !== null) || index === items.length - 1,
);
}
// ---------------------------------------------------------------------------
// composer: `/` command autocomplete off `available_commands_update` notices
// ---------------------------------------------------------------------------
export interface AvailableCommand {
name: string;
description: string;
input?: { hint?: string };
}
/** The live command list is whatever the most recent
* `available_commands_update` notice carries (docs/STRUCTURED-VIEW.md). */
export function commandsFromTranscript(rows: TranscriptRow[]): AvailableCommand[] {
for (let i = rows.length - 1; i >= 0; i--) {
if (rows[i].kind !== "notice") continue;
const data = rows[i].data as { sessionUpdate?: string; availableCommands?: unknown[] } | undefined;
if (data?.sessionUpdate !== "available_commands_update") continue;
return (data.availableCommands ?? []).flatMap((value) => {
if (!value || typeof value !== "object") return [];
const command = value as Partial<AvailableCommand>;
if (typeof command.name !== "string" || typeof command.description !== "string") return [];
return [{ name: command.name, description: command.description, input: command.input }];
});
}
return [];
}
export interface CommandToken {
start: number;
end: number;
query: string;
}
/** The whitespace-delimited token under the caret, when it is a `/command`
* being typed. Completing one replaces only that token. */
export function commandTokenAt(
value: string,
selectionStart: number,
selectionEnd: number,
): CommandToken | null {
if (selectionStart !== selectionEnd) return null;
let start = selectionStart;
while (start > 0 && !/\s/.test(value[start - 1])) start--;
let end = selectionStart;
while (end < value.length && !/\s/.test(value[end])) end++;
const match = /^\/([^\s/]*)$/.exec(value.slice(start, selectionStart));
return match ? { start, end, query: match[1].toLocaleLowerCase() } : null;
}
/** Substring match, prefix matches first, then alphabetical. */
export function filterCommands(commands: AvailableCommand[], query: string): AvailableCommand[] {
return commands
.filter((command) => command.name.toLocaleLowerCase().includes(query))
.sort((a, b) => {
const aStarts = a.name.toLocaleLowerCase().startsWith(query);
const bStarts = b.name.toLocaleLowerCase().startsWith(query);
return aStarts === bStarts ? a.name.localeCompare(b.name) : aStarts ? -1 : 1;
});
}
/** Replace the token under the caret with `/name`, returning the new draft and
* where the caret lands. A trailing space is added only when the completion
* ends the draft. */
export function applyCommand(
value: string,
token: CommandToken,
name: string,
): { value: string; cursor: number } {
const before = value.slice(0, token.start);
const after = value.slice(token.end);
let replacement = `/${name}`;
let cursor = before.length + replacement.length;
if (after.length === 0) {
replacement += " ";
cursor++;
} else if (/^[ \t]/.test(after)) {
cursor++;
}
return { value: before + replacement + after, cursor };
}
// ---------------------------------------------------------------------------
// session config menu
// ---------------------------------------------------------------------------
/** Category order the config menu sorts by; anything else lands after. */
const CONFIG_CATEGORY_ORDER = ["model", "thought_level", "mode", "model_config"];
export function orderConfigOptions<T extends { category?: string | null }>(options: T[]): T[] {
return [...options].sort((a, b) => {
const rank = (category?: string | null) => CONFIG_CATEGORY_ORDER.indexOf(category ?? "");
const aRank = rank(a.category);
const bRank = rank(b.category);
return (aRank < 0 ? 99 : aRank) - (bRank < 0 ? 99 : bRank);
});
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,112 @@
// What a tool row shows below its head, derived from `rawInput`/`rawOutput`.
// The adapters emit a handful of recognisable payload shapes (a shell command,
// an MCP call, a search, a plain function call); anything else falls through to
// the raw JSON disclosure rather than being guessed at.
import type { AcpContentBlock, AcpToolCallContent, AcpToolCallLike } from "../../types.js";
/** One labelled `<pre>` block under a tool head. */
export interface ToolDetail {
label: string;
text: string;
}
export interface ToolPayload {
details: ToolDetail[];
/** A shell command's exit code, when the output carried one. */
exitCode: number | null;
raw: { input: string | null; output: string | null };
}
function printable(value: unknown): string | null {
if (value === undefined || value === null) return null;
if (typeof value === "string") return value;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function jsonPrintable(value: unknown): string | null {
if (value === undefined) return null;
try {
return JSON.stringify(value, null, 2) ?? null;
} catch {
return printable(value);
}
}
function record(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
/** Push a detail unless it is empty, or repeats text the content blocks above
* already show verbatim. */
function push(details: ToolDetail[], label: string, value: unknown, duplicates?: Set<string>): void {
const text = printable(value);
if (text !== null && text !== "" && !duplicates?.has(text)) details.push({ label, text });
}
/** The text of every `content` block, so a `rawOutput` echoing it is not shown
* a second time. */
function contentTexts(content: AcpToolCallContent[]): Set<string> {
return new Set(
content
.filter((block): block is Extract<AcpToolCallContent, { type: "content" }> => block.type === "content")
.filter((block) => block.content.type === "text")
.map((block) => (block.content as Extract<AcpContentBlock, { type: "text" }>).text),
);
}
export function toolPayload(tool: AcpToolCallLike, content: AcpToolCallContent[]): ToolPayload {
const details: ToolDetail[] = [];
let exitCode: number | null = null;
const input = record(tool.rawInput);
const texts = contentTexts(content);
if (input) {
if (typeof input.command === "string") {
details.push({ label: "Command", text: input.command });
push(details, "Working directory", input.cwd);
const result = record(tool.rawOutput);
if (result) {
push(details, "Output", result.formatted_output ?? result.stdout ?? result.output, texts);
if (typeof result.exit_code === "number") exitCode = result.exit_code;
}
} else if (typeof input.server === "string" && typeof input.tool === "string" && "arguments" in input) {
push(details, "Arguments", input.arguments);
const result = record(tool.rawOutput);
if (result) {
push(details, "Result", result.result, texts);
push(details, "Error", result.error);
}
} else if (typeof input.query === "string" && (tool.kind === "search" || input.type === "webSearch")) {
details.push({ label: "Query", text: input.query });
push(details, "Action", input.action);
} else if ("arguments" in input) {
push(details, "Arguments", input.arguments);
const result = record(tool.rawOutput);
if (result) push(details, "Result", result.output, texts);
}
}
return {
details,
exitCode,
raw: { input: jsonPrintable(tool.rawInput), output: jsonPrintable(tool.rawOutput) },
};
}
/** A `data:` URL for an inline image/audio block, or `null` when the mime type
* is absent or does not name that family — never a URL built from an
* unvalidated string. */
export function safeMediaSource(
mimeType: string | undefined,
data: string | undefined,
family: "image" | "audio",
): string | null {
if (!mimeType || !data || !new RegExp(`^${family}/[a-z0-9.+-]+$`, "i").test(mimeType)) return null;
return `data:${mimeType};base64,${data}`;
}
@@ -0,0 +1,216 @@
// The `WS /api/structured/:id` half of the pane (#246). Every connect — fresh
// or reconnect — resends the whole transcript rather than resuming a cursor
// (docs/STRUCTURED-VIEW.md), so a snapshot replaces state wholesale instead of
// merging into what came before.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { structuredWsUrl } from "../../routes.js";
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "../../reconnect.js";
import type {
ReducedState,
StructuredEvent,
TranscriptDelta,
TranscriptRow,
Uuid,
} from "../../types.js";
export type StructuredStatus = "connecting" | "open" | "reconnecting" | "closed" | "error";
/** Injectable so tests can drive the socket without a daemon — the same seam
* structured.ts used. */
export type StructuredSocketFactory = (sessionId: Uuid) => WebSocket;
const idleState = (): ReducedState => ({
turn: { responding: false, thinking: false, active_tool_calls: [] },
});
function applyDelta(rows: TranscriptRow[], delta: TranscriptDelta): TranscriptRow[] {
if (delta.type === "append") {
// A patch can arrive for a row this connection has never seen (its
// snapshot predates it); treating an unknown id as an append keeps the
// transcript complete rather than dropping the row.
const at = rows.findIndex((row) => row.id === delta.row.id);
return at === -1 ? [...rows, delta.row] : rows.map((row, i) => (i === at ? delta.row : row));
}
if (delta.type === "patch") {
const at = rows.findIndex((row) => row.id === delta.row.id);
return at === -1 ? [...rows, delta.row] : rows.map((row, i) => (i === at ? delta.row : row));
}
return rows.filter((row) => row.id !== delta.id);
}
export interface StructuredSession {
rows: TranscriptRow[];
state: ReducedState;
status: StructuredStatus;
/** A daemon-supplied close reason (code ≥ 4000), when there is one. */
detail: string | null;
/** The number of sockets this pane has opened, newest last. A submission
* that straddles a reconnect uses it to notice its acceptance may predate
* the current snapshot. */
socketGeneration: number;
/** Drop the socket and reconnect, so the next snapshot cannot predate a
* POST that has already been accepted. */
reconnect(): void;
}
export interface UseStructuredSessionOptions {
shouldReconnect?: (code: number) => boolean;
onClosed?: () => void;
socketFactory?: StructuredSocketFactory;
}
/** Application-defined close codes (daemon rejection) and the clean-close code
* — retrying either would only loop. */
function defaultShouldReconnect(code: number): boolean {
return code < 4000 && code !== 1000;
}
export function useStructuredSession(
sessionId: Uuid,
{
shouldReconnect = defaultShouldReconnect,
onClosed,
socketFactory = (id) => new WebSocket(structuredWsUrl(id)),
}: UseStructuredSessionOptions = {},
): StructuredSession {
const [rows, setRows] = useState<TranscriptRow[]>([]);
const [state, setState] = useState<ReducedState>(idleState);
const [status, setStatus] = useState<StructuredStatus>("connecting");
const [detail, setDetail] = useState<string | null>(null);
const [socketGeneration, setSocketGeneration] = useState(0);
// Read through refs so a changed callback never tears the socket down.
const shouldReconnectRef = useRef(shouldReconnect);
const onClosedRef = useRef(onClosed);
const factoryRef = useRef(socketFactory);
shouldReconnectRef.current = shouldReconnect;
onClosedRef.current = onClosed;
factoryRef.current = socketFactory;
const reconnectRef = useRef<() => void>(() => {});
useEffect(() => {
let disposed = false;
let socket: WebSocket | null = null;
let reconnectMs = INITIAL_RECONNECT_MS;
let timer: number | null = null;
const closeSocket = () => {
const existing = socket;
socket = null;
if (!existing) return;
existing.onopen = null;
existing.onmessage = null;
existing.onerror = null;
existing.onclose = null;
try {
existing.close();
} catch {
// ignore — an already-closing socket has nothing to close
}
};
const cancelTimer = () => {
if (timer === null) return;
window.clearTimeout(timer);
timer = null;
};
function connect(): void {
if (disposed) return;
cancelTimer();
closeSocket();
const current = factoryRef.current(sessionId);
socket = current;
setSocketGeneration((n) => n + 1);
current.onopen = () => {
if (disposed || socket !== current) return;
reconnectMs = INITIAL_RECONNECT_MS;
setDetail(null);
setStatus("open");
};
current.onmessage = (ev) => {
if (disposed || socket !== current) return;
if (typeof ev.data !== "string") return;
let msg: StructuredEvent;
try {
msg = JSON.parse(ev.data) as StructuredEvent;
} catch {
return; // not a structured event we understand
}
switch (msg.type) {
case "transcript_snapshot":
setRows([...msg.snapshot.rows]);
break;
case "reduced_state":
setState(msg.state);
break;
case "projection_update":
if (msg.transcript) {
const delta = msg.transcript;
setRows((previous) => applyDelta(previous, delta));
}
setState(msg.state);
break;
}
};
current.onerror = () => {
if (disposed || socket !== current) return;
setStatus("error");
};
current.onclose = (ev) => {
if (disposed || socket !== current) return;
socket = null;
const reason = ev.reason?.trim();
if (ev.code >= 4000 && reason) {
setDetail(reason);
setStatus("error");
return;
}
if (shouldReconnectRef.current(ev.code)) {
const delay = Math.min(reconnectMs, MAX_RECONNECT_MS);
setStatus("reconnecting");
timer = window.setTimeout(() => {
timer = null;
if (disposed) return;
reconnectMs = Math.min(reconnectMs * 2, MAX_RECONNECT_MS);
connect();
}, delay);
return;
}
setDetail(String(ev.code));
setStatus("closed");
onClosedRef.current?.();
};
}
reconnectRef.current = () => {
if (disposed) return;
setStatus("reconnecting");
connect();
};
setStatus("connecting");
connect();
return () => {
disposed = true;
reconnectRef.current = () => {};
cancelTimer();
closeSocket();
};
}, [sessionId]);
const reconnect = useCallback(() => reconnectRef.current(), []);
return useMemo(
() => ({ rows, state, status, detail, socketGeneration, reconnect }),
[rows, state, status, detail, socketGeneration, reconnect],
);
}
+134
View File
@@ -0,0 +1,134 @@
// React port of ../../attach.ts's `buildKeyBar` — the Termux-style special-key
// bar for the soft keyboard (Esc/Tab/Ctrl/Alt/cursor keys/symbols it hides
// behind a page switch). Ctrl/Alt are one-shot sticky modifiers: arming one
// folds into the very next key, tapped here or typed on the soft keyboard,
// then disarms.
import { useCallback, useRef, useState } from "react";
import { applyModsToChar, cursorSeq, tildeSeq } from "../../keys.js";
type Modifier = "ctrl" | "alt";
/** CSI final bytes: arrows A-D, Home `H`, End `F`. */
type CursorFinal = "A" | "B" | "C" | "D" | "H" | "F";
type KeyDef =
| { label: string; mod: Modifier }
| { label: string; cursor: CursorFinal }
| { label: string; tilde: number }
| { label: string; char: string }
| { label: string; seq: string };
/**
* Row-major into an 8-column / 2-row grid (#7):
*
* Esc Tab Home ↑ End PgUp - /
* Ctrl Alt ← ↓ → PgDn | ~
*/
const KEY_DEFS: KeyDef[] = [
{ label: "Esc", seq: "\x1b" },
{ label: "Tab", seq: "\t" },
{ label: "Home", cursor: "H" },
{ label: "↑", cursor: "A" },
{ label: "End", cursor: "F" },
{ label: "PgUp", tilde: 5 },
{ label: "-", char: "-" },
{ label: "/", char: "/" },
{ label: "Ctrl", mod: "ctrl" },
{ label: "Alt", mod: "alt" },
{ label: "←", cursor: "D" },
{ label: "↓", cursor: "B" },
{ label: "→", cursor: "C" },
{ label: "PgDn", tilde: 6 },
{ label: "|", char: "|" },
{ label: "~", char: "~" },
];
/**
* Pending Ctrl/Alt state plus `consume`, which folds them into a soft-keyboard
* character before it reaches the PTY — used by the terminal's own `onData`
* handler, not by this component's own button clicks.
*/
export function useKeyBarModifiers() {
const armedRef = useRef<{ ctrl: boolean; alt: boolean }>({ ctrl: false, alt: false });
const [, setTick] = useState(0);
const rerender = useCallback(() => setTick((n) => n + 1), []);
const setMod = useCallback(
(mod: Modifier, on: boolean) => {
armedRef.current[mod] = on;
rerender();
},
[rerender],
);
const clearMods = useCallback(() => {
if (!armedRef.current.ctrl && !armedRef.current.alt) return;
armedRef.current = { ctrl: false, alt: false };
rerender();
}, [rerender]);
const consume = useCallback((data: string): string => {
const { ctrl, alt } = armedRef.current;
if ((ctrl || alt) && data.length === 1) {
const out = applyModsToChar(data, ctrl, alt);
armedRef.current = { ctrl: false, alt: false };
return out;
}
return data;
}, []);
return { armed: armedRef.current, setMod, clearMods, consume };
}
export interface KeyBarProps {
armed: { ctrl: boolean; alt: boolean };
onToggleMod(mod: Modifier): void;
onClearMods(): void;
onSend(data: string): void;
}
export function KeyBar({ armed, onToggleMod, onClearMods, onSend }: KeyBarProps) {
return (
<div
role="toolbar"
aria-label="Special keys"
className="flex flex-wrap gap-1 border-t border-white/10 bg-black/20 p-1"
>
{KEY_DEFS.map((def) => {
if ("mod" in def) {
return (
<button
key={def.label}
type="button"
aria-pressed={armed[def.mod]}
aria-label={`${def.label} modifier`}
className="min-w-8 rounded px-2 py-1 text-xs aria-pressed:bg-emerald-500/40"
onClick={() => onToggleMod(def.mod)}
>
{def.label}
</button>
);
}
const bytes = () => {
if ("cursor" in def) return cursorSeq(def.cursor, armed.ctrl, armed.alt);
if ("tilde" in def) return tildeSeq(def.tilde, armed.ctrl, armed.alt);
if ("char" in def) return applyModsToChar(def.char, armed.ctrl, armed.alt);
return def.seq;
};
return (
<button
key={def.label}
type="button"
className="min-w-8 rounded px-2 py-1 text-xs"
onClick={() => {
onSend(bytes());
onClearMods();
}}
>
{def.label}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,363 @@
// @vitest-environment happy-dom
import { cleanup, render } from "@testing-library/react";
import { createRef } from "react";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "../../reconnect.js";
import { attachWsUrl } from "../../routes.js";
import type { ClientControl } from "../../types.js";
class FakeTerminal {
static instances: FakeTerminal[] = [];
cols = 80;
rows = 24;
element: HTMLElement | undefined = undefined;
textarea: HTMLTextAreaElement | null = null;
disposed = false;
focus = vi.fn();
private dataCb: ((d: string) => void) | null = null;
constructor(public opts: unknown) {
FakeTerminal.instances.push(this);
}
loadAddon(): void {}
open(container: HTMLElement): void {
container.appendChild(document.createElement("div"));
}
onData(cb: (d: string) => void) {
this.dataCb = cb;
return { dispose: vi.fn() };
}
emitData(d: string): void {
this.dataCb?.(d);
}
onResize() {
return { dispose: vi.fn() };
}
onSelectionChange() {
return { dispose: vi.fn() };
}
write(_data: Uint8Array, cb?: () => void): void {
cb?.();
}
scrollToBottom(): void {}
resize(cols: number, rows: number): void {
this.cols = cols;
this.rows = rows;
}
clearTextureAtlas(): void {}
getSelection(): string {
return "";
}
dispose(): void {
this.disposed = true;
}
}
class FakeFitAddon {
fit = vi.fn();
}
class FakeWebglAddon {
static instances: FakeWebglAddon[] = [];
disposed = false;
private lossCb: (() => void) | null = null;
constructor() {
FakeWebglAddon.instances.push(this);
}
onContextLoss(cb: () => void): void {
this.lossCb = cb;
}
triggerLoss(): void {
this.lossCb?.();
}
dispose(): void {
this.disposed = true;
}
}
const policyEnabled = vi.fn(() => true);
const policyNoteLoss = vi.fn();
vi.mock("@xterm/xterm", () => ({ Terminal: FakeTerminal }));
vi.mock("@xterm/addon-fit", () => ({ FitAddon: FakeFitAddon }));
vi.mock("@xterm/addon-webgl", () => ({ WebglAddon: FakeWebglAddon }));
vi.mock("../../webglRenderer.js", () => ({
WebglPolicy: vi.fn().mockImplementation(() => ({
enabled: policyEnabled,
noteContextLoss: policyNoteLoss,
})),
}));
class FakeWebSocket {
static OPEN = 1;
static CONNECTING = 0;
static CLOSING = 2;
static CLOSED = 3;
static instances: FakeWebSocket[] = [];
readyState = FakeWebSocket.CONNECTING;
binaryType = "";
sent: (string | ArrayBufferView)[] = [];
onopen: (() => void) | null = null;
onmessage: ((ev: { data: unknown }) => void) | null = null;
onerror: (() => void) | null = null;
onclose: ((ev: { code: number; reason: string }) => void) | null = null;
constructor(public url: string) {
FakeWebSocket.instances.push(this);
}
send(data: string | ArrayBufferView): void {
this.sent.push(data);
}
close(): void {
this.readyState = FakeWebSocket.CLOSED;
}
open(): void {
this.readyState = FakeWebSocket.OPEN;
this.onopen?.();
}
drop(code = 1006, reason = ""): void {
this.readyState = FakeWebSocket.CLOSED;
this.onclose?.({ code, reason });
}
lastControl(): ClientControl {
return JSON.parse(this.sent.at(-1) as string) as ClientControl;
}
}
beforeAll(() => {
vi.stubGlobal(
"ResizeObserver",
class {
observe(): void {}
disconnect(): void {}
unobserve(): void {}
},
);
});
beforeEach(() => {
FakeTerminal.instances = [];
FakeWebglAddon.instances = [];
FakeWebSocket.instances = [];
policyEnabled.mockReturnValue(true);
policyEnabled.mockClear();
policyNoteLoss.mockClear();
vi.stubGlobal("WebSocket", FakeWebSocket);
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
// Imported after the mocks above so it picks up the mocked xterm/webgl modules.
const { TerminalPane } = await import("./TerminalPane.js");
describe("TerminalPane lifecycle", () => {
it("opens an xterm terminal and connects to the session's attach socket on mount", () => {
render(<TerminalPane sessionId="s1" />);
expect(FakeTerminal.instances).toHaveLength(1);
expect(FakeWebSocket.instances).toHaveLength(1);
expect(FakeWebSocket.instances[0].url).toBe(attachWsUrl("s1"));
});
it("sends the terminal size once the socket opens", () => {
render(<TerminalPane sessionId="s1" />);
const sock = FakeWebSocket.instances[0];
sock.open();
expect(sock.lastControl()).toEqual({ type: "Resize", cols: 80, rows: 24 });
});
it("disposes the terminal and closes the socket on unmount", () => {
const { unmount } = render(<TerminalPane sessionId="s1" />);
const term = FakeTerminal.instances[0];
const sock = FakeWebSocket.instances[0];
unmount();
expect(term.disposed).toBe(true);
expect(sock.readyState).toBe(FakeWebSocket.CLOSED);
});
it("reconnects to a fresh socket for a new sessionId", () => {
const { rerender } = render(<TerminalPane sessionId="s1" />);
rerender(<TerminalPane sessionId="s2" />);
expect(FakeWebSocket.instances).toHaveLength(2);
expect(FakeWebSocket.instances[1].url).toBe(attachWsUrl("s2"));
expect(FakeTerminal.instances[0].disposed).toBe(true);
});
});
describe("TerminalPane reconnect backoff", () => {
it("retries a dropped socket, doubling the delay from 500ms up to a 10s cap", () => {
vi.useFakeTimers();
render(<TerminalPane sessionId="s1" />);
const drop = () => FakeWebSocket.instances.at(-1)!.drop(1006, "");
const expectStillWaiting = (ms: number, countBefore: number) => {
vi.advanceTimersByTime(ms - 1);
expect(FakeWebSocket.instances).toHaveLength(countBefore);
vi.advanceTimersByTime(1);
expect(FakeWebSocket.instances).toHaveLength(countBefore + 1);
};
expect(FakeWebSocket.instances).toHaveLength(1);
drop();
expectStillWaiting(INITIAL_RECONNECT_MS, 1); // 500ms -> #2
drop();
expectStillWaiting(INITIAL_RECONNECT_MS * 2, 2); // 1000ms -> #3
drop();
expectStillWaiting(INITIAL_RECONNECT_MS * 4, 3); // 2000ms -> #4
drop();
expectStillWaiting(INITIAL_RECONNECT_MS * 8, 4); // 4000ms -> #5
drop();
expectStillWaiting(INITIAL_RECONNECT_MS * 16, 5); // 8000ms -> #6
drop();
expectStillWaiting(MAX_RECONNECT_MS, 6); // capped at 10s -> #7
drop();
expectStillWaiting(MAX_RECONNECT_MS, 7); // stays capped -> #8
});
it("resets the backoff once a reconnected socket opens", () => {
vi.useFakeTimers();
render(<TerminalPane sessionId="s1" />);
FakeWebSocket.instances[0].drop(1006, "");
vi.advanceTimersByTime(INITIAL_RECONNECT_MS);
FakeWebSocket.instances[1].open();
FakeWebSocket.instances[1].drop(1006, "");
vi.advanceTimersByTime(INITIAL_RECONNECT_MS - 1);
expect(FakeWebSocket.instances).toHaveLength(2);
vi.advanceTimersByTime(1);
expect(FakeWebSocket.instances).toHaveLength(3);
});
it("does not retry a clean close, and reports it closed", () => {
vi.useFakeTimers();
const onClosed = vi.fn();
render(<TerminalPane sessionId="s1" onClosed={onClosed} />);
FakeWebSocket.instances[0].drop(1000, "");
vi.advanceTimersByTime(MAX_RECONNECT_MS * 2);
expect(FakeWebSocket.instances).toHaveLength(1);
expect(onClosed).toHaveBeenCalledOnce();
});
it("does not retry an application-level rejection, and writes it into the terminal", () => {
vi.useFakeTimers();
const onClosed = vi.fn();
render(<TerminalPane sessionId="s1" onClosed={onClosed} />);
const term = FakeTerminal.instances[0];
const writeSpy = vi.spyOn(term, "write");
FakeWebSocket.instances[0].drop(4004, "unknown session");
vi.advanceTimersByTime(MAX_RECONNECT_MS * 2);
expect(FakeWebSocket.instances).toHaveLength(1);
expect(onClosed).not.toHaveBeenCalled();
expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining("unknown session"));
});
it("honors a caller-supplied shouldReconnect policy", () => {
vi.useFakeTimers();
const onClosed = vi.fn();
render(<TerminalPane sessionId="s1" shouldReconnect={() => false} onClosed={onClosed} />);
FakeWebSocket.instances[0].drop(1006, "");
vi.advanceTimersByTime(MAX_RECONNECT_MS * 2);
expect(FakeWebSocket.instances).toHaveLength(1);
expect(onClosed).toHaveBeenCalledOnce();
});
});
describe("TerminalPane WebGL context budget", () => {
it("acquires a WebGL context on mount, shared policy allowing", () => {
render(<TerminalPane sessionId="s1" />);
expect(policyEnabled).toHaveBeenCalled();
expect(FakeWebglAddon.instances).toHaveLength(1);
});
it("never creates a context once the shared budget is exhausted", () => {
policyEnabled.mockReturnValue(false);
render(<TerminalPane sessionId="s1" />);
expect(FakeWebglAddon.instances).toHaveLength(0);
});
it("releases its context on unmount, handing the budget back", () => {
const { unmount } = render(<TerminalPane sessionId="s1" />);
const addon = FakeWebglAddon.instances[0];
unmount();
expect(addon.disposed).toBe(true);
});
it("counts a context loss and re-acquires while still mounted", () => {
render(<TerminalPane sessionId="s1" />);
const first = FakeWebglAddon.instances[0];
first.triggerLoss();
expect(policyNoteLoss).toHaveBeenCalledOnce();
expect(first.disposed).toBe(true);
expect(FakeWebglAddon.instances).toHaveLength(2);
});
it("does not re-acquire a lost context once the budget is spent", () => {
render(<TerminalPane sessionId="s1" />);
const first = FakeWebglAddon.instances[0];
policyEnabled.mockReturnValue(false);
first.triggerLoss();
expect(FakeWebglAddon.instances).toHaveLength(1);
});
});
describe("TerminalPane imperative handle", () => {
it("sendInput writes bytes to the PTY only once the socket is open", () => {
const ref = createRef<import("./TerminalPane.js").TerminalPaneHandle>();
render(<TerminalPane sessionId="s1" ref={ref} />);
const sock = FakeWebSocket.instances[0];
ref.current!.sendInput("nope");
expect(sock.sent).toHaveLength(0);
sock.open();
sock.sent.length = 0; // drop the resize control frame from open()
ref.current!.sendInput("hi");
expect(sock.sent).toHaveLength(1);
expect(new TextDecoder().decode(sock.sent[0] as Uint8Array)).toBe("hi");
});
it("focusTerminal focuses the underlying xterm instance", () => {
const ref = createRef<import("./TerminalPane.js").TerminalPaneHandle>();
render(<TerminalPane sessionId="s1" ref={ref} />);
ref.current!.focusTerminal();
expect(FakeTerminal.instances[0].focus).toHaveBeenCalledOnce();
});
it("routes typed keystrokes through to the socket", () => {
render(<TerminalPane sessionId="s1" />);
const sock = FakeWebSocket.instances[0];
sock.open();
sock.sent.length = 0;
FakeTerminal.instances[0].emitData("q");
expect(new TextDecoder().decode(sock.sent[0] as Uint8Array)).toBe("q");
});
});
@@ -0,0 +1,362 @@
// React port of ../../attach.ts: an xterm terminal bound to the daemon's
// attach WebSocket, plus ../../webglRenderer.ts's shared GPU-context budget
// and ../../reconnect.ts's backoff schedule. Pane chrome (title, disconnect
// button, sessions drawer) stays out of scope here — this is the thin
// ref-based xterm wrapper the issue asked for; a header composes around it.
import { FitAddon } from "@xterm/addon-fit";
import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "../../reconnect.js";
import { attachWsUrl } from "../../routes.js";
import type { ClientControl, Uuid } from "../../types.js";
import { WebglPolicy } from "../../webglRenderer.js";
import { KeyBar, useKeyBarModifiers } from "./KeyBar.js";
/** One budget for the whole page — every mounted `TerminalPane` shares it, the
* same reasoning as attach.ts's own module-level policy. */
const webglPolicy = new WebglPolicy();
export type TerminalStatus = "connecting" | "open" | "reconnecting" | "closed" | "error";
/** Application-defined close codes (daemon rejection) and the clean-close
* code — retrying either would only loop. Mirrors reconnect.ts's
* `shouldRetryClose`, minus the catalog lookup a standalone pane doesn't have. */
function defaultShouldReconnect(code: number): boolean {
return code < 4000 && code !== 1000;
}
export interface TerminalPaneProps {
sessionId: Uuid;
/** Grabs the keyboard once the socket opens. A sidebar preview passes `false`. */
focus?: boolean;
/** Asked on every close: should the socket be retried? */
shouldReconnect?: (code: number) => boolean;
/** The socket closed for good (retry declined, or the daemon rejected the
* session) — not called on an ordinary unmount. */
onClosed?: () => void;
/** Connection status changes, for a header/status badge to render. */
onStatusChange?: (status: TerminalStatus, detail?: string) => void;
className?: string;
}
export interface TerminalPaneHandle {
focusTerminal(): void;
sendInput(data: string): void;
}
export const TerminalPane = forwardRef<TerminalPaneHandle, TerminalPaneProps>(
function TerminalPane(
{ sessionId, focus = true, shouldReconnect = defaultShouldReconnect, onClosed, onStatusChange, className },
ref,
) {
const bodyRef = useRef<HTMLDivElement | null>(null);
const sendInputRef = useRef<(data: string) => void>(() => {});
const focusTerminalRef = useRef<() => void>(() => {});
const keyBar = useKeyBarModifiers();
const [keybarActive, setKeybarActive] = useState(false);
useImperativeHandle(
ref,
() => ({
focusTerminal: () => focusTerminalRef.current(),
sendInput: (data: string) => sendInputRef.current(data),
}),
[],
);
useEffect(() => {
const body = bodyRef.current;
if (!body) return;
const term = new Terminal({
cursorBlink: true,
fontFamily:
'"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
fontSize: 13,
convertEol: false,
theme: {
background: "#121419",
foreground: "#e6e8ec",
cursor: "#5fe3a1",
cursorAccent: "#121419",
selectionBackground: "#2b6b4f",
},
scrollback: 5000,
allowProposedApi: true,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(body);
let webgl: WebglAddon | null = null;
let dprMedia: MediaQueryList | null = null;
const onDprChange = () => {
try {
term.clearTextureAtlas();
} catch {
// ignore — nothing to rebuild if the renderer already went away
}
watchDpr();
};
function watchDpr(): void {
dprMedia?.removeEventListener("change", onDprChange);
dprMedia = null;
try {
dprMedia = window.matchMedia?.(`(resolution: ${window.devicePixelRatio}dppx)`) ?? null;
dprMedia?.addEventListener("change", onDprChange);
} catch {
// ignore — a browser without `resolution` media queries just keeps
// the atlas it has
}
}
function releaseWebgl(): void {
dprMedia?.removeEventListener("change", onDprChange);
dprMedia = null;
if (!webgl) return;
const addon = webgl;
webgl = null;
try {
addon.dispose();
} catch {
// ignore — a lost context can throw on the way out
}
}
function acquireWebgl(): void {
if (webgl || disposed || !webglPolicy.enabled()) return;
try {
const addon = new WebglAddon();
addon.onContextLoss(() => {
webglPolicy.noteContextLoss();
releaseWebgl();
if (mounted) acquireWebgl();
});
term.loadAddon(addon);
webgl = addon;
watchDpr();
} catch {
webglPolicy.noteContextLoss();
webgl = null;
}
}
let mounted = false;
const tryFit = () => {
if (!mounted) return;
try {
const el2 = term.element;
const parent = el2?.parentElement;
const core = (
term as unknown as {
_core?: { _renderService?: { dimensions?: { css?: { cell?: { width: number; height: number } } } } };
}
)._core;
const cell = core?._renderService?.dimensions?.css?.cell;
if (!el2 || !parent || !cell || !cell.width || !cell.height) {
fit.fit();
return;
}
const vp = el2.querySelector(".xterm-viewport") as HTMLElement | null;
let availW: number;
let availH: number;
if (vp && vp.clientWidth > 0) {
availW = vp.clientWidth;
availH = vp.clientHeight;
} else {
const ps = getComputedStyle(parent);
const ts = getComputedStyle(el2);
availW = parseInt(ps.width) - (parseInt(ts.paddingLeft) + parseInt(ts.paddingRight) || 0);
availH = parseInt(ps.height) - (parseInt(ts.paddingTop) + parseInt(ts.paddingBottom) || 0);
}
const cols = Math.max(2, Math.floor(availW / cell.width));
const rows = Math.max(1, Math.floor(availH / cell.height));
if (cols !== term.cols || rows !== term.rows) term.resize(cols, rows);
} catch {
try {
fit.fit();
} catch {
// ignore — fit fails if the container is detached or zero-sized
}
}
};
let ws: WebSocket;
let reconnectMs = INITIAL_RECONNECT_MS;
let reconnectTimer: number | null = null;
let awaitingReplay = true;
let disposed = false;
const sendControl = (msg: ClientControl) => {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify(msg));
};
const sendInput = (data: string) => {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(new TextEncoder().encode(data));
};
sendInputRef.current = sendInput;
focusTerminalRef.current = () => term.focus();
const onResize = () => {
tryFit();
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
};
function connect(): void {
awaitingReplay = true;
ws = new WebSocket(attachWsUrl(sessionId));
ws.binaryType = "arraybuffer";
ws.onopen = () => {
reconnectMs = INITIAL_RECONNECT_MS;
onStatusChange?.("open");
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
if (mounted && focus) term.focus();
};
ws.onmessage = (ev) => {
if (typeof ev.data === "string") {
try {
JSON.parse(ev.data) as ClientControl;
} catch {
// ignore unknown control frames
}
return;
}
if (ev.data instanceof ArrayBuffer) {
const replay = awaitingReplay;
awaitingReplay = false;
term.write(new Uint8Array(ev.data), () => {
if (replay) term.scrollToBottom();
});
} else if (ev.data instanceof Blob) {
void ev.data.arrayBuffer().then((b) => {
const replay = awaitingReplay;
awaitingReplay = false;
term.write(new Uint8Array(b), () => {
if (replay) term.scrollToBottom();
});
});
}
};
ws.onerror = () => {
onStatusChange?.("error");
};
ws.onclose = (ev) => {
const reason = ev.reason?.trim();
if (ev.code >= 4000 && reason && !disposed) {
onStatusChange?.("error", reason);
term.write(`\r\n\x1b[31m⚠ cannot attach: ${reason}\x1b[0m\r\n`);
return;
}
if (disposed) return;
if (shouldReconnect(ev.code)) {
const delay = Math.min(reconnectMs, MAX_RECONNECT_MS);
onStatusChange?.("reconnecting");
reconnectTimer = window.setTimeout(() => {
reconnectTimer = null;
reconnectMs = Math.min(reconnectMs * 2, MAX_RECONNECT_MS);
connect();
}, delay);
return;
}
onStatusChange?.("closed", String(ev.code));
onClosed?.();
};
}
onStatusChange?.("connecting");
connect();
const dataDisp = term.onData((data) => {
sendInput(keyBar.consume(data));
});
const resizeDisp = term.onResize(({ cols, rows }) => {
sendControl({ type: "Resize", cols, rows });
});
const selectionDisp = term.onSelectionChange(() => {
const text = term.getSelection();
if (text) void navigator.clipboard?.writeText(text).catch(() => {});
});
const ta = term.textarea;
const onTaFocus = () => {
setKeybarActive(true);
requestAnimationFrame(tryFit);
};
const onTaBlur = () => {
setKeybarActive(false);
requestAnimationFrame(tryFit);
};
ta?.addEventListener("focus", onTaFocus);
ta?.addEventListener("blur", onTaBlur);
window.addEventListener("resize", onResize);
const visual = window.visualViewport ?? null;
visual?.addEventListener("resize", onResize);
const ro = new ResizeObserver(() => tryFit());
ro.observe(body);
document.fonts?.ready?.then(() => tryFit()).catch(() => {});
mounted = true;
acquireWebgl();
requestAnimationFrame(() => {
tryFit();
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
if (focus && ws.readyState === WebSocket.OPEN) term.focus();
});
return () => {
disposed = true;
mounted = false;
if (reconnectTimer !== null) {
window.clearTimeout(reconnectTimer);
reconnectTimer = null;
}
ro.disconnect();
window.removeEventListener("resize", onResize);
visual?.removeEventListener("resize", onResize);
ta?.removeEventListener("focus", onTaFocus);
ta?.removeEventListener("blur", onTaBlur);
dataDisp.dispose();
resizeDisp.dispose();
selectionDisp.dispose();
releaseWebgl();
try {
ws.close();
} catch {
// ignore
}
term.dispose();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- reconnecting
// a session change is a fresh connection, not a prop-driven re-render
}, [sessionId]);
return (
<div className={className ? `flex h-full flex-col ${className}` : "flex h-full flex-col"}>
<div ref={bodyRef} className="min-h-0 flex-1" />
{keybarActive && (
<KeyBar
armed={keyBar.armed}
onToggleMod={(mod) => keyBar.setMod(mod, !keyBar.armed[mod])}
onClearMods={keyBar.clearMods}
onSend={(data) => sendInputRef.current(data)}
/>
)}
</div>
);
},
);
+163
View File
@@ -0,0 +1,163 @@
// @vitest-environment happy-dom
import { act, cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { PALETTES, ThemeProvider, useTheme } from "./ThemeContext";
// happy-dom's vitest environment ships neither localStorage nor matchMedia;
// back both with plain in-memory fakes so persistence and system-theme
// reactivity are observable (mirrors ../theme.test.ts's localStorage fake).
let store = new Map<string, string>();
let systemPrefersLight = false;
let mediaListeners: Array<(e: MediaQueryListEvent) => void> = [];
beforeAll(() => {
Object.defineProperty(window, "localStorage", {
value: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => store.set(k, v),
removeItem: (k: string) => store.delete(k),
},
});
Object.defineProperty(window, "matchMedia", {
value: (query: string) => ({
matches: query.includes("light") ? systemPrefersLight : false,
media: query,
addEventListener: (_type: string, cb: (e: MediaQueryListEvent) => void) => {
mediaListeners.push(cb);
},
removeEventListener: (_type: string, cb: (e: MediaQueryListEvent) => void) => {
mediaListeners = mediaListeners.filter((l) => l !== cb);
},
}),
});
});
function fireSystemThemeChange(matches: boolean): void {
systemPrefersLight = matches;
for (const cb of mediaListeners) {
cb({ matches } as MediaQueryListEvent);
}
}
function Probe() {
const { theme, palette, setTheme, toggleTheme, setPalette } = useTheme();
return (
<div>
<span data-testid="theme">{theme}</span>
<span data-testid="palette">{palette}</span>
<button onClick={() => setTheme("light")}>pin-light</button>
<button onClick={toggleTheme}>toggle</button>
<button onClick={() => setPalette("ember")}>set-ember</button>
<button onClick={() => setPalette("graphite")}>set-graphite</button>
</div>
);
}
describe("ThemeProvider / useTheme", () => {
beforeEach(() => {
store = new Map();
mediaListeners = [];
systemPrefersLight = false;
delete document.documentElement.dataset.theme;
delete document.documentElement.dataset.palette;
});
afterEach(() => {
cleanup();
});
it("defaults to graphite and the system theme, no data-palette attribute", () => {
render(
<ThemeProvider>
<Probe />
</ThemeProvider>,
);
expect(screen.getByTestId("theme").textContent).toBe("dark");
expect(screen.getByTestId("palette").textContent).toBe("graphite");
expect(document.documentElement.dataset.palette).toBeUndefined();
});
it("follows a system theme change while unpinned", () => {
render(
<ThemeProvider>
<Probe />
</ThemeProvider>,
);
act(() => fireSystemThemeChange(true));
expect(screen.getByTestId("theme").textContent).toBe("light");
});
it("setTheme pins, persists, and stops following the system", () => {
render(
<ThemeProvider>
<Probe />
</ThemeProvider>,
);
act(() => screen.getByText("pin-light").click());
expect(screen.getByTestId("theme").textContent).toBe("light");
expect(document.documentElement.dataset.theme).toBe("light");
expect(localStorage.getItem("maestro-theme")).toBe("light");
act(() => fireSystemThemeChange(true));
expect(screen.getByTestId("theme").textContent).toBe("light");
});
it("toggleTheme flips the resolved theme", () => {
render(
<ThemeProvider>
<Probe />
</ThemeProvider>,
);
act(() => screen.getByText("toggle").click());
expect(screen.getByTestId("theme").textContent).toBe("light");
act(() => screen.getByText("toggle").click());
expect(screen.getByTestId("theme").textContent).toBe("dark");
});
it("setPalette stamps data-palette and persists; graphite clears it", () => {
render(
<ThemeProvider>
<Probe />
</ThemeProvider>,
);
act(() => screen.getByText("set-ember").click());
expect(document.documentElement.dataset.palette).toBe("ember");
expect(localStorage.getItem("maestro-palette")).toBe("ember");
act(() => screen.getByText("set-graphite").click());
expect(document.documentElement.dataset.palette).toBeUndefined();
});
it("a fresh mount re-applies a stored palette", () => {
localStorage.setItem("maestro-palette", "periwinkle");
render(
<ThemeProvider>
<Probe />
</ThemeProvider>,
);
expect(screen.getByTestId("palette").textContent).toBe("periwinkle");
expect(document.documentElement.dataset.palette).toBe("periwinkle");
});
it("exposes every palette in picker order", () => {
expect(PALETTES).toEqual([
"graphite",
"ice-cyan",
"periwinkle",
"amber-phosphor",
"ember",
"orchid",
]);
});
it("throws when used outside a provider", () => {
const Bare = () => {
useTheme();
return null;
};
expect(() => render(<Bare />)).toThrow(
"useTheme must be used within a ThemeProvider",
);
});
});
+118
View File
@@ -0,0 +1,118 @@
// React port of ../theme.ts. Same storage keys and `data-theme`/`data-palette`
// attributes on <html>, so this can sit alongside the vanilla shell (which
// still owns theme.ts) without either one fighting the other over state.
import {
createContext,
useCallback,
useContext,
useLayoutEffect,
useState,
type ReactNode,
} from "react";
type Theme = "light" | "dark";
const THEME_KEY = "maestro-theme";
/** Every `data-palette` value, in picker order. Graphite is the default. */
export const PALETTES = [
"graphite",
"ice-cyan",
"periwinkle",
"amber-phosphor",
"ember",
"orchid",
] as const;
export type Palette = (typeof PALETTES)[number];
const PALETTE_KEY = "maestro-palette";
function storedTheme(): Theme | null {
const v = localStorage.getItem(THEME_KEY);
return v === "light" || v === "dark" ? v : null;
}
function storedPalette(): Palette | null {
const v = localStorage.getItem(PALETTE_KEY);
return (PALETTES as readonly string[]).includes(v ?? "")
? (v as Palette)
: null;
}
function systemPrefersLight(): boolean {
return window.matchMedia("(prefers-color-scheme: light)").matches;
}
// Dev-only `?theme=` override for visual review, same as theme.ts's — never
// persisted, just seeds the initial pin for this session.
function devThemeOverride(): Theme | null {
if (!import.meta.env.DEV) return null;
const q = new URLSearchParams(location.search).get("theme");
return q === "light" || q === "dark" ? q : null;
}
interface ThemeContextValue {
theme: Theme;
palette: Palette;
setTheme: (t: Theme) => void;
toggleTheme: () => void;
setPalette: (p: Palette) => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [pinnedTheme, setPinnedTheme] = useState<Theme | null>(
() => devThemeOverride() ?? storedTheme(),
);
const [systemIsLight, setSystemIsLight] = useState(systemPrefersLight);
const [palette, setPaletteState] = useState<Palette>(
() => storedPalette() ?? "graphite",
);
const theme: Theme = pinnedTheme ?? (systemIsLight ? "light" : "dark");
useLayoutEffect(() => {
const mql = window.matchMedia("(prefers-color-scheme: light)");
const onChange = (e: MediaQueryListEvent) => setSystemIsLight(e.matches);
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}, []);
// Pinning is one-way (no UI un-pins back to "follow system"), so this only
// ever needs to set the attribute — never clear it — same as theme.ts.
useLayoutEffect(() => {
if (pinnedTheme) document.documentElement.dataset.theme = pinnedTheme;
}, [pinnedTheme]);
useLayoutEffect(() => {
if (palette === "graphite") delete document.documentElement.dataset.palette;
else document.documentElement.dataset.palette = palette;
}, [palette]);
const setTheme = useCallback((t: Theme) => {
localStorage.setItem(THEME_KEY, t);
setPinnedTheme(t);
}, []);
const toggleTheme = useCallback(() => {
setTheme(theme === "dark" ? "light" : "dark");
}, [theme, setTheme]);
const setPalette = useCallback((p: Palette) => {
localStorage.setItem(PALETTE_KEY, p);
setPaletteState(p);
}, []);
return (
<ThemeContext.Provider
value={{ theme, palette, setTheme, toggleTheme, setPalette }}
>
{children}
</ThemeContext.Provider>
);
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used within a ThemeProvider");
return ctx;
}
+10
View File
@@ -0,0 +1,10 @@
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./main.css";
const container = document.getElementById("root");
if (!container) {
throw new Error("missing #root element");
}
createRoot(container).render(<App />);
+2
View File
@@ -0,0 +1,2 @@
@import "tailwindcss";
@config "../tailwind.config.ts";
+11 -5
View File
@@ -182,26 +182,32 @@ describe("sidebar preview requests (docs/PREVIEW.md)", () => {
expect(seen.at(-1)).toBe(null);
});
it("asks for a preview of the hovered card, and clears it on the way out", () => {
it("ctrl+hover previews a card, plain hover does not", () => {
const seen: (string | null)[] = [];
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
// Plain hover — no preview.
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
expect(seen).toEqual([]);
// Ctrl+hover — preview fires.
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse", ctrlKey: true }));
expect(seen).toEqual([A]);
// Moving within the same card is not a new preview.
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
// Deduplicated while still on the same card.
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse", ctrlKey: true }));
expect(seen).toHaveLength(1);
tree(el).dispatchEvent(new PointerEvent("pointerleave", { pointerType: "mouse" }));
expect(seen.at(-1)).toBe(null);
});
it("ignores touch, where a tap already opens the session", () => {
it("ignores touch even with ctrl", () => {
const seen: (string | null)[] = [];
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "touch" }));
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "touch", ctrlKey: true }));
expect(seen).toEqual([]);
});
});
+1
View File
@@ -7,6 +7,7 @@ const base: SpawnFormValues = {
cwd: "/home/user/project",
name: "amber-fox",
structuredView: false,
autoApprove: false,
worktreeEnabled: false,
worktreeBranch: "",
worktreeBranchMode: "new",
+96 -56
View File
@@ -11,7 +11,7 @@ import { attachDirAutocomplete } from "./dirAutocomplete.js";
import { parentDir } from "./paths.js";
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
import type { ProjectGroup } from "./projectList.js";
import { splitCommand } from "./toolIcon.js";
import { splitCommand, toolIconSvg, TOOL_LABELS } from "./toolIcon.js";
import { abbreviateCwd } from "./types.js";
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
@@ -49,6 +49,7 @@ export interface SpawnFormValues {
cwd: string;
name: string;
structuredView: boolean;
autoApprove: boolean;
worktreeEnabled: boolean;
worktreeBranch: string;
worktreeBranchEdited: boolean;
@@ -79,6 +80,10 @@ export function buildSpawnRequest(
req.view = "Structured";
}
if (values.autoApprove) {
req.auto_approve = true;
}
if (values.worktreeEnabled) {
const mode = values.worktreeBranchMode ?? "new";
const branch = values.worktreeBranch.trim();
@@ -135,6 +140,12 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
const form = document.createElement("form");
form.className = "spawn-form";
form.innerHTML = `
<div class="field">
<span class="field-label">Harness</span>
<div class="harness-grid cmd-tiles"></div>
<input class="raw-input cmd-raw" name="command_raw" type="text" hidden
placeholder="bash -l" autocomplete="off" />
</div>
<div class="field">
<span class="field-label">Project</span>
<input class="raw-input project-filter" type="text" placeholder="Filter projects…"
@@ -146,26 +157,20 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
</div>
</div>
<div class="field">
<span class="field-label">Command</span>
<div class="tile-row cmd-tiles"></div>
<input class="raw-input cmd-raw" name="command_raw" type="text" hidden
placeholder="bash -l" autocomplete="off" />
</div>
<div class="field">
<span class="field-label">Name</span>
<div class="name-row">
<input name="name" type="text" autocomplete="off"
placeholder="named after your first prompt" />
</div>
</div>
<fieldset class="worktree">
<div class="advanced">
<button type="button" class="tree-folder-header wt-toggle" aria-expanded="false">
<span class="chevron">▾</span>
<span class="folder-name">Advanced options</span>
<span class="folder-name">Advanced</span>
<span class="wt-summary"></span>
</button>
<div class="wt-collapsible" hidden>
<div class="field">
<span class="field-label">Session name</span>
<div class="name-row">
<input name="name" type="text" autocomplete="off"
placeholder="named after your first prompt" />
</div>
</div>
<label class="row-inline structured-view" hidden>
<input type="checkbox" name="structured_view" />
Structured view
@@ -174,34 +179,36 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
<span class="field-label">Worktree</span>
<div class="picker wt-choices"></div>
</div>
<label class="row-inline">
<input type="checkbox" name="worktree_enabled" />
Create git worktree
</label>
<p class="wt-hint" hidden></p>
<div class="wt-body" hidden>
<p class="wt-repo"></p>
<div class="wt-mode" role="radiogroup" aria-label="Branch source">
<label class="row-inline">
<input type="radio" name="worktree_branch_mode" value="new" />
New branch
<div class="adv-group">
<label class="row-inline">
<input type="checkbox" name="worktree_enabled" />
Create git worktree
</label>
<p class="wt-hint" hidden></p>
<div class="wt-body" hidden>
<p class="wt-repo"></p>
<div class="wt-mode" role="radiogroup" aria-label="Branch source">
<label class="row-inline">
<input type="radio" name="worktree_branch_mode" value="new" />
New branch
</label>
<label class="row-inline">
<input type="radio" name="worktree_branch_mode" value="existing" />
Existing branch
</label>
</div>
<label class="wt-new-wrap">
New branch name
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
</label>
<label class="row-inline">
<input type="radio" name="worktree_branch_mode" value="existing" />
Existing branch
<label class="wt-exist-wrap" hidden>
Pick a branch
<select class="branch-select"></select>
</label>
</div>
<label class="wt-new-wrap">
New branch name
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
</label>
<label class="wt-exist-wrap" hidden>
Pick a branch
<select class="branch-select"></select>
</label>
</div>
</div>
</fieldset>
</div>
<p class="form-error" role="alert" aria-live="polite"></p>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Spawn</button>
@@ -261,6 +268,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
let ready = false;
let destroyed = false;
const branchValue = () => (branchMode === "existing" ? branchSelect.value : branchInput.value);
const selectedAutoApprove = () =>
presets.find((p) => p.id === selectedPresetId)?.auto_approve ?? false;
const emit = () => {
if (!ready || destroyed) return;
opts.onChange?.({
@@ -268,6 +277,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
cwd,
name: nameInput.value,
structuredView: structuredView.checked,
autoApprove: selectedAutoApprove(),
worktreeEnabled: wtEnabled.checked,
worktreeBranch: branchValue(),
worktreeBranchEdited: branchEdited,
@@ -338,37 +348,69 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
command = preset!.command;
}
updateStructuredAvailability();
for (const tile of cmdTiles.querySelectorAll(".tile")) {
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === id);
for (const card of cmdTiles.querySelectorAll(".harness-card")) {
card.classList.toggle("is-active", (card as HTMLElement).dataset.id === id);
}
emit();
}
/** Re-highlight the operator's already-picked tile after `renderCommandTiles`
* rebuilds the row from scratch (new tiles carry no `is-active` class) —
/** Re-highlight the operator's already-picked card after `renderCommandTiles`
* rebuilds the grid from scratch (new cards carry no `is-active` class) —
* without touching `command`, since that was already settled. */
function reapplyActiveTile(): void {
for (const tile of cmdTiles.querySelectorAll(".tile")) {
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === selectedPresetId);
for (const card of cmdTiles.querySelectorAll(".harness-card")) {
card.classList.toggle("is-active", (card as HTMLElement).dataset.id === selectedPresetId);
}
}
/** Resolve a preset id to a tool icon id. The preset's command is the
* source of truth — `splitCommand` extracts the tool the same way the
* sidebar does. For the "Custom…" preset (empty command) there is no
* tool to show, and a terminal prompt stands in. */
function presetToolId(p: SpawnPreset): string | null {
if (!p.command) return null;
return splitCommand(p.command).tool;
}
function renderCommandTiles(): void {
cmdTiles.replaceChildren();
for (const p of presets) {
const tile = document.createElement("button");
tile.type = "button";
tile.className = "tile";
tile.dataset.id = p.id;
tile.innerHTML = `<span class="tile-label">${escapeHtml(p.label)}</span>`;
tile.addEventListener("click", () => {
const isCustom = p.command === "";
const card = document.createElement("button");
card.type = "button";
card.className = isCustom ? "harness-card harness-custom" : "harness-card";
card.dataset.id = p.id;
const toolId = presetToolId(p);
const iconHtml = isCustom ? customIcon() : toolId ? harnessIcon(toolId) : harnessIcon("shell");
const label = TOOL_LABELS[toolId ?? ""] ?? p.label;
card.innerHTML =
`<span class="harness-icon">${iconHtml}</span>` +
`<span class="harness-label">${escapeHtml(isCustom ? "Custom\u2026" : label)}</span>`;
card.addEventListener("click", () => {
commandTouched = true;
selectPreset(p.id);
});
cmdTiles.appendChild(tile);
cmdTiles.appendChild(card);
}
}
/** Render a harness card icon at card size (24x24). Re-uses the SVG bodies
* from toolIcon.ts but at a larger viewport so the marks breathe. */
function harnessIcon(toolId: string): string {
// toolIconSvg renders at 13x13 with viewBox 0 0 16 16 — we want the
// same shape at 24x24 for the card context.
const small = toolIconSvg(toolId);
if (!small) return "";
return small
.replace(/width="13"/g, 'width="24"')
.replace(/height="13"/g, 'height="24"');
}
/** An ellipsis mark for the "Custom…" escape hatch. */
function customIcon(): string {
return `<svg class="tool-icon" viewBox="0 0 16 16" width="24" height="24" role="img" aria-label="Custom command"><title>Custom command</title><g fill="currentColor"><circle cx="4" cy="8" r="1.3"/><circle cx="8" cy="8" r="1.3"/><circle cx="12" cy="8" r="1.3"/></g></svg>`;
}
// Set once the operator picks a tile or types a custom command — guards
// `applyInitialCommand()` the same way `nameTouched` guards a late name
// batch: a late `/api/info` resolution must never overwrite a field the
@@ -638,16 +680,13 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
box.type = "button";
box.className = `project-box${isStale(project, new Date()) ? " is-stale" : ""}`;
box.dataset.cwd = project.path;
const trees = worktreesOf(group).length;
// The parent directory only — the last segment is the name line above it.
// Unabbreviated beyond `~`, since the stem is what tells two projects
// sharing a basename apart (#95).
const parent = abbreviateCwd(parentDir(project.path), homeDir);
const meta = trees > 0 ? `${trees} worktree${trees === 1 ? "" : "s"}` : "";
box.innerHTML = `
<span class="project-name">${escapeHtml(project.name)}</span>
<span class="project-path">${escapeHtml(parent)}</span>
<span class="project-meta">${escapeHtml(meta)}</span>`;
<span class="project-path">${escapeHtml(parent)}</span>`;
box.addEventListener("click", () => selectProject(group));
return box;
}
@@ -927,6 +966,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
cwd,
name: nameInput.value,
structuredView: structuredView.checked,
autoApprove: selectedAutoApprove(),
worktreeEnabled: wtEnabled.checked,
worktreeBranch: branchValue(),
worktreeBranchEdited: branchEdited,
+1 -1
View File
@@ -139,7 +139,7 @@ describe("spawnForm Enter-to-submit (#97)", () => {
const form = await mount([MAESTRO]);
box(form, MAESTRO.path).click();
await drain();
form.querySelector<HTMLButtonElement>('.cmd-tiles .tile[data-id="custom"]')!.click();
form.querySelector<HTMLButtonElement>('.cmd-tiles .harness-card[data-id="custom"]')!.click();
const raw = form.querySelector<HTMLInputElement>(".cmd-raw")!;
raw.value = "bash -l";
raw.dispatchEvent(new Event("input"));
+3 -3
View File
@@ -63,7 +63,7 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
await drain();
// Fallback presets are up; the operator picks "zsh" before /api/info lands.
const zshTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
const zshTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
(t) => t.dataset.id === "zsh",
)!;
zshTile.click();
@@ -88,7 +88,7 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
document.body.appendChild(form);
await drain();
const customTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
const customTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
(t) => t.dataset.id === "custom",
)!;
customTile.click();
@@ -116,7 +116,7 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
// Untouched: the refined preset list is still free to settle the command.
expect(values?.command).toBe("claude");
const fishTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
const fishTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
(t) => t.dataset.id === "fish",
);
expect(fishTile).toBeDefined();
+2 -2
View File
@@ -52,14 +52,14 @@ describe("spawn modal", () => {
expect(host.querySelector(".form-actions .cancel")).toBeTruthy();
});
it("opens with the project filter focused, ahead of the command tiles", async () => {
it("opens with the project filter focused, harness before project", async () => {
openSpawnModal({ onSuccess: () => {} });
await flush();
const form = modal()!.querySelector("form.spawn-form")!;
expect(document.activeElement).toBe(form.querySelector(".project-filter"));
const fields = [...form.querySelectorAll(".field .field-label")].map((el) => el.textContent);
expect(fields.slice(0, 2)).toEqual(["Project", "Command"]);
expect(fields.slice(0, 2)).toEqual(["Harness", "Project"]);
});
it("titles a seeded open as a clone", async () => {
+206
View File
@@ -0,0 +1,206 @@
import { describe, expect, it, vi } from "vitest";
import {
createCatalogStore,
INITIAL_BACKOFF_MS,
MAX_BACKOFF_MS,
type CatalogStore,
type Scheduler,
} from "./catalogStore.js";
import type { CatalogEvent, Session } from "../types.js";
function session(id: string): Session {
return {
id,
cwd: "/home/n",
command: "bash",
env: {},
metadata: {},
status: "Running",
created_at: "2026-08-22T00:00:00Z",
} as Session;
}
/** Just enough of a WebSocket for the store to drive, plus a way to push frames. */
class FakeSocket {
onmessage: ((ev: { data: string }) => void) | null = null;
onopen: (() => void) | null = null;
onclose: (() => void) | null = null;
onerror: (() => void) | null = null;
closed = false;
close(): void {
this.closed = true;
}
open(): void {
this.onopen?.();
}
emit(event: CatalogEvent): void {
this.onmessage?.({ data: JSON.stringify(event) });
}
drop(): void {
this.onclose?.();
}
}
/** Scheduler that records delays instead of waiting them out. */
function fakeScheduler(): Scheduler & { delays: number[]; runNext(): void } {
const queued: Array<() => void> = [];
return {
delays: [] as number[],
setTimer(fn, ms) {
this.delays.push(ms);
queued.push(fn);
return queued.length;
},
clearTimer() {},
runNext() {
queued.shift()?.();
},
};
}
function storeWith(sockets: FakeSocket[], refetch?: () => Promise<Session[]>) {
let i = 0;
const scheduler = fakeScheduler();
const store = createCatalogStore({
socket: () => sockets[i++] as unknown as WebSocket,
scheduler,
refetch,
});
return { store, scheduler };
}
describe("catalogStore reducer", () => {
it("replaces state on Snapshot and applies deltas", () => {
const ws = new FakeSocket();
const { store } = storeWith([ws]);
store.getState().start();
ws.emit({ type: "Snapshot", sessions: [session("a")] } as CatalogEvent);
expect([...store.getState().sessions.keys()]).toEqual(["a"]);
ws.emit({ type: "SessionAdded", session: session("b") } as CatalogEvent);
expect([...store.getState().sessions.keys()].sort()).toEqual(["a", "b"]);
ws.emit({ type: "SessionRemoved", id: "a" } as CatalogEvent);
expect([...store.getState().sessions.keys()]).toEqual(["b"]);
});
it("ignores a frame that is not JSON", () => {
const ws = new FakeSocket();
const { store } = storeWith([ws]);
store.getState().start();
ws.emit({ type: "Snapshot", sessions: [session("a")] } as CatalogEvent);
ws.onmessage?.({ data: "not json" });
expect([...store.getState().sessions.keys()]).toEqual(["a"]);
});
it("refetches over REST when the server reports dropped frames", async () => {
const ws = new FakeSocket();
const refetch = vi.fn(async () => [session("fresh")]);
const { store } = storeWith([ws], refetch);
store.getState().start();
ws.emit({ type: "Snapshot", sessions: [session("stale")] } as CatalogEvent);
ws.emit({ type: "Lagged", skipped: 4 } as CatalogEvent);
await vi.waitFor(() => expect(refetch).toHaveBeenCalledOnce());
expect([...store.getState().sessions.keys()]).toEqual(["fresh"]);
});
it("hands each subscriber a fresh sessions map it can diff against the previous one", () => {
const ws = new FakeSocket();
const { store } = storeWith([ws]);
const seen: CatalogStore[] = [];
store.subscribe((s) => seen.push(s));
store.getState().start();
ws.emit({ type: "Snapshot", sessions: [session("a")] } as CatalogEvent);
ws.emit({ type: "SessionAdded", session: session("b") } as CatalogEvent);
const [afterSnapshot, afterAdd] = seen;
expect(afterSnapshot.sessions).not.toBe(afterAdd.sessions);
expect(afterSnapshot.sessions.size).toBe(1);
expect(afterAdd.sessions.size).toBe(2);
});
});
describe("catalogStore reconnect", () => {
it("replaces a suspended socket immediately", () => {
const first = new FakeSocket();
const second = new FakeSocket();
const { store } = storeWith([first, second]);
store.getState().start();
store.getState().reconnect();
expect(first.closed).toBe(true);
second.open();
// A late close from the old socket is deliberately ignored.
first.drop();
expect(store.getState().backoff()).toBe(INITIAL_BACKOFF_MS);
});
it("doubles the delay per attempt and caps it", () => {
const sockets = Array.from({ length: 8 }, () => new FakeSocket());
const { store, scheduler } = storeWith(sockets);
store.getState().start();
for (let i = 0; i < 7; i++) {
sockets[i].drop();
scheduler.runNext();
}
expect(scheduler.delays[0]).toBe(INITIAL_BACKOFF_MS);
expect(scheduler.delays[1]).toBe(INITIAL_BACKOFF_MS * 2);
expect(scheduler.delays.at(-1)).toBe(MAX_BACKOFF_MS);
});
it("resets the delay once a connection opens", () => {
const sockets = [new FakeSocket(), new FakeSocket(), new FakeSocket()];
const { store, scheduler } = storeWith(sockets);
store.getState().start();
sockets[0].drop();
scheduler.runNext();
expect(store.getState().backoff()).toBe(INITIAL_BACKOFF_MS * 2);
sockets[1].open();
expect(store.getState().backoff()).toBe(INITIAL_BACKOFF_MS);
});
it("stop() tears the socket down without scheduling a reconnect", () => {
const ws = new FakeSocket();
const { store, scheduler } = storeWith([ws]);
store.getState().start();
store.getState().stop();
ws.drop();
expect(ws.closed).toBe(true);
expect(scheduler.delays).toEqual([]);
});
});
describe("catalogStore connected state", () => {
it("reports the socket opening and dropping", () => {
const sock = new FakeSocket();
const store = createCatalogStore({
socket: () => sock as unknown as WebSocket,
scheduler: fakeScheduler(),
});
const seen: boolean[] = [];
store.subscribe((s) => seen.push(s.connected));
store.getState().start();
sock.open();
sock.drop();
expect(seen).toEqual([true, false]);
});
});
+188
View File
@@ -0,0 +1,188 @@
import { create } from "zustand";
import { eventsWsUrl, listSessions } from "../api.js";
import type { CatalogEvent, Session, Uuid } from "../types.js";
export interface CatalogState {
sessions: Map<Uuid, Session>;
/** The events socket's up/down state. Drives the daemon link (daemonLink.ts)
* — the socket dying is the first sign the daemon went down or was
* upgraded under the page. */
connected: boolean;
}
/** Opens the events socket. Injectable so the reducer and the backoff schedule
* can be driven without a server. */
export type SocketFactory = () => WebSocket;
/** Schedules a reconnect attempt; returns a handle `clearTimer` understands. */
export interface Scheduler {
setTimer(fn: () => void, ms: number): number;
clearTimer(handle: number): void;
}
const browserScheduler: Scheduler = {
setTimer: (fn, ms) => window.setTimeout(fn, ms),
clearTimer: (h) => window.clearTimeout(h),
};
/** Initial reconnect delay, doubling to `MAX_BACKOFF_MS`. */
export const INITIAL_BACKOFF_MS = 500;
export const MAX_BACKOFF_MS = 10_000;
export interface CatalogStoreOptions {
socket?: SocketFactory;
scheduler?: Scheduler;
/** Full-state refetch used when the server reports it dropped frames. */
refetch?: () => Promise<Session[]>;
}
export interface CatalogActions {
start(): void;
/** Replace a socket that may have gone stale while the browser was suspended. */
reconnect(): void;
stop(): void;
/** Current reconnect delay, for tests asserting the backoff schedule. */
backoff(): number;
}
export type CatalogStore = CatalogState & CatalogActions;
/** Builds an isolated catalog store — a socket/scheduler/refetch can be
* injected (tests), or defaulted to the real browser/daemon ones. The
* returned value is both a React hook (`useCatalog(selector)`) and a plain
* object (`useCatalog.getState()`, `useCatalog.subscribe(fn)`), so the
* reconnect/backoff machinery works the same whether or not React is
* driving it. */
export function createCatalogStore(opts: CatalogStoreOptions = {}) {
const socketFactory = opts.socket ?? (() => new WebSocket(eventsWsUrl()));
const scheduler = opts.scheduler ?? browserScheduler;
const refetch = opts.refetch ?? listSessions;
let ws: WebSocket | null = null;
let reconnectTimer: number | null = null;
let backoffMs = INITIAL_BACKOFF_MS;
return create<CatalogStore>((set, get) => {
function apply(event: CatalogEvent): void {
switch (event.type) {
case "Snapshot":
set({ sessions: new Map(event.sessions.map((s) => [s.id, s])) });
return;
case "SessionAdded":
case "SessionUpdated": {
const next = new Map(get().sessions);
next.set(event.session.id, event.session);
set({ sessions: next });
return;
}
case "SessionRemoved": {
const next = new Map(get().sessions);
next.delete(event.id);
set({ sessions: next });
return;
}
case "Lagged":
// Re-derive from REST to avoid showing stale state.
void resync();
return;
}
}
async function resync(): Promise<void> {
try {
const sessions = await refetch();
set({ sessions: new Map(sessions.map((s) => [s.id, s])) });
} catch {
// surface in UI on next reconnect cycle; nothing else useful to do
}
}
function connect(): void {
const socket = socketFactory();
ws = socket;
socket.onmessage = (ev) => {
let event: CatalogEvent;
try {
event = JSON.parse(ev.data) as CatalogEvent;
} catch {
return;
}
apply(event);
};
socket.onopen = () => {
backoffMs = INITIAL_BACKOFF_MS;
set({ connected: true });
};
socket.onclose = () => {
ws = null;
set({ connected: false });
scheduleReconnect();
};
socket.onerror = () => {
try {
socket.close();
} catch {
// ignore
}
};
}
function scheduleReconnect(): void {
if (reconnectTimer !== null) return;
const delay = Math.min(backoffMs, MAX_BACKOFF_MS);
reconnectTimer = scheduler.setTimer(() => {
reconnectTimer = null;
backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
connect();
}, delay);
}
return {
sessions: new Map(),
connected: false,
start() {
connect();
},
reconnect() {
if (reconnectTimer !== null) {
scheduler.clearTimer(reconnectTimer);
reconnectTimer = null;
}
if (ws) {
// A close event from the superseded socket must not schedule a
// second connection alongside the new one.
ws.onclose = null;
ws.onerror = null;
ws.close();
ws = null;
}
connect();
},
stop() {
if (reconnectTimer !== null) {
scheduler.clearTimer(reconnectTimer);
reconnectTimer = null;
}
if (ws) {
ws.onclose = null;
ws.close();
ws = null;
}
},
backoff() {
return backoffMs;
},
};
});
}
export type CatalogStoreApi = ReturnType<typeof createCatalogStore>;
+414
View File
@@ -0,0 +1,414 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { createConnectionStore } from "./connectionStore.js";
import { createCatalogStore } from "./catalogStore.js";
import type { Session } from "../types.js";
// The pane's own bytes-out path, stubbed: the escape hatch only needs to know
// the pool reaches the focused connection with them.
const sent: { id: string; data: string }[] = [];
const disposed: string[] = [];
const mounts: { id: string; focus: boolean | undefined }[] = [];
function fakeConn(kind: string, id: string) {
const el = document.createElement("div");
el.tabIndex = -1; // focusable via .focus(), like a real input/terminal
el.dataset.kind = kind;
el.dataset.session = id;
return {
sessionId: id,
kind,
el,
onMount(opts?: { focus?: boolean }) {
mounts.push({ id, focus: opts?.focus });
},
onUnmount() {},
focusTerminal() {},
sendInput(data: string) {
sent.push({ id, data });
},
updateInfo() {},
dispose() {
disposed.push(`${kind}:${id}`);
},
};
}
vi.mock("../attach.js", () => ({
createAttachConnection: (id: string) => fakeConn("attach", id),
}));
vi.mock("../structured.js", () => ({
createStructuredConnection: (id: string) => fakeConn("structured", id),
}));
afterEach(() => {
vi.unstubAllGlobals();
sent.length = 0;
disposed.length = 0;
mounts.length = 0;
});
/** Minimal fixture for the sessions map a catalog store broadcasts. */
function session(id: string, view?: "Terminal" | "Structured"): Session {
return {
id,
cwd: "/repo",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-01-01T00:00:00Z",
view,
};
}
/** A catalog store an id can be pushed into on demand, mirroring
* catalogStore.test.ts's own injectable-socket pattern. */
class FakeCatalogSocket {
onmessage: ((ev: { data: string }) => void) | null = null;
onopen: (() => void) | null = null;
onclose: (() => void) | null = null;
onerror: (() => void) | null = null;
close(): void {}
push(sessions: Session[]): void {
this.onmessage?.({ data: JSON.stringify({ type: "Snapshot", sessions }) });
}
}
function fakeCatalog() {
const socket = new FakeCatalogSocket();
const store = createCatalogStore({ socket: () => socket as unknown as WebSocket });
store.getState().start();
return { store, socket };
}
const A = "aaaaaaaa-0000-0000-0000-0000000000ff";
const B = "bbbbbbbb-0000-0000-0000-0000000000ff";
function mockFetch() {
const fetchMock = vi.fn(() => Promise.resolve(new Response(null, { status: 204 })));
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
describe("connectionStore setFocus", () => {
it("reports the new session focused, none unfocused, the first time", () => {
const fetchMock = mockFetch();
const pool = createConnectionStore();
pool.getState().setFocus(A);
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toBe(`/api/sessions/${A}/focus`);
expect(init.method).toBe("POST");
expect(JSON.parse(init.body as string)).toEqual({ focused: true });
expect(pool.getState().focusedId).toBe(A);
});
it("unfocuses the previous session and focuses the new one on a switch", () => {
const fetchMock = mockFetch();
const pool = createConnectionStore();
pool.getState().setFocus(A);
fetchMock.mockClear();
pool.getState().setFocus(B);
expect(fetchMock).toHaveBeenCalledTimes(2);
const bodies = fetchMock.mock.calls.map(([url, init]: unknown[]) => [
url,
JSON.parse((init as RequestInit).body as string),
]);
expect(bodies).toContainEqual([`/api/sessions/${A}/focus`, { focused: false }]);
expect(bodies).toContainEqual([`/api/sessions/${B}/focus`, { focused: true }]);
});
it("reports unfocused when focus clears to null", () => {
const fetchMock = mockFetch();
const pool = createConnectionStore();
pool.getState().setFocus(A);
fetchMock.mockClear();
pool.getState().setFocus(null);
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toBe(`/api/sessions/${A}/focus`);
expect(JSON.parse(init.body as string)).toEqual({ focused: false });
});
it("is a no-op when the focused session doesn't change", () => {
const fetchMock = mockFetch();
const pool = createConnectionStore();
pool.getState().setFocus(A);
fetchMock.mockClear();
pool.getState().setFocus(A);
expect(fetchMock).not.toHaveBeenCalled();
});
it("does not let a fetch rejection break the pool", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const pool = createConnectionStore();
expect(() => pool.getState().setFocus(A)).not.toThrow();
// Let the rejected promise's `.catch` settle before the test ends.
await Promise.resolve();
await Promise.resolve();
});
});
describe("connectionStore sendToFocused", () => {
it("types the bytes into the focused session's PTY", () => {
mockFetch();
sent.length = 0;
const pool = createConnectionStore();
const pane = document.createElement("div");
pool.getState().mount(A, pane);
pool.getState().setFocus(A);
expect(pool.getState().sendToFocused("\x0b")).toBe(true);
expect(sent).toEqual([{ id: A, data: "\x0b" }]);
});
it("is a no-op with nothing focused", () => {
mockFetch();
sent.length = 0;
const pool = createConnectionStore();
expect(pool.getState().sendToFocused("\x0b")).toBe(false);
expect(sent).toEqual([]);
});
});
describe("connectionStore mount/unmount", () => {
it("tracks mounted and LRU order reactively", () => {
mockFetch();
const pool = createConnectionStore();
const paneA = document.createElement("div");
const paneB = document.createElement("div");
pool.getState().mount(A, paneA);
pool.getState().mount(B, paneB);
expect(pool.getState().cacheOrder).toEqual([A, B]);
expect([...pool.getState().mounted]).toEqual([A, B]);
pool.getState().unmount(A);
expect([...pool.getState().mounted]).toEqual([B]);
// Parked, not evicted — still in LRU order.
expect(pool.getState().cacheOrder).toEqual([A, B]);
pool.getState().close(A);
expect(pool.getState().cacheOrder).toEqual([B]);
expect(disposed).toEqual(["attach:" + A]);
});
it("evicts the least-recently-viewed parked connection beyond MAX_PARKED", () => {
mockFetch();
const pool = createConnectionStore();
const ids = Array.from({ length: 8 }, (_, i) => `id-${i}`);
for (const id of ids) {
pool.getState().mount(id, document.createElement("div"));
pool.getState().unmount(id); // park immediately
}
// Each mount only has itself visible (the rest already parked), so the
// cap-crossing eviction fires once, on the mount that pushes the parked
// count from 6 to 7: the single oldest (id-0) is dropped.
expect(disposed).toEqual(["attach:id-0"]);
expect(pool.getState().cacheOrder).toEqual(ids.slice(1));
});
});
// A deep link or restored layout can `mount()` a session before the catalog
// snapshot names its view — the daemon defaults an absent `view` to
// `"Terminal"`, but so does an *unknown* session, and only one of those is
// actually resolved (#140 review).
describe("connectionStore: view resolution races", () => {
it("replaces a wrongly-typed connection once the catalog resolves it, without waiting for another mount()", () => {
mockFetch();
const { store, socket } = fakeCatalog();
const pool = createConnectionStore({ store });
const pane = document.createElement("div");
pool.getState().mount(A, pane); // no info: deep-link-before-snapshot race
expect(disposed).toEqual([]);
socket.push([session(A, "Structured")]);
expect(disposed).toEqual(["attach:" + A]);
// The fresh connection landed in the same pane nothing re-mounted it into.
expect(pane.children).toHaveLength(1);
});
it("also repairs a wrongly-typed connection that is parked, not mounted, without evicting its other parked siblings", () => {
mockFetch();
const { store, socket } = fakeCatalog();
const pool = createConnectionStore({ store });
const hidden = document.body.lastElementChild as HTMLElement; // this pool's own cache
pool.getState().mount(A, document.createElement("div"));
pool.getState().mount(B, document.createElement("div"));
pool.getState().unmount(A);
pool.getState().unmount(B);
disposed.length = 0;
socket.push([session(A, "Structured")]);
expect(disposed).toEqual(["attach:" + A]);
// B's own element is still parked — A's repair didn't wipe its siblings
// out of the shared hidden cache.
expect(hidden.querySelector(`[data-session="${B}"]`)).not.toBeNull();
expect((hidden.querySelector(`[data-session="${A}"]`) as HTMLElement | null)?.dataset.kind).toBe("structured");
});
it("does not replace a connection whose resolved view already matches", () => {
mockFetch();
const { store, socket } = fakeCatalog();
const pool = createConnectionStore({ store });
const pane = document.createElement("div");
pool.getState().mount(A, pane, { name: "a", command: "claude", view: "Structured" });
disposed.length = 0;
socket.push([session(A, "Structured")]);
expect(disposed).toEqual([]);
});
it("does not downgrade an already-resolved connection when a later mount() call has no info", () => {
mockFetch();
const pool = createConnectionStore();
const pane = document.createElement("div");
pool.getState().mount(A, pane, { name: "a", command: "claude", view: "Structured" });
disposed.length = 0;
pool.getState().mount(A, pane); // e.g. a re-render before the catalog answers again
expect(disposed).toEqual([]);
});
it("does not treat a session missing from the catalog as Terminal and churn a live connection", () => {
mockFetch();
const { store, socket } = fakeCatalog();
const pool = createConnectionStore({ store });
const pane = document.createElement("div");
pool.getState().mount(A, pane, { name: "a", command: "claude", view: "Structured" });
disposed.length = 0;
// `A` is absent from this snapshot — the embedder's own subscription
// handles closing the pane; the pool must not race it by "fixing" the
// view first.
socket.push([session(B, "Terminal")]);
expect(disposed).toEqual([]);
});
it("re-focuses the fresh connection when the keyboard was actually inside the replaced pane", () => {
mockFetch();
const { store, socket } = fakeCatalog();
const pool = createConnectionStore({ store });
const pane = document.createElement("div");
document.body.appendChild(pane);
pool.getState().mount(A, pane);
pool.getState().setFocus(A);
(pane.firstElementChild as HTMLElement).focus();
expect(document.activeElement).toBe(pane.firstElementChild);
mounts.length = 0;
socket.push([session(A, "Structured")]);
expect(disposed).toEqual(["attach:" + A]);
expect(mounts).toEqual([{ id: A, focus: true }]);
pane.remove();
});
it("does not steal focus from a same-tab sidebar preview even though focusedId still names the previous pane", () => {
mockFetch();
const { store, socket } = fakeCatalog();
const pool = createConnectionStore({ store });
const pane = document.createElement("div");
document.body.appendChild(pane);
pool.getState().mount(A, pane);
pool.getState().setFocus(A);
(pane.firstElementChild as HTMLElement).focus();
const sidebarInput = document.createElement("input");
document.body.appendChild(sidebarInput);
sidebarInput.focus();
expect(document.activeElement).toBe(sidebarInput);
mounts.length = 0;
socket.push([session(A, "Structured")]);
expect(disposed).toEqual(["attach:" + A]);
expect(mounts).toEqual([{ id: A, focus: false }]);
pane.remove();
sidebarInput.remove();
});
it("does not focus a parked (unmounted) connection it repairs", () => {
mockFetch();
const { store, socket } = fakeCatalog();
const pool = createConnectionStore({ store });
const pane = document.createElement("div");
pool.getState().mount(A, pane);
pool.getState().setFocus(A);
pool.getState().unmount(A); // parked — no longer visible, `focusedId` still names it
mounts.length = 0;
socket.push([session(A, "Structured")]);
expect(disposed).toEqual(["attach:" + A]);
expect(mounts).toEqual([]); // parked repair calls onUnmount, never onMount
});
});
describe("connectionStore setVisible/activate/parkActive", () => {
it("parks sessions not in the next visible set and mounts the rest", () => {
mockFetch();
const pool = createConnectionStore();
const paneA = document.createElement("div");
const paneB = document.createElement("div");
pool.getState().setVisible([
{ id: A, pane: paneA },
{ id: B, pane: paneB },
]);
expect([...pool.getState().mounted].sort()).toEqual([A, B]);
pool.getState().setVisible([{ id: B, pane: paneB }]);
expect([...pool.getState().mounted]).toEqual([B]);
});
it("activate mounts+focuses alone, parkActive parks all and clears focus", () => {
mockFetch();
const pool = createConnectionStore();
const pane = document.createElement("div");
pool.getState().activate(A, pane);
expect(pool.getState().focusedId).toBe(A);
expect(pool.getState().isMounted(A)).toBe(true);
pool.getState().parkActive();
expect(pool.getState().focusedId).toBeNull();
expect(pool.getState().isMounted(A)).toBe(false);
});
});
describe("connectionStore dispose", () => {
it("disposes every cached connection and resets state", () => {
mockFetch();
const pool = createConnectionStore();
pool.getState().mount(A, document.createElement("div"));
pool.getState().mount(B, document.createElement("div"));
disposed.length = 0;
pool.getState().dispose();
expect(disposed.sort()).toEqual(["attach:" + A, "attach:" + B]);
expect(pool.getState().mounted.size).toBe(0);
expect(pool.getState().cacheOrder).toEqual([]);
expect(pool.getState().focusedId).toBeNull();
});
});
+360
View File
@@ -0,0 +1,360 @@
import { create } from "zustand";
import { createAttachConnection } from "../attach.js";
import type { AttachInfo } from "../paneTitle.js";
import type { PaneConnection } from "../pane.js";
import { createStructuredConnection } from "../structured.js";
import { setSessionFocus } from "../api.js";
import { sessionName, type Session, type SessionView, type Uuid } from "../types.js";
import type { CatalogStoreApi } from "./catalogStore.js";
/**
* How many *parked* (off-screen but still-connected) sessions to keep warm
* beyond whatever is currently visible. Visible panes are never counted or
* evicted — only this background buffer is bounded, so the browser doesn't hold
* an unbounded fan of open sockets. Switching back to a recently-viewed session
* is instant while it's still parked; older ones are evicted (WS closed) once
* the buffer overflows.
*/
const MAX_PARKED = 6;
/** A session's view defaults to `"Terminal"` wherever it isn't carried —
* matching the wire default (`SessionView`'s `#[default]`, types.ts). Only
* meaningful once the session itself is known; an altogether-unknown
* session (no `AttachInfo`/`Session` at all) is a distinct, un-resolved
* state callers must check for separately, not "Terminal". */
function resolveView(view: SessionView | undefined): SessionView {
return view ?? "Terminal";
}
export interface PoolOptions {
/** Fired when the focused session changes (drives sidebar highlight + HUD). */
onFocusChange?: (id: Uuid | null) => void;
/** Fired when a view's explicit disconnect control is used; the embedder
* decides how to update the layout/route (then typically calls `close`). */
onRequestClose?: (id: Uuid) => void;
/** Fired when a connection's socket closes on its own (session ended/dropped),
* not via an intentional close/eviction. The embedder closes the pane. */
onClosed?: (id: Uuid) => void;
/** Forwarded to each connection: should a dropped socket be retried? */
shouldReconnect?: (id: Uuid, code: number) => boolean;
/** Threaded through to a structured connection so its rail can read live
* worktree/git-status data (#140). Terminal connections don't need it. */
store?: CatalogStoreApi;
}
export interface ConnectionState {
/** Sessions currently mounted into a visible pane. */
mounted: Set<Uuid>;
/** Every live (mounted or parked) connection, oldest first — LRU order. */
cacheOrder: Uuid[];
/** The focused session, or null when nothing is focused (welcome pane). */
focusedId: Uuid | null;
}
export interface ConnectionActions {
isMounted(id: Uuid): boolean;
/**
* Ensure `id` is live and shown inside `pane`, reusing its connection if one
* exists (refreshing LRU recency). Does not touch other mounted panes.
*/
mount(id: Uuid, pane: HTMLElement, info?: AttachInfo, opts?: { focus?: boolean }): void;
/** Park `id` back into the hidden cache (still connected) and stop showing it. */
unmount(id: Uuid): void;
/**
* Reconcile the visible set to exactly `entries` (in render order): park any
* mounted session not listed, mount/refresh each listed one in its pane.
* `focus: false` shows the panes without taking the keyboard (preview).
*/
setVisible(entries: { id: Uuid; pane: HTMLElement; info?: AttachInfo }[], opts?: { focus?: boolean }): void;
/** Also tells the daemon which pane is focused (docs/SIDEBAR.md's
* `AwaitingInput` auto-clear) — the sidebar highlight never calls this,
* only a pane actually becoming the visible view. Best-effort: a failed
* report only delays the glow clearing. */
setFocus(id: Uuid | null): void;
/** Compat shim: show `id` alone in `pane` and focus it; park everything else. */
activate(id: Uuid, pane: HTMLElement, info?: AttachInfo): void;
/** Compat shim: park all visible panes and clear focus (e.g. welcome pane). */
parkActive(): void;
/** Put the keyboard back into the focused session's terminal. No-op when
* nothing is focused (welcome pane) or its connection is gone. */
focusTerminal(): void;
/** Type raw bytes into the focused session's PTY. Returns false when there is
* nothing focused, or its connection is gone — the leader's literal Ctrl-K
* is a no-op over the welcome pane rather than an error
* (docs/COMMAND-BAR.md). */
sendToFocused(data: string): boolean;
/** Fully disconnect + forget a session. */
close(id: Uuid): void;
dispose(): void;
}
export type ConnectionStore = ConnectionState & ConnectionActions;
/**
* Builds an isolated connection store — owns the set of live attach
* connections, decoupled from the router/UI. The returned value is both a
* React hook and a plain object (`getState`/`subscribe`), same shape as
* {@link createCatalogStore}.
*
* Unlike a single-active pool, several connections can be **mounted**
* (visible) at once — one per leaf of the active tab's layout tree. The
* caller reconciles the visible set with {@link ConnectionActions.setVisible}.
* Sessions no longer shown are **parked** in a hidden cache (still streaming)
* and bounded by an LRU. A connection only dies on eviction, an explicit
* {@link ConnectionActions.close}, or {@link ConnectionActions.dispose}.
*/
export function createConnectionStore(opts: PoolOptions = {}) {
/** All live connections. Insertion order doubles as LRU: oldest first. */
const cache = new Map<Uuid, PaneConnection>();
/** The view each cached connection was actually created for — a deep link
* or a restored layout can `mount()` a session before the catalog
* snapshot names its view, defaulting it to `"Terminal"`; this is what
* lets a later reconciliation notice the resolved view disagrees (#140
* review). */
const connViews = new Map<Uuid, SessionView>();
// Off-screen home for parked, still-connected terminals.
const hidden = document.createElement("div");
hidden.className = "conn-cache";
hidden.setAttribute("aria-hidden", "true");
document.body.appendChild(hidden);
let unsubscribeStore: (() => void) | undefined;
const store = create<ConnectionStore>((set, get) => {
function createConn(id: Uuid, info?: AttachInfo): PaneConnection {
const view = resolveView(info?.view);
connViews.set(id, view);
const connOpts = {
onRequestClose: (sid: Uuid) => opts.onRequestClose?.(sid),
onClosed: (sid: Uuid) => opts.onClosed?.(sid),
shouldReconnect: (sid: Uuid, code: number) => opts.shouldReconnect?.(sid, code) ?? false,
};
// structured.ts's own `store` param still expects the pre-Zustand
// `CatalogStore` class (events.ts) and doesn't use it yet (#140) — left
// unpassed here until that rail wiring is ported too.
return view === "Structured"
? createStructuredConnection(id, connOpts, info)
: createAttachConnection(id, connOpts, info);
}
/** Dispose whatever is cached for `id` and cache a freshly created
* connection in its place. Does not touch the DOM, `mounted`, or LRU
* order — callers place the new element themselves. */
function replace(id: Uuid, info?: AttachInfo): PaneConnection {
cache.get(id)?.dispose();
const fresh = createConn(id, info);
cache.set(id, fresh);
return fresh;
}
function insertOrder(id: Uuid): void {
set((s) => ({ cacheOrder: [...s.cacheOrder, id] }));
}
function touchOrder(id: Uuid): void {
set((s) => ({ cacheOrder: [...s.cacheOrder.filter((x) => x !== id), id] }));
}
function removeOrder(id: Uuid): void {
set((s) => ({ cacheOrder: s.cacheOrder.filter((x) => x !== id) }));
}
function setMounted(id: Uuid, isMounted: boolean): void {
set((s) => {
if (s.mounted.has(id) === isMounted) return {};
const next = new Set(s.mounted);
if (isMounted) next.add(id);
else next.delete(id);
return { mounted: next };
});
}
function setFocus(id: Uuid | null): void {
const previous = get().focusedId;
if (id === previous) return;
set({ focusedId: id });
if (previous !== null) void setSessionFocus(previous, false).catch(() => {});
if (id !== null) void setSessionFocus(id, true).catch(() => {});
opts.onFocusChange?.(id);
}
function close(id: Uuid): void {
const conn = cache.get(id);
if (!conn) return;
conn.dispose();
cache.delete(id);
connViews.delete(id);
removeOrder(id);
setMounted(id, false);
if (get().focusedId === id) setFocus(null);
}
/** Drop least-recently-viewed *parked* connections beyond the buffer cap. */
function evict(): void {
while (cache.size - get().mounted.size > MAX_PARKED) {
const mounted = get().mounted;
let victim: Uuid | null = null;
for (const id of cache.keys()) {
if (!mounted.has(id)) {
victim = id;
break;
}
}
if (victim === null) break;
close(victim);
}
}
function mount(id: Uuid, pane: HTMLElement, info?: AttachInfo, mountOpts?: { focus?: boolean }): void {
let conn = cache.get(id);
if (conn) {
// `info === undefined` means "session not known yet", not "Terminal" —
// never downgrade a resolved connection just because a later caller
// couldn't resolve the session. Only a *resolved* view that disagrees
// with what this connection was built for forces a replace.
if (info !== undefined && resolveView(info.view) !== connViews.get(id)) {
conn = replace(id, info);
} else {
touchOrder(id);
if (info) conn.updateInfo(info);
}
} else {
conn = createConn(id, info);
cache.set(id, conn);
insertOrder(id);
}
if (conn.el.parentElement !== pane) {
pane.replaceChildren(conn.el);
conn.onMount(mountOpts);
} else if (!get().mounted.has(id)) {
conn.onMount(mountOpts);
}
setMounted(id, true);
evict();
}
function unmount(id: Uuid): void {
if (!get().mounted.has(id)) return;
setMounted(id, false);
const conn = cache.get(id);
if (conn) {
hidden.appendChild(conn.el); // stays alive, just off-screen
conn.onUnmount();
}
}
function setVisible(
entries: { id: Uuid; pane: HTMLElement; info?: AttachInfo }[],
visOpts?: { focus?: boolean },
): void {
const next = new Set(entries.map((e) => e.id));
for (const id of [...get().mounted]) {
if (!next.has(id)) unmount(id);
}
for (const e of entries) mount(e.id, e.pane, e.info, visOpts);
}
/** Proactively fix any cached connection whose view no longer matches the
* catalog — the only path that repairs a pane nothing will `mount()`
* again on its own (parked, or mounted but not currently being
* re-rendered by anything else). A session absent from `sessions` (not
* yet known, or removed) is left alone — that's the embedder's own
* close/eviction concern, not a view mismatch. */
function reconcileViews(sessions: Map<Uuid, Session>): void {
for (const id of [...cache.keys()]) {
const session = sessions.get(id);
if (session === undefined || resolveView(session.view) === connViews.get(id)) continue;
const existing = cache.get(id)!;
const parent = existing.el.parentElement;
const wasMounted = get().mounted.has(id);
// Capture before anything below touches the DOM: dispose()/el.remove()
// detach `existing.el`, and once a focused descendant is detached the
// browser moves `document.activeElement` to `<body>` — too late to
// tell whether the keyboard was actually in this pane.
const hadDomFocus = existing.el.contains(document.activeElement);
const info: AttachInfo = { name: sessionName(session), command: session.command, view: session.view };
const fresh = replace(id, info);
existing.el.remove(); // don't rely on dispose() to clear the DOM
if (parent && parent !== hidden) {
parent.replaceChildren(fresh.el);
} else {
// Parked: `hidden` holds every parked connection's element as
// siblings — append, never replaceChildren, or they'd all vanish.
hidden.appendChild(fresh.el);
}
if (wasMounted) {
// `focusedId` names the session the pool was last told to focus —
// but a same-tab sidebar preview moves the keyboard into the
// sidebar without clearing it, so `focusedId` alone can point at a
// pane the keyboard already left. Only re-focus when the DOM
// agrees it never did.
fresh.onMount({ focus: get().focusedId === id && hadDomFocus });
} else {
fresh.onUnmount();
}
}
}
// A session `mount()`ed before its catalog entry exists gets a
// `"Terminal"` connection by default; once the snapshot actually names
// the view, fix any connection that guessed wrong — nothing else
// re-`mount()`s an already-open pane just because the catalog updated.
unsubscribeStore = opts.store?.subscribe((state) => reconcileViews(state.sessions));
return {
mounted: new Set<Uuid>(),
cacheOrder: [],
focusedId: null,
isMounted(id) {
return get().mounted.has(id);
},
mount,
unmount,
setVisible,
setFocus,
activate(id, pane, info) {
setVisible([{ id, pane, info }]);
setFocus(id);
},
parkActive() {
for (const id of [...get().mounted]) unmount(id);
setFocus(null);
},
focusTerminal() {
const id = get().focusedId;
if (id === null) return;
cache.get(id)?.focusTerminal();
},
sendToFocused(data) {
const id = get().focusedId;
if (id === null) return false;
const conn = cache.get(id);
if (!conn) return false;
conn.sendInput(data);
return true;
},
close,
dispose() {
unsubscribeStore?.();
for (const conn of cache.values()) conn.dispose();
cache.clear();
connViews.clear();
set({ mounted: new Set(), cacheOrder: [], focusedId: null });
hidden.remove();
},
};
});
return store;
}
export type ConnectionStoreApi = ReturnType<typeof createConnectionStore>;
+199 -16
View File
@@ -134,7 +134,7 @@ describe("structured connection: data flow", () => {
} });
input.dispatchEvent(paste);
conn.el.querySelector("form")!.dispatchEvent(new Event("submit", { cancelable: true }));
await vi.waitFor(() => expect(input.disabled).toBe(false));
await vi.waitFor(() => expect(conn.el.textContent).toContain("Upload failed"));
expect(input.value).toBe("Read this");
expect(sendStructuredPrompt).not.toHaveBeenCalled();
const remove = conn.el.querySelector<HTMLButtonElement>('[aria-label="Remove notes.txt"]')!;
@@ -413,6 +413,95 @@ describe("structured connection: data flow", () => {
conn.dispose();
});
it("absorbs intermediate agent text into tool group, keeps final text outside", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 8,
rows: [
row("user:1", "user", { content: { type: "text", text: "do it" } }),
row("agent:1", "agent_text", { content: { type: "text", text: "Let me check" } }),
toolRow("tool:1", "one"),
toolRow("tool:2", "two"),
row("agent:2", "agent_text", { content: { type: "text", text: "Found issues, fixing" } }),
toolRow("tool:3", "three"),
toolRow("tool:4", "four"),
row("agent:3", "agent_text", { content: { type: "text", text: "All done" } }),
],
},
});
const groups = conn.el.querySelectorAll<HTMLElement>(".st-tool-group");
expect(groups).toHaveLength(1);
// Header counts only tool rows, not intermediate text.
expect(groups[0].querySelector(".st-tool-group-head")!.textContent).toContain("4 tool calls");
// Intermediate text (agent:2) is inside the group.
expect(conn.el.querySelector('[data-row-id="agent:2"]')?.closest(".st-tool-group")).not.toBeNull();
// First text (agent:1) is outside — it precedes all tools.
expect(conn.el.querySelector('[data-row-id="agent:1"]')?.closest(".st-tool-group")).toBeNull();
// Final text (agent:3) is outside — no tool follows it.
expect(conn.el.querySelector('[data-row-id="agent:3"]')?.closest(".st-tool-group")).toBeNull();
conn.dispose();
});
it("keeps last intermediate text outside group while turn is active", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
const respondingState: ReducedState = {
turn: { responding: true, thinking: false, active_tool_calls: [] },
};
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 6,
rows: [
row("user:1", "user", { content: { type: "text", text: "do it" } }),
toolRow("tool:1", "one"),
row("agent:1", "agent_text", { content: { type: "text", text: "Now fixing" } }),
toolRow("tool:2", "two"),
row("agent:2", "agent_text", { content: { type: "text", text: "Found more" } }),
toolRow("tool:3", "three"),
],
},
});
ws.emit({ type: "reduced_state", state: respondingState });
// Intermediate updates stay inside the collapsed activity disclosure,
// rather than becoming a second visible transcript alongside its summary.
expect(conn.el.querySelector('[data-row-id="agent:2"]')?.closest(".st-tool-group")).not.toBeNull();
expect(conn.el.querySelector('[data-row-id="agent:1"]')?.closest(".st-tool-group")).not.toBeNull();
// It remains collapsed when the turn ends.
ws.emit({ type: "reduced_state", state: idleState });
expect(conn.el.querySelector('[data-row-id="agent:2"]')?.closest(".st-tool-group")).not.toBeNull();
expect(conn.el.querySelector('[data-row-id="agent:1"]')?.closest(".st-tool-group")).not.toBeNull();
conn.dispose();
});
it("collapses trailing thought commentary with a turn's tool activity", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 5,
rows: [
row("user:1", "user", { content: { type: "text", text: "do it" } }),
toolRow("tool:1", "one"),
toolRow("tool:2", "two"),
row("thought:1", "agent_thought", { content: { type: "text", text: "Checking the next step" } }),
row("thought:2", "agent_thought", { content: { type: "text", text: "Preparing the change" } }),
],
},
});
for (const id of ["thought:1", "thought:2"]) {
expect(conn.el.querySelector(`[data-row-id="${id}"]`)?.closest(".st-tool-group")).not.toBeNull();
}
conn.dispose();
});
it("keeps an expanded running tool open without emitting a click when patched", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
@@ -660,7 +749,7 @@ describe("structured connection: turn/composer gating", () => {
conn.dispose();
});
it("disables the composer while a turn is active and re-enables when idle", () => {
it("keeps the composer available while a turn is active", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
@@ -668,13 +757,13 @@ describe("structured connection: turn/composer gating", () => {
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
const cancel = conn.el.querySelector(".structured-cancel") as HTMLButtonElement;
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
expect(input.disabled).toBe(true);
expect(input.disabled).toBe(false);
expect(cancel.hidden).toBe(false);
expect((conn.el.querySelector(".structured-send") as HTMLButtonElement).hidden).toBe(true);
expect((conn.el.querySelector(".structured-send span") as HTMLElement).textContent).toBe("Send");
ws.emit({ type: "reduced_state", state: idleState });
expect(input.disabled).toBe(false);
expect(cancel.hidden).toBe(true);
expect((conn.el.querySelector(".structured-send") as HTMLButtonElement).hidden).toBe(false);
expect((conn.el.querySelector(".structured-send span") as HTMLElement).textContent).toBe("Send");
conn.dispose();
});
@@ -737,7 +826,7 @@ describe("structured connection: turn/composer gating", () => {
conn.dispose();
});
it("disables immediately and dispatches only once before begin-turn is projected", () => {
it("queues a second message before begin-turn is projected", () => {
vi.mocked(sendStructuredPrompt).mockImplementationOnce(() => new Promise(() => {}));
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
@@ -750,12 +839,45 @@ describe("structured connection: turn/composer gating", () => {
input.value = "only once";
form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
input.value = "queued next";
form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
expect(input.disabled).toBe(true);
expect(send.disabled).toBe(true);
expect(input.disabled).toBe(false);
expect(send.disabled).toBe(false);
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "only once");
const queued = conn.el.querySelectorAll(".st-row-queued");
expect(queued).toHaveLength(1);
expect(queued[0].textContent).toBe("queued next");
conn.dispose();
});
it("dispatches queued messages in order as turns become idle", async () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
const submit = (text: string) => {
input.value = text;
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
};
submit("first");
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
submit("second");
submit("third");
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
expect(conn.el.querySelectorAll(".st-row-queued")).toHaveLength(2);
await Promise.resolve().then(() => Promise.resolve());
ws.emit({ type: "reduced_state", state: idleState });
expect(sendStructuredPrompt).toHaveBeenNthCalledWith(2, "s1", "second");
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
await Promise.resolve().then(() => Promise.resolve());
ws.emit({ type: "reduced_state", state: idleState });
expect(sendStructuredPrompt).toHaveBeenNthCalledWith(3, "s1", "third");
expect(conn.el.querySelectorAll(".st-row-queued")).toHaveLength(0);
conn.dispose();
});
@@ -807,7 +929,7 @@ describe("structured connection: turn/composer gating", () => {
expect(sendStructuredPrompt).toHaveBeenCalledTimes(2);
expect(input.value).toBe("");
expect(input.disabled).toBe(true);
expect(input.disabled).toBe(false);
expect(conn.el.textContent).not.toContain("stale failure");
conn.dispose();
});
@@ -869,7 +991,7 @@ describe("structured connection: approvals", () => {
conn.dispose();
});
it("removes the buttons once the approval resolves, even with no accompanying transcript delta", () => {
it("hides a standalone resolved approval entirely when no tool group absorbs it", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
@@ -885,7 +1007,68 @@ describe("structured connection: approvals", () => {
// Resolved on another client: state clears with no transcript delta at all.
ws.emit({ type: "reduced_state", state: idleState });
expect(conn.el.querySelector(".st-approval-acts")).toBeNull();
expect(conn.el.querySelector(".st-approval-resolved")).not.toBeNull();
// Standalone resolved approval is hidden — no card, no compact line.
expect(conn.el.querySelector(".st-row-approval")).toBeNull();
conn.dispose();
});
it("absorbs a resolved approval into the tool group as a compact line", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 0,
rows: [
row("user:1", "user", { content: { text: "go" } }),
toolRow("tool:1", "first"),
row("approval:1", "approval", { toolCall: { title: "Write file.ts" }, options: allApprovalOptions }),
toolRow("tool:2", "second"),
],
},
});
ws.emit({ type: "reduced_state", state: idleState });
// 2+ tools in the turn → tool group exists; resolved approval is inside it.
const group = conn.el.querySelector(".st-tool-group");
expect(group).not.toBeNull();
const compact = group!.querySelector(".st-approval-compact");
expect(compact).not.toBeNull();
expect(compact!.textContent).toContain("Write file.ts");
expect(compact!.textContent).toContain("approved");
// No full-card approval anywhere on the transcript.
expect(conn.el.querySelector(".st-approval")).toBeNull();
conn.dispose();
});
it("keeps a pending approval standalone even when a tool group exists in the same turn", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 0,
rows: [
row("user:1", "user", { content: { text: "go" } }),
toolRow("tool:1", "first"),
row("approval:1", "approval", { toolCall: { title: "Write file.ts" }, options: allApprovalOptions }),
toolRow("tool:2", "second"),
],
},
});
ws.emit({
type: "reduced_state",
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
});
// Pending approval stays outside the tool group as a full card.
const approvalCard = conn.el.querySelector(".st-approval");
expect(approvalCard).not.toBeNull();
expect(conn.el.querySelectorAll(".st-approval-acts button").length).toBeGreaterThan(0);
// It should not be inside the tool group.
const group = conn.el.querySelector(".st-tool-group");
expect(group).not.toBeNull();
expect(group!.querySelector(".st-approval")).toBeNull();
conn.dispose();
});
@@ -1126,7 +1309,7 @@ describe("structured connection: reconnection/error/loading", () => {
second.open();
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
second.emit({ type: "reduced_state", state: idleState });
expect(input.disabled).toBe(true);
expect(input.disabled).toBe(false);
second.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
second.emit({ type: "reduced_state", state: idleState });
@@ -1178,12 +1361,12 @@ describe("structured connection: reconnection/error/loading", () => {
second.open();
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
second.emit({ type: "reduced_state", state: idleState });
expect(input.disabled).toBe(true);
expect(input.disabled).toBe(false);
accept();
await Promise.resolve().then(() => Promise.resolve());
expect(input.disabled).toBe(true);
expect(input.disabled).toBe(false);
expect(second.closed).toBe(true);
third.open();
@@ -1584,7 +1767,7 @@ describe("structured connection: pane lifecycle", () => {
conn.dispose();
});
it("focusTerminal focuses the composer only when it is enabled", () => {
it("focusTerminal keeps the composer reachable during an active turn", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
@@ -1592,7 +1775,7 @@ describe("structured connection: pane lifecycle", () => {
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
conn.focusTerminal();
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
expect(document.activeElement).not.toBe(input);
expect(document.activeElement).toBe(input);
ws.emit({ type: "reduced_state", state: idleState });
conn.focusTerminal();
expect(document.activeElement).toBe(input);
+260 -55
View File
@@ -85,10 +85,10 @@ export function createStructuredConnection(
<div class="structured-main">
<div class="structured-scroll">
<div class="structured-rows"></div>
</div>
<div class="structured-activity" role="status" aria-live="polite" hidden>
<span class="structured-activity-pulse" aria-hidden="true"></span>
<span class="structured-activity-text">Thinking</span>
<div class="structured-activity" role="status" aria-live="polite" hidden>
<span class="structured-activity-pulse" aria-hidden="true"></span>
<span class="structured-activity-text">Thinking</span>
</div>
</div>
<form class="structured-composer">
<button type="button" class="btn btn-ghost btn-icon structured-attach" aria-label="Attach files" title="Attach files (up to 20 MiB each)">
@@ -113,7 +113,10 @@ export function createStructuredConnection(
<button type="submit" class="btn btn-primary structured-send"><span>Send</span>
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m7 5 5 5-5 5M12 10H3"/></svg>
</button>
<button type="button" class="btn structured-cancel" hidden>Cancel turn</button>
<button type="button" class="btn btn-icon structured-cancel" hidden aria-label="Cancel turn" title="Cancel turn">
<svg class="structured-cancel-spinner" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="50 14" stroke-linecap="round"/></svg>
<svg class="structured-cancel-stop" viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor"/></svg>
</button>
</form>
</div>
</div>
@@ -133,11 +136,13 @@ export function createStructuredConnection(
const commandMenu = el.querySelector(".structured-command-menu") as HTMLDivElement;
const input = el.querySelector(".structured-input") as HTMLTextAreaElement;
const sendBtn = el.querySelector(".structured-send") as HTMLButtonElement;
const sendLabel = sendBtn.querySelector("span") as HTMLSpanElement;
const cancelBtn = el.querySelector(".structured-cancel") as HTMLButtonElement;
const attachBtn = el.querySelector(".structured-attach") as HTMLButtonElement;
const fileInput = el.querySelector(".structured-file-input") as HTMLInputElement;
const attachmentEl = el.querySelector(".structured-attachments") as HTMLDivElement;
type Attachment = { file: File; path?: string };
type QueuedPrompt = { text: string; attachments: Attachment[] };
let attachments: Attachment[] = [];
let uploading = false;
let pasteCount = 0;
@@ -155,7 +160,7 @@ export function createStructuredConnection(
remove.type = "button";
remove.textContent = "×";
remove.setAttribute("aria-label", `Remove ${attachment.file.name}`);
remove.disabled = input.disabled;
remove.disabled = uploading;
remove.addEventListener("click", () => {
attachments = attachments.filter((item) => item !== attachment);
renderAttachments();
@@ -166,7 +171,7 @@ export function createStructuredConnection(
}
function addFiles(files: File[]): void {
if (input.disabled || disposed) return;
if (uploading || disposed) return;
for (const file of files) {
if (file.size > 20 * 1024 * 1024) {
showError(`${file.name} exceeds 20 MiB. Choose a smaller file.`);
@@ -193,7 +198,7 @@ export function createStructuredConnection(
});
input.addEventListener("paste", (event) => {
const clipboard = event.clipboardData;
if (!clipboard || input.disabled) return;
if (!clipboard || uploading) return;
if (clipboard.files.length) {
event.preventDefault();
addFiles(Array.from(clipboard.files));
@@ -244,6 +249,10 @@ export function createStructuredConnection(
let disposed = false;
let optimisticTurnStartedAt: number | null = null;
let activityTimer: number | null = null;
const promptQueue: QueuedPrompt[] = [];
let promptRequestPending = false;
/** Server path → object URL for uploaded images, so user bubbles can show previews. */
const imageObjectUrls = new Map<string, string>();
// Within 64px, wheel and touch imprecision should still count as following.
const FOLLOW_BOTTOM_THRESHOLD_PX = 64;
@@ -374,6 +383,9 @@ export function createStructuredConnection(
}
}
/** Pattern matching `Attached file "name": "path"` references appended by dispatchPrompt. */
const attachedFileRe = /Attached file "(?:[^"\\]|\\.)*": "((?:[^"\\]|\\.)*)"/g;
function buildRowElement(row: TranscriptRow): HTMLElement | null {
const el = buildTranscriptRow(
row,
@@ -390,20 +402,102 @@ export function createStructuredConnection(
if (el.querySelector(".st-tool-head")?.getAttribute("aria-expanded") !== "true") {
expandedRowIds.delete(row.id);
}
// Inject image previews into user message bubbles and strip the path text.
if (row.kind === "user" && imageObjectUrls.size > 0) {
const data = row.data as { content?: { type: string; text?: string } } | undefined;
const text = data?.content?.type === "text" ? data.content.text ?? "" : "";
const body = el.querySelector(".st-body");
if (body && text) {
const images: string[] = [];
attachedFileRe.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = attachedFileRe.exec(text)) !== null) {
const serverPath = JSON.parse(`"${match[1]}"`);
const objectUrl = imageObjectUrls.get(serverPath);
if (objectUrl) images.push(objectUrl);
}
if (images.length > 0) {
// Strip attachment reference lines from the displayed text.
const userText = body.querySelector(".st-user-text");
if (userText) {
const cleaned = text.replace(/\n\nAttached file "[^"]*": "[^"]*"/g, "").trim();
userText.textContent = cleaned;
if (!cleaned) userText.remove();
}
for (const src of images) {
const img = document.createElement("img");
img.className = "st-user-image";
img.src = src;
img.alt = "Uploaded image";
img.loading = "lazy";
body.appendChild(img);
}
}
}
}
return el;
}
function toolGroupsByTurn(): Map<string, TranscriptRow[]> {
const groups = new Map<string, TranscriptRow[]>();
/** Group tool rows (and intermediate agent text / resolved approvals between
* them) by turn. An agent_text/agent_thought row is "intermediate" when a
* tool row follows it in the same turn — it gets absorbed into the group so
* the collapsed block preserves chronological order. Resolved approval rows
* (not the currently pending one) are absorbed the same way. The *last*
* agent text with no subsequent tool stays standalone (the prominent final
* answer). Returns groups keyed by `turn:<userId>`, each with `toolCount`
* (for the header label) and `rows` (tools + intermediate text/approvals,
* in order). */
function activityGroupsByTurn(): Map<string, { toolCount: number; rows: TranscriptRow[] }> {
// First: count tools per turn to know which turns qualify for grouping.
const toolCounts = new Map<string, number>();
let turnId = "before-first-message";
for (const row of rows) {
if (row.kind === "user") turnId = row.id;
if (row.kind !== "tool_call" && row.kind !== "tool_diff") continue;
const id = `turn:${turnId}`;
const group = groups.get(id) ?? [];
group.push(row);
groups.set(id, group);
const gid = `turn:${turnId}`;
toolCounts.set(gid, (toolCounts.get(gid) ?? 0) + 1);
}
// Second: build groups with interleaved text for turns with 2+ tools.
const groups = new Map<string, { toolCount: number; rows: TranscriptRow[] }>();
turnId = "before-first-message";
// Pending rows that might be intermediate (not yet confirmed by a
// following tool). If no tool follows, they stay standalone.
let pending: TranscriptRow[] = [];
let currentGid = `turn:${turnId}`;
for (const row of rows) {
if (row.kind === "user") {
turnId = row.id;
currentGid = `turn:${turnId}`;
pending = [];
continue;
}
const count = toolCounts.get(currentGid) ?? 0;
if (count < 2) continue;
const isIntermediateText = row.kind === "agent_text" || row.kind === "agent_thought";
const isResolvedApproval = row.kind === "approval" && row.id !== pendingApprovalRowId;
if (isIntermediateText || isResolvedApproval) {
pending.push(row);
continue;
}
if (row.kind === "tool_call" || row.kind === "tool_diff") {
const group = groups.get(currentGid) ?? { toolCount: count, rows: [] };
// Flush any pending rows — a tool follows, so they're intermediate.
group.rows.push(...pending);
pending = [];
group.rows.push(row);
groups.set(currentGid, group);
}
}
// A final agent text is the answer, so it stays outside. Thought rows are
// working commentary, though: when a turn already has a disclosure they
// belong with its activity even if the next tool call has not arrived yet.
// Leaving that tail loose made a busy agent look like it was publishing a
// long italic answer above a collapsed "N tool calls" summary.
const lastGroup = groups.get(currentGid);
if (lastGroup && pending.length > 0 && pending.every((row) => row.kind === "agent_thought")) {
lastGroup.rows.push(...pending);
}
return groups;
}
@@ -413,11 +507,12 @@ export function createStructuredConnection(
const focusedGroupId = active?.closest<HTMLElement>("[data-tool-group-id]")?.dataset.toolGroupId;
const restoreToolFocus = active?.classList.contains("st-tool-head") ?? false;
const restoreGroupFocus = active?.classList.contains("st-tool-group-head") ?? false;
const groups = toolGroupsByTurn();
const groups = activityGroupsByTurn();
const groupedRowIds = new Set<string>();
const liveGroupIds = new Set([...groups].filter(([, tools]) => tools.length > 1).map(([id]) => id));
const liveGroupIds = new Set([...groups.keys()]);
for (const id of expandedGroupIds) if (!liveGroupIds.has(id)) expandedGroupIds.delete(id);
const prevGroupEls = new Map(groupEls);
rowEls.clear();
groupEls.clear();
const children: HTMLElement[] = [];
@@ -425,34 +520,54 @@ export function createStructuredConnection(
for (const row of rows) {
if (row.kind === "user") turnId = row.id;
const groupId = `turn:${turnId}`;
const groupRows = groups.get(groupId) ?? [];
if ((row.kind === "tool_call" || row.kind === "tool_diff") && groupRows.length > 1) {
if (groupedRowIds.has(row.id)) continue;
const toolEls = groupRows.flatMap((toolRow) => {
groupedRowIds.add(toolRow.id);
const toolEl = buildRowElement(toolRow);
if (!toolEl) return [];
rowEls.set(toolRow.id, toolEl);
return [toolEl];
const group = groups.get(groupId);
if (group && groupedRowIds.has(row.id)) continue;
if (group && (row.kind === "tool_call" || row.kind === "tool_diff") && !groupedRowIds.has(row.id)) {
// First ungrouped tool in this turn — render the whole activity group.
const memberEls = group.rows.flatMap((memberRow) => {
groupedRowIds.add(memberRow.id);
const memberEl = buildRowElement(memberRow);
if (!memberEl) return [];
rowEls.set(memberRow.id, memberEl);
return [memberEl];
});
const groupEl = buildToolGroup(
groupId,
groupRows,
toolEls,
expandedGroupIds.has(groupId),
(expanded) => {
if (expanded) expandedGroupIds.add(groupId);
else expandedGroupIds.delete(groupId);
settleDisclosureLayout();
},
);
if (groupRows.some((toolRow) => enteringRowIds.has(toolRow.id))) {
groupEl.classList.add("st-row-enter");
// Reuse existing group element when possible — avoids tearing down
// and recreating the wrapper, which restarts CSS transitions (visible
// flash) and destroys keyboard/focus state.
const existing = prevGroupEls.get(groupId);
if (existing) {
const titleEl = existing.querySelector<HTMLElement>(".st-tool-group-title");
if (titleEl) titleEl.textContent = `${group.toolCount} tool calls`;
const inner = existing.querySelector<HTMLElement>(".st-disclosure-inner");
if (inner) inner.replaceChildren(...memberEls);
groupEls.set(groupId, existing);
children.push(existing);
} else {
const groupEl = buildToolGroup(
groupId,
group.rows,
memberEls,
expandedGroupIds.has(groupId),
(expanded) => {
if (expanded) expandedGroupIds.add(groupId);
else expandedGroupIds.delete(groupId);
settleDisclosureLayout();
},
group.toolCount,
);
if (group.rows.some((r) => enteringRowIds.has(r.id))) {
groupEl.classList.add("st-row-enter");
}
groupEls.set(groupId, groupEl);
children.push(groupEl);
}
groupEls.set(groupId, groupEl);
children.push(groupEl);
continue;
}
// Resolved approvals not absorbed into a tool group are hidden — the
// tool row itself already identifies the action, so a standalone
// "answered" card adds nothing. Pending approvals always render.
if (row.kind === "approval" && row.id !== pendingApprovalRowId) continue;
const rowEl = buildRowElement(row);
if (!rowEl) continue;
rowEls.set(row.id, rowEl);
@@ -504,6 +619,24 @@ export function createStructuredConnection(
rows.push(delta.row);
} else {
rows[i] = delta.row;
// Surgical DOM swap: replace just this row's element instead of
// tearing down and rebuilding the entire transcript, which causes
// visible flashing and breaks text selection.
const oldEl = rowEls.get(delta.row.id);
if (oldEl) {
const active = document.activeElement as HTMLElement | null;
const hadToolFocus = active?.classList.contains("st-tool-head") && oldEl.contains(active);
const newEl = buildRowElement(delta.row);
if (newEl) {
oldEl.replaceWith(newEl);
rowEls.set(delta.row.id, newEl);
if (hadToolFocus) {
newEl.querySelector<HTMLButtonElement>(".st-tool-head")?.focus();
}
}
syncAvailableCommands();
return;
}
}
} else {
const i = rowIndex.get(delta.id);
@@ -548,12 +681,14 @@ export function createStructuredConnection(
}
}
let configDirty = false;
function renderConfig(): void {
const options = sessionConfigOptions();
configEl.hidden = options.length === 0;
if (options.length === 0) {
configMenu.hidden = true;
configTrigger.setAttribute("aria-expanded", "false");
configDirty = false;
return;
}
const ordered = [...options].sort((a, b) => {
@@ -567,6 +702,15 @@ export function createStructuredConnection(
configTrigger.title = summary;
configTrigger.setAttribute("aria-label", `Session settings. ${summary.replaceAll("\n", ". ")}`);
configTrigger.disabled = configBusy;
// When the menu is open and the user is interacting (not mid-change),
// skip the DOM rebuild so open <select> dropdowns aren't destroyed.
// Mark dirty so the next open rebuilds with fresh data.
if (!configMenu.hidden && !configBusy) {
configDirty = true;
return;
}
configDirty = false;
configMenu.replaceChildren();
for (const option of ordered) {
const row = document.createElement("label");
@@ -605,17 +749,33 @@ export function createStructuredConnection(
}
}
function renderQueuedPrompts(): void {
for (const old of rowsEl.querySelectorAll(".st-row-queued")) old.remove();
for (const queued of promptQueue) {
const wrap = document.createElement("div");
wrap.className = "st-row st-row-queued";
const body = document.createElement("div");
body.className = "st-body";
const line = document.createElement("div");
line.className = "st-user-text";
line.textContent = queued.text;
body.appendChild(line);
wrap.appendChild(body);
rowsEl.appendChild(wrap);
}
}
function renderComposerState(): void {
const active = uploading || state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
const projectedActive = state.turn.responding || state.turn.thinking;
input.disabled = active;
attachBtn.disabled = active;
fileInput.disabled = active;
input.disabled = false;
attachBtn.disabled = uploading;
fileInput.disabled = uploading;
renderAttachments();
sendBtn.disabled = active;
sendBtn.hidden = projectedActive;
sendBtn.disabled = false;
sendBtn.hidden = false;
sendLabel.textContent = "Send";
cancelBtn.hidden = !projectedActive;
if (active) closeCommandMenu();
renderQueuedPrompts();
renderActivity();
}
@@ -627,6 +787,7 @@ export function createStructuredConnection(
function renderActivity(): void {
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
const wasHidden = activityEl.hidden;
activityEl.hidden = !active;
if (!active) {
optimisticTurnStartedAt = null;
@@ -649,6 +810,11 @@ export function createStructuredConnection(
}
activityText.textContent = parts.join(" · ");
if (activityTimer === null) activityTimer = window.setInterval(renderActivity, 1_000);
// Activity lives inside the scroll area; nudge scroll when it first appears.
if (wasHidden && followingBottom) {
beginTranscriptLayout(true);
finishTranscriptLayout();
}
}
function reconcileAwaitingBegin(): void {
@@ -664,19 +830,22 @@ export function createStructuredConnection(
}
function applyState(next: ReducedState, connectionGeneration: number): void {
const wasTurnActive = state.turn.responding || state.turn.thinking;
state = next;
stateSocketGeneration = connectionGeneration;
reconcileAwaitingBegin();
const isTurnActive = state.turn.responding || state.turn.thinking;
const newPendingId = state.pending_approval ? lastRowIdOfKind("approval") : null;
if (newPendingId !== pendingApprovalRowId) {
const needsRebuild = newPendingId !== pendingApprovalRowId || wasTurnActive !== isTurnActive;
if (needsRebuild) {
updateTranscriptLayout(() => {
const prev = pendingApprovalRowId;
pendingApprovalRowId = newPendingId;
if (prev !== null || newPendingId !== null) rebuildTranscript();
rebuildTranscript();
});
}
renderConfig();
renderComposerState();
dispatchQueuedPrompt();
}
function closeConfigMenu(): void {
@@ -686,6 +855,7 @@ export function createStructuredConnection(
configTrigger.addEventListener("click", () => {
const opening = configMenu.hidden;
if (opening && configDirty) renderConfig();
configMenu.hidden = !opening;
configTrigger.setAttribute("aria-expanded", String(opening));
});
@@ -876,17 +1046,14 @@ export function createStructuredConnection(
if (!commandMenu.contains(document.activeElement)) closeCommandMenu();
}, 0);
});
composerForm.addEventListener("submit", (e) => {
e.preventDefault();
const text = input.value;
if ((!text.trim() && attachments.length === 0) || input.disabled) return;
const submittedAttachments = attachments;
function dispatchPrompt(text: string, submittedAttachments: Attachment[] = []): void {
uploading = submittedAttachments.length > 0;
const generation = ++submissionGeneration;
const submissionSocketGeneration = socketGeneration;
awaitingBeginGeneration = generation;
optimisticTurnStartedAt = Date.now();
acceptedSubmission = null;
promptRequestPending = true;
input.value = "";
autoResize();
clearError();
@@ -895,6 +1062,9 @@ export function createStructuredConnection(
for (const attachment of submittedAttachments) {
if (disposed) return;
if (!attachment.path) attachment.path = (await uploadStructuredFile(sessionId, attachment.file)).path;
if (attachment.path && attachment.file.type.startsWith("image/")) {
imageObjectUrls.set(attachment.path, URL.createObjectURL(attachment.file));
}
}
if (disposed) return;
const references = submittedAttachments.map(({ file, path }) =>
@@ -903,6 +1073,7 @@ export function createStructuredConnection(
};
void send().then(
() => {
promptRequestPending = false;
if (disposed || generation !== submissionGeneration) return;
uploading = false;
if (attachments === submittedAttachments) {
@@ -916,11 +1087,17 @@ export function createStructuredConnection(
// A replacement socket may have captured idle before the POST claimed
// the prompt. Reconnect again so its snapshot cannot predate the 204.
if (acceptanceSocketGeneration > submissionSocketGeneration) reconnectForReconciliation();
renderComposerState();
dispatchQueuedPrompt();
},
(e: unknown) => {
promptRequestPending = false;
uploading = false;
if (disposed || generation !== submissionGeneration) return;
if (awaitingBeginGeneration === generation) awaitingBeginGeneration = null;
if (attachments.length === 0 && submittedAttachments.length > 0) {
attachments = submittedAttachments;
}
if (input.value === "") {
input.value = text;
autoResize();
@@ -929,6 +1106,32 @@ export function createStructuredConnection(
renderComposerState();
},
);
}
function dispatchQueuedPrompt(): void {
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
if (disposed || active || promptRequestPending || promptQueue.length === 0) return;
const next = promptQueue.shift()!;
dispatchPrompt(next.text, next.attachments);
}
composerForm.addEventListener("submit", (e) => {
e.preventDefault();
const text = input.value;
if ((!text.trim() && attachments.length === 0) || uploading) return;
const submittedAttachments = attachments;
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
if (active || promptQueue.length > 0) {
promptQueue.push({ text, attachments: submittedAttachments });
attachments = [];
input.value = "";
autoResize();
clearError();
renderComposerState();
dispatchQueuedPrompt();
return;
}
dispatchPrompt(text, submittedAttachments);
});
cancelBtn.addEventListener("click", () => {
clearError();
@@ -1101,6 +1304,8 @@ export function createStructuredConnection(
closeCurrentSocket();
expandedRowIds.clear();
expandedGroupIds.clear();
for (const url of imageObjectUrls.values()) URL.revokeObjectURL(url);
imageObjectUrls.clear();
scrollEl.removeEventListener("scroll", onTranscriptScroll);
scrollEl.removeEventListener("click", onTranscriptClick, true);
scrollEl.removeEventListener("wheel", stopSmoothFollow);
+5 -4
View File
@@ -315,8 +315,8 @@ describe("buildTranscriptRow: tool diffs", () => {
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
const diff = el.querySelector(".st-diff") as HTMLElement;
expect(diff).not.toBeNull();
expect(diff.querySelector(".st-ln-add")!.textContent).toBe("c");
expect(diff.querySelector(".st-ln-del")!.textContent).toBe("b");
expect(diff.querySelector(".st-ln-add")!.textContent).toBe("+ c");
expect(diff.querySelector(".st-ln-del")!.textContent).toBe("- b");
});
it("gives the diff its own horizontally-scrolling container", () => {
@@ -368,13 +368,14 @@ describe("buildTranscriptRow: approval", () => {
expect(el.querySelector(".st-approval-path")!.textContent).toBe("/etc/x");
});
it("hides the buttons and shows an answered note once resolved", () => {
it("renders a compact one-liner once resolved", () => {
const el = buildTranscriptRow(approvalRow(allThreeOptions), {
pending: false,
onDecide: () => Promise.resolve(),
});
expect(el.querySelector(".st-approval-acts")).toBeNull();
expect(el.querySelector(".st-approval-resolved")!.textContent).toBe("answered");
expect(el.classList.contains("st-approval-compact")).toBe(true);
expect(el.textContent).toContain("approved");
});
it("invokes onDecide with the clicked decision and disables the buttons", () => {
+50 -50
View File
@@ -223,15 +223,16 @@ function buildDiffBlock(diff: { path: string; oldText?: string | null; newText:
wrap.appendChild(label);
const scroller = el("div", "st-diff");
const pre = document.createElement("pre");
for (const line of computeDiffLines(diff.oldText, diff.newText)) {
const lines = computeDiffLines(diff.oldText, diff.newText);
const prefixes: Record<string, string> = { add: "+ ", del: "- ", ctx: " ", hunk: "", eof: "" };
for (const line of lines) {
const span = document.createElement("span");
span.className = `st-ln st-ln-${line.kind}`;
span.textContent = line.text;
span.textContent = (prefixes[line.kind] ?? "") + line.text;
pre.appendChild(span);
pre.appendChild(document.createTextNode("\n"));
}
scroller.appendChild(pre);
makeCopyable(scroller, pre.textContent ?? "", "diff");
makeCopyable(scroller, lines.map((l) => (prefixes[l.kind] ?? "") + l.text).join("\n"), "diff");
wrap.appendChild(scroller);
return wrap;
}
@@ -581,7 +582,9 @@ export function buildToolGroup(
children: HTMLElement[],
expanded: boolean,
onExpandedChange?: (expanded: boolean) => void,
toolCount?: number,
): HTMLElement {
const count = toolCount ?? rows.length;
const wrap = el("div", "st-tool-group");
wrap.dataset.toolGroupId = id;
const head = document.createElement("button");
@@ -593,15 +596,8 @@ export function buildToolGroup(
glyph.textContent = "›";
glyph.setAttribute("aria-hidden", "true");
const title = el("span", "st-tool-group-title");
title.textContent = `${rows.length} tool calls`;
const names = el("span", "st-tool-group-names");
names.textContent = rows
.map((row) => {
const tool = (row.data ?? {}) as AcpToolCallLike;
return splitToolTitle(tool.title, tool.name, tool.kind).label;
})
.join(" · ");
head.append(glyph, title, names);
title.textContent = `${count} tool calls`;
head.append(glyph, title);
const body = el("div", "st-tool-group-body st-disclosure");
const bodyClip = el("div", "st-disclosure-clip");
@@ -709,6 +705,16 @@ function offeredDecisions(options: AcpPermissionOption[]): StructuredApprovalDec
function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLElement {
const request = (row.data ?? {}) as { toolCall?: AcpToolCallLike; options?: AcpPermissionOption[] };
const toolCall = request.toolCall ?? {};
// Resolved approvals render as a compact one-liner inside a tool group
// (or are hidden entirely when standalone — see rebuildTranscript).
if (!approval.pending) {
const wrap = el("div", "st-row st-row-approval st-approval-compact");
const label = toolCall.title ?? toolKindLabel(toolCall.kind);
wrap.textContent = `${label} — approved`;
return wrap;
}
const wrap = el("div", "st-row st-row-approval");
const card = el("div", "st-approval");
wrap.appendChild(card);
@@ -728,45 +734,39 @@ function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLE
card.appendChild(pathEl);
}
if (approval.pending) {
const acts = el("div", "st-approval-acts");
const mk = (label: string, decision: StructuredApprovalDecision, cls: string) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = cls ? `act ${cls}` : "act";
btn.textContent = label;
btn.addEventListener("click", () => {
const buttons = [...acts.querySelectorAll<HTMLButtonElement>("button")];
buttons.forEach((b) => (b.disabled = true));
approval.onDecide(decision).catch(() => {
// Rejects only when this approval is still pending (see
// ApprovalRowState) — restore the controls so the operator can
// retry; the error itself is surfaced by the connection.
buttons.forEach((b) => (b.disabled = false));
});
const acts = el("div", "st-approval-acts");
const mk = (label: string, decision: StructuredApprovalDecision, cls: string) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = cls ? `act ${cls}` : "act";
btn.textContent = label;
btn.addEventListener("click", () => {
const buttons = [...acts.querySelectorAll<HTMLButtonElement>("button")];
buttons.forEach((b) => (b.disabled = true));
approval.onDecide(decision).catch(() => {
// Rejects only when this approval is still pending (see
// ApprovalRowState) — restore the controls so the operator can
// retry; the error itself is surfaced by the connection.
buttons.forEach((b) => (b.disabled = false));
});
return btn;
};
const offered = offeredDecisions(request.options ?? []);
const specs: [StructuredApprovalDecision, string, string][] = [
["allow_once", "Allow once", "primary"],
["allow_for_session", "Allow for session", ""],
["deny", "Deny", "deny"],
];
for (const [decision, label, cls] of specs) {
if (offered.includes(decision)) acts.appendChild(mk(label, decision, cls));
}
if (acts.children.length > 0) {
card.appendChild(acts);
} else {
const none = el("div", "st-approval-resolved");
none.textContent = "no decision offered";
card.appendChild(none);
}
});
return btn;
};
const offered = offeredDecisions(request.options ?? []);
const specs: [StructuredApprovalDecision, string, string][] = [
["allow_once", "Allow once", "primary"],
["allow_for_session", "Allow for session", ""],
["deny", "Deny", "deny"],
];
for (const [decision, label, cls] of specs) {
if (offered.includes(decision)) acts.appendChild(mk(label, decision, cls));
}
if (acts.children.length > 0) {
card.appendChild(acts);
} else {
const resolved = el("div", "st-approval-resolved");
resolved.textContent = "answered";
card.appendChild(resolved);
const none = el("div", "st-approval-resolved");
none.textContent = "no decision offered";
card.appendChild(none);
}
return wrap;
+203 -99
View File
@@ -2290,6 +2290,9 @@ input.leaf-rename:focus {
.structured-main {
min-width: 0;
min-height: 0;
max-width: 820px;
margin-inline: auto;
width: 100%;
display: flex;
flex-direction: column;
flex: 1 1 auto;
@@ -2315,9 +2318,7 @@ input.leaf-rename:focus {
gap: var(--sp-2);
min-height: 30px;
padding: var(--sp-1) var(--sp-3);
border-top: 1px solid var(--border);
color: var(--muted);
background: var(--surface-2);
font-size: var(--fs-11);
font-variant-numeric: tabular-nums;
}
@@ -2374,11 +2375,36 @@ input.leaf-rename:focus {
background: var(--surface-2);
}
/* Queued messages: optimistic user bubbles, not yet sent. */
.st-row-queued {
justify-content: flex-end;
pointer-events: none;
}
.st-row-queued .st-body {
flex: 0 1 auto;
max-width: min(86%, 72ch);
padding: var(--sp-2) var(--sp-3);
border-radius: var(--r-2);
background: var(--surface-3);
opacity: 0.45;
filter: blur(0.5px);
}
.st-user-text {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.st-user-image {
display: block;
max-width: min(100%, 480px);
max-height: 360px;
margin-top: var(--sp-2);
border-radius: var(--r-1);
object-fit: contain;
}
/* agent prose: no card, no background — plain text is the point */
.st-row-agent .st-body p {
margin: 0 0 var(--sp-2);
@@ -2481,7 +2507,8 @@ input.leaf-rename:focus {
.st-row-thought {
color: var(--faint);
font-style: italic;
font-size: var(--fs-12);
line-height: 1.4;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
@@ -2828,12 +2855,12 @@ input.leaf-rename:focus {
border: 1px solid var(--border);
border-radius: var(--r-2);
background: color-mix(in oklab, var(--surface-2) 55%, transparent);
padding: var(--sp-2) var(--sp-3);
padding: var(--sp-1) var(--sp-2);
transition: background var(--dur-fast) var(--ease-out);
}
.st-tool-group:hover {
background: var(--surface-2);
background: color-mix(in oklab, var(--surface-2) 70%, transparent);
}
.st-tool-group-head {
@@ -2870,15 +2897,6 @@ input.leaf-rename:focus {
font-weight: 500;
}
.st-tool-group-names {
min-width: 0;
overflow: hidden;
color: var(--muted);
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.st-tool-group-body {
padding-top: 0;
}
@@ -2886,11 +2904,42 @@ input.leaf-rename:focus {
.st-tool-group-body .st-disclosure-inner {
display: flex;
flex-direction: column;
padding-top: var(--sp-2);
padding-top: var(--sp-1);
}
.st-tool-group-body .st-disclosure-inner > .st-row-tool {
padding: var(--sp-1) 0;
padding: 0;
}
/* inside a group, tool cards lose their border and background —
the group container already provides the visual boundary. */
.st-tool-group-body .st-tool {
border: 0;
}
.st-tool-group-body .st-tool-head {
padding: var(--sp-1) var(--sp-2);
border-radius: var(--r-1);
}
/* intermediate agent text inside a tool group — subdued so the final
answer outside the group stays the prominent one. */
.st-tool-group-body .st-row-agent {
color: var(--muted);
font-size: var(--fs-12);
padding: var(--sp-1) var(--sp-2);
}
.st-tool-group-body .st-row-agent .st-body > :first-child {
margin-top: 0;
}
.st-tool-group-body .st-row-agent .st-body > :last-child {
margin-bottom: 0;
}
.st-tool-group-body .st-row-thought {
padding: var(--sp-1) var(--sp-2);
}
.st-tool-group-body[hidden] {
@@ -2942,6 +2991,7 @@ input.leaf-rename:focus {
display: block;
white-space: pre;
padding: 0 var(--sp-3);
line-height: 1.4;
}
.st-ln-add {
@@ -3015,6 +3065,16 @@ input.leaf-rename:focus {
color: var(--faint);
}
/* compact resolved approval inside a tool group — same muted treatment
as intermediate agent text so the audit trail is preserved without
drawing attention to itself. */
.st-approval-compact {
color: var(--muted);
font-size: var(--fs-12);
padding: var(--sp-1) var(--sp-2);
font-style: italic;
}
.act {
appearance: none;
font: inherit;
@@ -3062,31 +3122,26 @@ input.leaf-rename:focus {
}
/* The composer is a sibling of the scrolling transcript, not part of it: it
stays reachable at the pane edge without changing transcript scroll math. */
stays reachable at the pane edge without changing transcript scroll math.
It is a flush band at the foot of the pane rather than a floating card: its
own `--surface` fill is what separates it from the transcript, over a
hairline that doubles as the focus indicator. Inner metrics line the draft
up with the transcript's own 12px gutter. */
.structured-composer {
position: relative;
flex: 0 0 auto;
margin: var(--sp-2) var(--sp-3) max(var(--sp-2), env(safe-area-inset-bottom));
padding: var(--sp-1);
border: 1px solid var(--border-strong);
border-radius: var(--r-3);
padding: var(--sp-1) var(--sp-1) max(var(--sp-1), env(safe-area-inset-bottom));
border-top: 1px solid var(--border);
background: var(--surface);
display: flex;
align-items: flex-end;
gap: var(--sp-1);
box-shadow:
inset 0 1px 0 color-mix(in oklab, var(--ink) 6%, transparent),
var(--shadow-1);
transition:
border-color var(--dur-fast) var(--ease-out),
box-shadow var(--dur-fast) var(--ease-out);
transition: border-color var(--dur-fast) var(--ease-out);
}
.structured-composer:focus-within {
border-color: var(--accent);
box-shadow:
inset 0 1px 0 color-mix(in oklab, var(--ink) 8%, transparent),
0 0 0 3px var(--accent-tint),
var(--shadow-1);
border-top-color: var(--border);
}
.structured-input-wrap {
@@ -3151,6 +3206,10 @@ input.leaf-rename:focus {
line-height: 20px;
}
.structured-input:focus-visible {
outline: none;
}
.structured-composer .structured-send,
.structured-composer .structured-cancel {
height: 40px;
@@ -3198,7 +3257,9 @@ input.leaf-rename:focus {
}
.structured-composer .structured-cancel {
padding: 0 var(--sp-3);
position: relative;
width: 40px;
padding: 0;
border-color: color-mix(in oklab, var(--danger) 35%, var(--border));
color: var(--danger);
}
@@ -3207,18 +3268,47 @@ input.leaf-rename:focus {
background: var(--danger-tint);
}
.structured-cancel-stop {
width: 16px;
height: 16px;
}
.structured-cancel-spinner {
position: absolute;
inset: 4px;
width: auto;
height: auto;
animation: cancel-spin 0.8s linear infinite;
}
@keyframes cancel-spin {
to { transform: rotate(360deg); }
}
@media (max-width: 520px) {
/* One row cannot hold a draft plus three controls at phone width — the
draft gets a line of its own and the controls drop below it. */
.structured-composer {
margin: var(--sp-1) 0 0;
padding-bottom: calc(var(--sp-1) + env(safe-area-inset-bottom, 0px));
border-right: 0;
border-bottom: 0;
border-left: 0;
border-radius: var(--r-2) var(--r-2) 0 0;
flex-wrap: wrap;
justify-content: flex-end;
padding-left: var(--sp-2);
padding-right: var(--sp-2);
}
/* `order` keeps the draft on the first line: without it the attach button,
which precedes it in the DOM, wraps onto a line of its own. */
.structured-input-wrap {
flex: 1 1 100%;
order: -1;
}
.structured-composer .structured-config {
margin-right: auto;
}
.structured-config-menu {
right: calc(-1 * (var(--control-h) + var(--sp-2)));
left: 0;
right: auto;
}
}
@@ -3432,14 +3522,9 @@ input.leaf-rename:focus {
overflow-y: visible;
}
/* Rides the bottom of the scroll box: reachable at any scroll position, and
`margin-top: auto` keeps it at the screen's foot when the form is short. */
.spawn-modal .form-actions {
position: sticky;
bottom: 0;
margin-top: auto;
padding: var(--sp-2) 0 calc(var(--sp-2) + env(safe-area-inset-bottom, 0px));
background: var(--surface);
padding-bottom: calc(var(--sp-2) + env(safe-area-inset-bottom, 0px));
box-shadow: 0 -8px 12px -8px oklch(0 0 0 / 0.5);
}
@@ -3521,20 +3606,16 @@ input.leaf-rename:focus {
flex: 0 0 auto;
}
.worktree {
border: 1px solid var(--border);
border-radius: var(--r-2);
padding: 0 var(--sp-3);
margin: 0;
.advanced {
display: flex;
flex-direction: column;
background: color-mix(in oklab, var(--surface-2) 50%, transparent);
}
.worktree .wt-toggle {
margin: 0 calc(var(--sp-3) * -1);
padding-left: var(--sp-3);
width: calc(100% + var(--sp-3) * 2);
.advanced .wt-toggle {
border-top: 1px solid var(--border);
border-radius: 0;
padding-top: var(--sp-2);
padding-bottom: var(--sp-2);
}
.wt-summary {
@@ -3546,13 +3627,27 @@ input.leaf-rename:focus {
display: flex;
flex-direction: column;
gap: var(--sp-3);
padding-bottom: var(--sp-3);
padding: var(--sp-2) 0 var(--sp-1);
}
.wt-collapsible[hidden] {
display: none;
}
/* Worktree sub-group: left accent border when expanded, visually nested. */
.adv-group {
display: flex;
flex-direction: column;
gap: var(--sp-3);
padding-left: var(--sp-3);
border-left: 2px solid var(--border);
margin-top: var(--sp-1);
}
.adv-group:has(input[name="worktree_enabled"]:checked) {
border-left-color: var(--accent);
}
.form-error {
color: var(--danger);
margin: 0;
@@ -3579,61 +3674,78 @@ input.leaf-rename:focus {
color: var(--ink);
}
/* One-click tiles: host row + command row. */
.tile-row {
display: flex;
flex-wrap: wrap;
/* Harness cards: the first decision in the spawn form — which tool runs. */
.harness-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: var(--sp-2);
}
.tile {
flex: 1 1 auto;
min-width: 96px;
.harness-card {
display: flex;
flex-direction: column;
gap: 2px;
align-items: flex-start;
padding: var(--sp-2) var(--sp-3);
align-items: center;
gap: var(--sp-2);
padding: var(--sp-3) var(--sp-2);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r-2);
color: var(--ink);
font-family: inherit;
cursor: pointer;
text-align: left;
text-align: center;
transition:
border-color var(--dur-fast) var(--ease-out),
background var(--dur-fast) var(--ease-out);
}
.tile:hover {
.harness-card:hover {
border-color: var(--border-strong);
background: var(--surface-3);
}
.tile.is-active {
.harness-card.is-active {
border-color: var(--accent);
background: var(--accent-tint);
}
.tile-label {
font-size: var(--fs-14);
font-weight: 600;
}
.tile-sub {
font-size: var(--fs-12);
color: var(--muted);
font-family: var(--font-mono);
}
.tile-more {
flex: 0 0 auto;
min-width: 0;
justify-content: center;
.harness-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
color: var(--muted);
}
.harness-card.is-active .harness-icon {
color: var(--ink);
}
.harness-icon .tool-icon {
display: block;
}
.harness-label {
font-size: var(--fs-13);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
/* The "Custom…" card is an escape hatch, not a peer tool — dashed border
and subdued color separate it from the named harnesses. */
.harness-custom {
border-style: dashed;
color: var(--muted);
}
.harness-custom .harness-label {
font-weight: 500;
}
/* Working-directory picker (#95). Two steps: a grid of project boxes, then the
picked project's worktree choices. Stale beyond 7 days dims but stays
selectable. */
@@ -3660,7 +3772,7 @@ input.leaf-rename:focus {
.picker.projects {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
grid-template-columns: repeat(2, 1fr);
align-content: start;
gap: var(--sp-2);
max-height: 300px;
@@ -3716,19 +3828,6 @@ input.leaf-rename:focus {
overflow-wrap: anywhere;
}
.project-meta {
font-family: var(--font-mono);
font-size: var(--fs-12);
color: var(--faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-meta:empty {
display: none;
}
.project-other {
align-self: flex-start;
padding: 4px 0;
@@ -3870,6 +3969,11 @@ input.leaf-rename:focus {
gap: var(--sp-2);
justify-content: flex-end;
margin-top: var(--sp-2);
position: sticky;
bottom: 0;
padding: var(--sp-2) 0;
background: var(--surface);
z-index: 1;
}
.reroll {
+3 -1
View File
@@ -11,7 +11,7 @@ export type SessionStatus = "Running" | "Dead";
/** Daemon-inferred activity dimension, orthogonal to `SessionStatus` (NAP-340).
* Absent on payloads from daemons that predate the field. `Error` is only set
* when an agent reports failure (#105); the daemon never infers it. */
export type ActivityState = "Active" | "Idle" | "AwaitingInput" | "Error";
export type ActivityState = "Active" | "Idle" | "AwaitingInput" | "Error" | "Starting";
export interface Session {
id: Uuid;
@@ -91,6 +91,7 @@ export interface SpawnPreset {
id: string;
label: string;
command: string;
auto_approve?: boolean;
}
/** `GET /api/info` — the daemon's identity plus its spawn presets (#26).
@@ -277,6 +278,7 @@ export interface CreateSessionRequest {
metadata?: Record<string, unknown>;
worktree?: WorktreeSpec;
view?: SessionView;
auto_approve?: boolean;
}
export type CatalogEvent =
+105
View File
@@ -0,0 +1,105 @@
import type { Config } from "tailwindcss";
// Bridges Tailwind utilities to the OKLCH custom-property tokens defined in
// src/styles.css. Every value here is `var(--token)`, never a literal, so
// theme/palette switching (data-theme, data-palette on <html>) keeps working
// through Tailwind classes exactly as it does through the vanilla CSS.
export default {
content: ["src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
bg: "var(--bg)",
"bg-grid": "var(--bg-grid)",
surface: "var(--surface)",
"surface-2": "var(--surface-2)",
"surface-3": "var(--surface-3)",
border: "var(--border)",
"border-strong": "var(--border-strong)",
ink: "var(--ink)",
muted: "var(--muted)",
faint: "var(--faint)",
accent: "var(--accent)",
"accent-strong": "var(--accent-strong)",
"accent-ink": "var(--accent-ink)",
"accent-tint": "var(--accent-tint)",
"accent-ring": "var(--accent-ring)",
"accent-fill": "var(--accent-fill)",
preview: "var(--preview)",
"preview-ink": "var(--preview-ink)",
"preview-tint": "var(--preview-tint)",
"preview-ring": "var(--preview-ring)",
live: "var(--live)",
"live-glow": "var(--live-glow)",
danger: "var(--danger)",
"danger-tint": "var(--danger-tint)",
warn: "var(--warn)",
"term-bg": "var(--term-bg)",
},
spacing: {
"sp-1": "var(--sp-1)",
"sp-2": "var(--sp-2)",
"sp-3": "var(--sp-3)",
"sp-4": "var(--sp-4)",
"sp-5": "var(--sp-5)",
"sp-6": "var(--sp-6)",
"sp-8": "var(--sp-8)",
},
borderRadius: {
"r-1": "var(--r-1)",
"r-2": "var(--r-2)",
"r-3": "var(--r-3)",
"r-pill": "var(--r-pill)",
},
fontSize: {
"fs-11": "var(--fs-11)",
"fs-12": "var(--fs-12)",
"fs-13": "var(--fs-13)",
"fs-14": "var(--fs-14)",
"fs-15": "var(--fs-15)",
"fs-17": "var(--fs-17)",
"fs-20": "var(--fs-20)",
},
fontFamily: {
mono: "var(--font-mono)",
sans: "var(--font-sans)",
},
height: {
"row-h": "var(--row-h)",
"control-h": "var(--control-h)",
tap: "var(--tap)",
"bar-h": "var(--bar-h)",
},
width: {
tap: "var(--tap)",
},
minHeight: {
"control-h": "var(--control-h)",
tap: "var(--tap)",
},
zIndex: {
sticky: "var(--z-sticky)",
backdrop: "var(--z-backdrop)",
modal: "var(--z-modal)",
toast: "var(--z-toast)",
},
transitionDuration: {
fast: "var(--dur-fast)",
DEFAULT: "var(--dur)",
slow: "var(--dur-slow)",
},
transitionTimingFunction: {
out: "var(--ease-out)",
},
boxShadow: {
1: "var(--shadow-1)",
2: "var(--shadow-2)",
},
},
},
} satisfies Config;
+1
View File
@@ -4,6 +4,7 @@
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
+3
View File
@@ -1,4 +1,6 @@
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
// Where the dev proxy forwards `/api/*` (and the events WS) to. Default to a
// local daemon on 127.0.0.1:7070. Override with:
@@ -18,6 +20,7 @@ export default defineConfig(({ mode }) => {
const target = resolveProxyTarget(env.MAESTRO_DAEMON_URL);
const secure = target.startsWith("https://");
return {
plugins: [react(), tailwindcss()],
build: {
target: "es2022",
outDir: "dist",