Adds a transcript surface for a Structured-view session alongside the
existing xterm attach view: a WS /api/structured/:id connection
(structured.ts) mounted by ConnectionPool whenever a session's `view`
is `"Structured"`, and pure per-row/rail renderers (structuredRender.ts,
structuredDiff.ts) covering all eight transcript row kinds.
Hierarchy follows the design reference (artifact 88ecd690): the
approval is the only lifted, card-like element; a user turn is a
tinted block with an accent rail; agent prose is plain text; tool
calls are compact bordered rows expanding to output or a coloured,
horizontally-scrolling diff computed from ACP's full old/new text.
The client renders `reduced_state` and rows exactly as received, with
no raw-event folding or turn tracking of its own.
Extracted attach.ts's pane-header title building into paneTitle.ts and
its connection interface into pane.ts (PaneConnection) so both
connection kinds share one contract and one header.
Escape unfocusing the pane made it impossible to send a literal Esc into
the attached session. Removes pane.focusList and its Escape binding, and
rebinds focus.toggle from ctrl-shift-h (claimed by the browser's History
shortcut) to leader-h, consistent with the other leader escape hatches.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The web N path sent the generated handle as the session name as well as
the worktree branch, so the session was born named and never got its
Haiku name from its first prompt. The TUI already spawns unnamed (#131);
the handle now only seeds the branch.
The TUI read its leader from `[tui].leader`; the web used `DEFAULT_LEADER`,
a module constant. Rebinding one left the other on `ctrl-a`, so the two
escape hatches hanging off the leader (`leader ctrl-k`, `leader leader`)
moved on one client and not the other.
`GET /api/info` now carries `leader`. The web binds that when it arrives and
rebuilds the leader-owning `CommandSet`, so the `?` overlay and the command
bar render the configured chord; an absent or empty field — an older daemon,
or an unreachable `/api/info` — leaves it on `ctrl-a`.
Chose the existing `[tui].leader` served as-is over a new shared key: one
knob, no precedence rules between two spellings, and no migration for
operators who already set it. The name is a wart — it is the leader for every
client now, not the TUI's — and `docs/COMMAND-BAR.md` and `docs/INSTALL.md`
say so.
There is no ctrl-k on a phone and the web UI is mobile-first, so the field
the operator already reaches for becomes the touch entry point: focusing or
tapping it dispatches OPEN_COMMAND_BAR_EVENT and the shell opens the bar with
an empty query. The field is readonly and blurs itself first, so no keyboard
rises over it and the bar can be closed without reopening it.
This replaces the inline substring filter — one search affordance in the
header, not two. buildTree loses its filter argument, and the tree's "No
matches" empty state goes with it; the bar's own no-results state took over.
The filter's name-and-directory matching is now covered against the bar.
The bar's phone-width sheet moves to the spawn modal's 599px break, with
thumb-sized rows and a field that clears the zoom-on-focus threshold.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ctrl-a is consumed as the leader on every press, so it never reaches the
shell and readline's beginning-of-line was gone with no way back — unlike
Ctrl-K, which already has an escape hatch. Fix it the way tmux does:
pressing the leader twice sends the literal leader byte to the focused
pane and clears the pending chord, on both web and TUI, built from the
configured leader so a rebound leader carries it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The global listener already ran in the capture phase, but deferred to
isTypingTarget, so xterm's hidden textarea kept Ctrl-K. The bar now treats
that textarea as not a typing target, while real form fields — the spawn
form's inputs — keep their own Ctrl-K.
Leader then ctrl-k (ctrl-a ctrl-k) sends the literal 0x0B to the focused
pane through the leader chord the command set already resolves, so
readline's kill-to-end-of-line stays reachable. The pool grows a
sendToFocused for it, over a new AttachConnection.sendInput.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`>` narrows the bar to commands, matched on the label only with the chord
shown beside each row and deduped by action. `~` narrows to directories over
recent dirs and the project list, filled in from /api/dirs as a path is typed;
Enter fast-forward-spawns there, falling back to the pre-filled spawn form
when a default cannot resolve. Without a sigil the list stays mixed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The overlay, the query line and the session half of the results
(docs/COMMAND-BAR.md): an empty query lists sessions most recently
focused first, typing ranks through `matchAndRank` with the winning
field's ranges highlighted, and results render under kind headers
ordered by their best-scoring member.
Enter dispatches `SESSION_SELECT_EVENT`, the path the sidebar's click
and the hash router already share, so a bar attach cannot leave the
layout in a state a click could not produce.
The global key listener moves to the capture phase, which #148 left
alone: an attached xterm swallows every keystroke it is given, so
ctrl-k has to be seen ahead of the terminal.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One entry per command — stable id, plain-language label, key chord, run()
— replacing the ad-hoc keydown listeners in main.ts, catalog.ts, debug.ts
and attach.ts and the parallel SHORTCUTS constant in help.ts. The `?`
overlay now renders the registry, so a binding and its documentation
cannot disagree.
Keys are canonicalised the way the TUI's `InputKey::canonical` spells
them, and chords resolve like its `ChordState`: hold while the sequence
is a strict prefix, otherwise dispatch. That gives the web a leader,
`ctrl-a` by default like `[tui].leader`, held as a partial chord and
`preventDefault`ed so it stops being select-all — the escape hatch in
docs/COMMAND-BAR.md needs somewhere to land (#149). The leader stays out
of real form fields, where select-all is still what the operator wants.
Same keys, same effects: plain single-key bindings remain deaf inside
form fields and the terminal's hidden textarea, chorded ones do not.
Refs #148
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pane waited 150 ms on hover and 60 ms on a keyboard step before
following the sidebar highlight, so every preview paid for the fast walk
it might have been. Replace both with a leading-edge 120 ms gap: the
first move shows at once, a burst is coalesced to its start and its end.
The row's own hover no longer eases either — `background` was on a 120 ms
transition, which is what read as lag on the row itself.
The sidebar also rebuilt its whole tree on every `SessionUpdated`, a
couple of times a minute per live session, dropping `:hover` from the row
under the pointer. Refreshes that change nothing structural now patch the
activity rail and meta line into the standing rows instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two mounts were taking focus out of the sidebar mid-walk: the render that
put the multi-view on screen before the preview applied, and the render
that restores the previous pane when a preview ends. Both now mount
without focusing, so j/k keeps walking. Also marks the preview with a
badge on the canvas and drops the close button from its tab.
Moving the sidebar highlight (keyboard or hover) now shows that session in
the content pane without opening it: its own tab comes forward, or a
throwaway tab carries it. The layout, the saved layout and the daemon's
focus report are all left untouched, and the keyboard stays in the list.
Contract in docs/PREVIEW.md.
Tab now moves the keyboard from the session list to the pane (was
Escape); Escape moves it back from the pane to the list, intercepted via
xterm's attachCustomKeyEventHandler so it never reaches the shell/app.
Matches the TUI's `tab`/`esc` pair; Ctrl+Q was ruled out since browsers
reserve it and never deliver it to page JS.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sidebar rows only navigated via location.hash + hashchange. Moving
pane focus by split, drag, or direct pane click never touched the
hash, so a later click back onto a session whose row href matched the
stale hash fired no hashchange and was silently ignored — the pane
had to be clicked directly to regain focus.
Row clicks now also dispatch a session-select event handled the same
way as a hash deep-link, independent of whether the hash text changes.
N picked the globally freshest project by git-mtime, ignoring the session
the operator was actually on. Plain n's spawn form already seeds from the
focused session (spawnSimilarProjectSeed); fast-forward now matches that,
falling back to recency only when nothing is focused.
The picker now leads the form in both clients and takes focus on open, so
Tab walks from it into the command tiles. Opening the form also preselects
the project of the session you were last on — a worktree session seeds its
base repository — so 'n' then Enter spawns another session there.
The sidebar footer gains a status dot beside the spawn button: green while the
daemon answers, amber while reconnecting, red while it is gone. Hovering it
shows the release the daemon is running — commit hash and commit date.
When the events socket drops, `DaemonLink` starts polling `GET /api/info`. Past
a 1200 ms grace window an overlay blocks the UI (and swallows the app's
shortcuts) until the daemon answers again. If it comes back on a different
commit the page reloads, so a tab never drives new server code from the bundle
that was just replaced; a plain restart just unlocks.
docs/RESTART.md is the contract.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Draft panes are gone: an empty pane is a placeholder again, and every
spawn affordance opens a dialog around the shared spawn form. Escape,
Cancel, the ✕ and a backdrop click dismiss it; a successful submit puts
the session in a new tab (Spawn similar and the fast-forward fallback
keep placing it beside the pane they acted on).
Removes the per-leaf draft state, its persistence strip, and the form
reuse/teardown machinery the panes needed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The header carried only the brand and a session tally; app actions had
already moved to the sidebar footer. The mobile ☰ it hosted moves to the
pane header (replacing the redundant back arrow) and to the welcome
state, so the session drawer stays reachable without a swipe. The DEV
`?kit` link moves to the sidebar footer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Spawn and Spawn similar open a draft pane behind the open sidebar on
mobile, so the tap read as dead. Extract the drawer into its own module
and close it on both spawn events as well as a session tap.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ctrl+Shift+H moves the keyboard between the session list and the pane;
j/k walk the cards, Enter opens, Escape hands the pane back. The walk
skips expanded folder headers and stops on collapsed ones, matching the
TUI.
Spawn similar dropped the session's cwd into the free-text directory hatch,
so the project picker showed nothing selected. For a worktree session that
cwd was the daemon-made checkout, and the worktree toggle defaulted on, so
the clone asked for a worktree of a worktree.
It now seeds the picker: the cloned project's box selected, the same
worktree decision, base repo plus '+ new' when the source had a worktree.
A seeded path that matches no project falls back to the raw input showing
that path instead of an empty picker.
`?` toggles a dialog listing every web binding, mirroring the TUI's help
overlay. A `?` button in the header covers pointer-only clients, and the
welcome screen points at the key.
One registry (SHORTCUTS) drives the rendering, so a binding added without
an entry reads as missing. main.ts and the overlay now share one
isTypingTarget guard instead of two copies.
Removes the retained-worktree store end to end: the daemon's state-dir
store, GET/DELETE /api/worktrees, `maestro worktrees list|reclaim`, the
TUI overlay behind `w`, and the web `#/worktrees` view and header link.
The kill guard stays. A forced kill and a dirty natural exit now leave
the worktree on disk untouched instead of parking it in a store, so
removing one is an ordinary `git worktree remove`. A pristine tree is
still removed on exit. DELETE /api/sessions/:id always answers 204.
The header's debug button also goes; Ctrl+Shift+D and `?hud` still
toggle the HUD.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pressing Spawn while reading a session split that pane in half. It now
opens the draft in its own tab, leaving the current tab untouched.
Scoped to the explicit Spawn affordances (the header button and the
sidebar empty state, both via OPEN_SPAWN_EVENT). "Spawn similar" and the
fast-forward key deliberately keep splitting: both act on the session in
front of you, so a pane beside it is the point. Reusing an empty pane
(#72) is unchanged — that path never split anything.
Closes#100
The cleanup guard plus deterministic paths had created a trap: one
untracked file an agent left behind — a log, a .env, a stray artifact —
made the guard refuse removal, the directory stayed, and every later
spawn of that branch hit PathExists. The branch was burned permanently
and the product offered no way to clear it. A leftover tree registered
for the same branch is now adopted, so the operator lands back in their
own unsaved work. A tree a *live* session holds is still rejected, since
two sessions must not share a checkout.
Also:
- Fast-forward still awaited /api/info, /api/projects and /api/names
inline, the exact freeze the draft loaders were restructured to avoid.
It posts to the same channel now.
- Draft updates were routed by tab index, which shifts when a tab closes:
an in-flight load could be dropped or land on the wrong pane. Drafts
carry an id and are found by it.
- extend_names left the name it had just displayed at the head of the
queue, so the next reroll produced it again and the key looked dead.
- The fast-forward fallback discarded info.home_dir, so its draft showed
unabbreviated paths and seeded "Other directory…" with / — disagreeing
with the normal draft entry point.
- Web fast-forward mounted via openSession, which replaces the focused
pane: pressing it while attached evicted that session from the layout.
It reuses an empty pane or splits, as the TUI does.
- branchEdited was inferred from the seeded branch value, but in "new"
mode that value is the auto-synced session name — so any form rebuild
froze the branch against later rerolls.
- resolveBaseRepo ignored the worktree checkbox, rebuilding the fieldset
expanded under an unticked box and probing gitroot for a worktree the
operator had turned off.
- short_hash used DefaultHasher, whose algorithm is not stable across
Rust releases, in a directory name — a toolchain bump would orphan
every worktree under the override root. Now FNV-1a.
The existing-branch picker could never succeed in either client: both
treated the main checkout's HEAD as available, and since that is usually
`main` it became the default pick — git then refuses the second checkout.
A TUI test had baked the bug in by asserting the selection lands on the
`is_head` entry. Both pickers now treat "checked out anywhere" as held.
Worktree paths were still not injective. Flattening `/` and `.` to `-`
made `feat/x`, `feat.x` and `feat-x` share one directory, and the
override root keyed on the repo basename alone, so `~/a/maestro` and
`~/b/maestro` collided. A branch name is already a valid relative path —
git stores it as refs/heads/feat/example — so it is used verbatim and
nests under `worktrees/feat/`; the override root gains a digest of the
full repo path. Git forbids holding both `feat` and `feat/x`, so the
nesting cannot collide for valid refs.
Also:
- Recovered sessions dropped their worktree association, so after a
restart a kill cleaned up nothing and the leftover directory blocked
that branch forever. It is persisted on Session now.
- The nesting guard compared git's resolved paths against an unresolved
one, so it silently passed on any symlinked repo path — and that guard
is what stands between a kill and an inner tree's uncommitted work.
- Draft loads ran on the TUI event loop: /api/projects on a cold cache
walks all of $HOME, freezing keys, frames and attached panes for up to
the request timeout. They are detached tasks posting to a channel now.
- Fast-forward in the web client created the session and never mounted
it, so `N` looked like a dead key while the TUI attached.
- The fast-forward fallback seed forced the worktree toggle off, against
both a normal draft and the TUI.
- The projects background refresh honoured the TTL, so two of every three
ticks were no-ops and requests paid for the rescan instead.
The draft pane covers every field the modal had; the modal was a second
code path for the same job. The header Spawn button, sidebar empty state,
welcome button, and ?mock&spawn now open a draft pane via
MultiView.openDraft — reusing an empty focused pane, else splitting it.
"Spawn similar" seeds a draft with the session's cwd + command.
Modal-only CSS (backdrop, sheet/dialog chrome, keyframes) is deleted;
.modal-body form styling folds into .spawn-form, which also picks up the
select styling the worktree branch picker was borrowing from the modal.
SPA attach had no reconnect at all. Any dropped frame closed the socket,
`onClosed` fired, and the pane was destroyed permanently — the TUI
reconnects forever on the same endpoint. Session death also reached the UI
on two uncoordinated channels: the row from the events socket, the pane
from `ws.onclose`, with nothing reconciling them, so either could happen
without the other.
The catalog is now the authority. `shouldRetryClose` decides from session
status: Running means the link dropped, so retry with 500ms-to-10s
backoff; Dead means the PTY stream was sealed and nothing more is coming;
absent means it is gone. Application closes (4000+, a relayed daemon
rejection) and clean closes are never retried. The shell also closes any
pane whose session has left the catalog. The policy lives in reconnect.ts
so it is tested without a DOM.
kill_session checked `has()` before `kill()`, which only opened a window
for the liveness watcher to reap in between — turning a 404 into a 502.
`kill` reports the miss itself.
with_spawn_history logged and carried on when `Arc::get_mut` failed,
producing a daemon whose recent-dirs picker is permanently empty with
nothing to show for it. It panics now: that is a construction-order
mistake, at startup, in code that runs once.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rewrites CLAUDE.md as the current-architecture reference, drops the
registry from INSTALL/PRODUCT/DESIGN and the web README, deletes the
registry Dockerfile, and marks the dated design specs superseded where
they describe the registry, JWT auth, or cross-host attach.
The installer loses its configure step — every config field now has a
default, so a daemon starts with no config file at all.
Session tree nests by working directory only; the daemon chips in the
header become a session tally, and the spawn form loses its host picker.
API calls move from /api/daemons/:id/* to /api/dirs, /api/recent-dirs,
/api/gitroot, and /api/info.
Horizontal swipes now drive the off-canvas drawer (right opens, left closes)
instead of triggering browser back/forward. Gated to mobile and to clearly
horizontal gestures so vertical terminal scrolling is unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The sidebar-drawer toggle now lives left of the logo in the global header
instead of floating over the content area / tab bar. Removes the in-canvas
fab and tab-bar menu button and their styles.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Below 860px the split tree projects to a one-pane-at-a-time pill strip (no
splits/drag/resize — desktop only); the tree is untouched so widening restores
the splits. Only the visible pane's session stays live; the rest park. The
sidebar becomes an off-canvas drawer toggled by a ☰ in the tab bar, with a
backdrop; tapping a session opens it and closes the drawer. Re-renders on the
breakpoint crossing without losing sessions.
Closes#35. Part of epic #37.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Save the layout tree (tabs + splits + focus + ratios) to localStorage,
debounced, on every structural/focus/resize change; restore it on boot. Load
rebuilds with fresh node ids and defensively drops malformed data — a corrupt or
versioned-out blob falls back to a single empty tab. Sessions that no longer
exist when restored aren't pruned eagerly (the session list isn't known at
boot); their panes attach, the broker closes the dead socket, and the pane
auto-closes.
Closes#34. Part of epic #37.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Render the content pane as tabs of nested split trees, each session leaf a live
attach mounted through the multi-mount pool:
- #30 arbitrarily-nested H/V splits with mouse-resizable dividers; per-pane
⏻/▥/▤/✕ tools; closing a pane collapses its parent split.
- #31 tabs, each holding its own tree; add / switch / close.
- #32 drag-to-dock: drag a pane title or a sidebar session onto a pane for a
5-zone dock (edges split, centre swaps/replaces); a sidebar drag moves an
already-open session.
- #33 focused pane at full strength, others dim; sidebar shows focused (loud)
vs open-elsewhere (quiet) with a stable per-session colour mapping rows to
panes.
Opening a session fills the focused pane (single-pane parity by default);
multi-pane is opt-in via split / drag / tabs. Panes auto-close when their
session ends (guarded against eviction and intentional close). The steady
"connected" label is hidden as noise.
Closes#30, #31, #32, #33. Part of epic #37.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rework the pool from single-active to a mounted-set model: several connections
can be visible at once (one per leaf), sessions no longer shown are parked
(still streaming) and bounded by an LRU buffer that never evicts visible panes.
Keep activate/parkActive/active() as compat shims and an onFocusChange/
onRequestClose surface; the HUD keeps working via active() -> focused().
main.ts now derives the single-pane view from a one-leaf layout tree via
setVisible/setFocus instead of calling activate directly — no behaviour change,
but the multi-mount path is now exercised end to end.
Refs #29 (epic #37).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A game-HUD-style debug overlay (FPS-counter for sessions) that surfaces the
daemon-inferred fields from #18 — activity, foreground, idle time, status — on
the active session, live off the existing events WS. Lets us validate the
inferred data before designing any "real" UI, and stays useful as a permanent
debugging tool.
- Off by default; toggled via Ctrl+Shift+D (persisted in localStorage) or `?hud`
in dev (debug.ts).
- Fixed bottom-right overlay, pointer-events:none so it never blocks the
terminal; activity value is color-coded (active/awaiting/idle).
- Generic: renders every non-client-owned Session field, so a new daemon-derived
field (#19 `detail`, later metrics) surfaces with no overlay change. Idle time
re-renders on a 1s ticker; values update on SessionUpdated.
- Pure consumer-side: no daemon/protocol/registry changes. The `?mock` fixture
seeds varied activity so the HUD can be reviewed without a daemon.
Closes#20
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a web app manifest, icons, and a runtime-caching service worker so the
UI can be installed on Android Chrome (and added to the iOS home screen).
- web/public/manifest.webmanifest: standalone display, theme/bg colors, icon set
- web/public/icon.svg + generated icon-192/512 and apple-touch-icon PNGs
- web/public/sw.js: caches the static shell for offline launch; never touches
/api (HTTP or WS), so live-daemon behavior is unchanged
- index.html: link manifest + apple-touch-icon, iOS standalone metas
- main.ts: register the service worker (production only)
Assets land in web/dist via Vite's public/ dir and are embedded by the
existing include_dir! in maestro-web, so no Rust changes are needed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rework web/src/spawn.ts from raw text boxes into a low-friction, tile-first
flow: pick host tile → pick command tile → pick recent dir → Spawn, name
already filled.
- Command tiles from the daemon's advertised presets (GET /api/web/info, #26),
with built-in fallback; `custom` reveals the free-text command input.
- Host tiles replace the <select> (label hostname, sub daemon_id); most-
recently-spawned-on host sorts first + pre-selected; overflow behind "More…".
- Recent-dirs picker per host (GET /api/daemons/:id/recent-dirs, #26), rows show
dir + last command, re-queried on host change; picking a dir can pre-select
its last command. Persistent "Other directory…" drops to the raw input +
NAP-329 autocomplete; empty history prefills raw with home_dir.
- Worktree by branch alone: toggle infers base_repo via /gitroot from the
chosen dir (#26); non-repo dir disables the toggle with an inline hint;
WorktreeSpec.base_repo filled client-side (wire unchanged).
- Name field prefilled with a generated cyberpunk handle (names.ts mirrors the
maestro-names wordlists); ↻ rerolls.
Adds api.ts getWebInfo/recentDirs/gitRoot, the WebInfo/SpawnPreset/RecentDirs/
GitRoot types, and stubs the new endpoints in the `?mock&spawn` review fixture.
Closes#27
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On touch devices the soft keyboard omits the keys terminals depend on
(Esc, Tab, Ctrl, arrows, Home/End, PgUp/PgDn). Add a Termux-style
scrollable key bar to the attach view that injects the right xterm
sequences into the existing PTY input path.
- Ctrl is a one-shot sticky modifier: arm it and the next character —
tapped here or typed on the soft keyboard — is sent as its control
code, so Ctrl+C and friends work on mobile.
- Taps preventDefault on pointerdown so the terminal keeps focus and the
keyboard stays open.
- Bar is revealed only on coarse-pointer devices; a physical keyboard
already has these keys.
- Shell height now tracks the visual viewport (installViewportSizing) so
the bar lands above the soft keyboard instead of behind it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>