Compare commits

...
Author SHA1 Message Date
Miguel Palhas babdc678e0 feat(maestro): port spawn dialog to React (#248)
Ports the vanilla spawn form/modal to src/components/spawn/:
SpawnModal, SpawnForm, DirAutocomplete, BranchPicker, and a
useFastForward hook for the one-keystroke spawn. Pure logic modules
(spawnForm.ts's buildSpawnRequest, fastForward.ts, spawnSimilar.ts,
projectList.ts, toolIcon.ts) are reused as-is rather than re-derived.

Exports supportsStructuredView from spawnForm.ts so the React form can
share the structured-view-eligibility check with the vanilla one.
2026-09-09 01:04:05 +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
49 changed files with 7014 additions and 59 deletions
+11 -14
View File
@@ -80,24 +80,21 @@ mod tests {
}
#[test]
fn non_repo_dir_is_none() {
// A temp dir with no `.git` anywhere up the chain. We need a path whose
// *entire* ancestor chain is `.git`-free, but `/tmp` itself may live
// inside a repo (CI containers, sandbox VMs). Work around this by
// creating a tmpfs-like isolated root via an in-memory mount when
// possible, or — more portably — by asserting the result is NOT inside
// the temp dir we control rather than asserting None outright.
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();
// If an ancestor of /tmp has .git, find_root will return that — but it
// must never return anything at or below our tempdir.
let result = find_root(&sub);
if let Some(ref root) = result {
assert!(
match find_root(&sub) {
None => {} // expected on most hosts
Some(root) => assert!(
!root.starts_with(tmp.path()),
"find_root returned {root:?} which is inside our .git-free tempdir",
);
"found .git inside the fixture at {root:?}, but none was created"
),
}
}
+4
View File
@@ -805,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) => {
+9 -6
View File
@@ -1020,12 +1020,15 @@ async fn gitroot_probes_path() {
.json()
.await
.unwrap();
if let Some(serde_json::Value::String(root)) = resp.get("root") {
let root = std::path::Path::new(root);
assert!(
!root.starts_with(tmp.path()),
"gitroot returned {root:?} inside our .git-free tempdir",
);
// 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:?}"
),
}
}
+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>
);
}
@@ -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,79 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { BranchInfo } from "../../types.js";
import { BranchPicker, type BranchPickerProps } from "./BranchPicker.js";
afterEach(cleanup);
function props(over: Partial<BranchPickerProps> = {}): BranchPickerProps {
return {
enabled: true,
onToggleEnabled: vi.fn(),
hint: null,
baseRepo: "/repo",
mode: "new",
onModeChange: vi.fn(),
newBranch: "",
onNewBranchChange: vi.fn(),
branches: undefined,
existingBranch: "",
onExistingBranchChange: vi.fn(),
...over,
};
}
describe("BranchPicker", () => {
it("shows the hint instead of the body while unresolved", () => {
render(<BranchPicker {...props({ hint: "Choose a working directory first." })} />);
expect(screen.getByText("Choose a working directory first.")).not.toBeNull();
expect(screen.queryByText("New branch name")).toBeNull();
});
it("shows the new-branch field by default and the repo line once resolved", () => {
render(<BranchPicker {...props()} />);
expect(screen.getByText("repo: /repo")).not.toBeNull();
expect(screen.getByPlaceholderText("feat/foo")).not.toBeNull();
expect(screen.queryByText("Pick a branch")).toBeNull();
});
it("switches to the existing-branch select and reports the mode change", () => {
const onModeChange = vi.fn();
render(<BranchPicker {...props({ onModeChange })} />);
fireEvent.click(screen.getByLabelText("Existing branch"));
expect(onModeChange).toHaveBeenCalledWith("existing");
});
it("disables a branch already held by another worktree, and the main checkout's HEAD", () => {
const branches: BranchInfo[] = [
{ name: "main", is_head: true },
{ name: "feat/a", is_head: false, worktree: "/repos/a" },
{ name: "feat/b", is_head: false },
];
render(<BranchPicker {...props({ mode: "existing", branches })} />);
const main = screen.getByRole("option", { name: /main — held by the main checkout/ }) as HTMLOptionElement;
const a = screen.getByRole("option", { name: /feat\/a — held by \/repos\/a/ }) as HTMLOptionElement;
const b = screen.getByRole("option", { name: "feat/b" }) as HTMLOptionElement;
expect(main.disabled).toBe(true);
expect(a.disabled).toBe(true);
expect(b.disabled).toBe(false);
});
it("shows a placeholder option when no local branches were found", () => {
render(<BranchPicker {...props({ mode: "existing", branches: [] })} />);
expect(screen.getByText("No local branches found.")).not.toBeNull();
});
it("toggles the worktree checkbox", () => {
const onToggleEnabled = vi.fn();
render(<BranchPicker {...props({ onToggleEnabled })} />);
fireEvent.click(screen.getByLabelText("Create git worktree"));
expect(onToggleEnabled).toHaveBeenCalledWith(false);
});
});
+119
View File
@@ -0,0 +1,119 @@
// The spawn form's worktree section (#61): create-worktree toggle, then
// (once a base repo is known) new-branch-vs-existing-branch, mirroring
// spawnForm.ts's `.adv-group`/`.wt-body`/`.wt-mode` markup.
import type { BranchInfo } from "../../types.js";
import type { WorktreeBranchMode } from "../../spawnForm.js";
export interface BranchPickerProps {
enabled: boolean;
onToggleEnabled(enabled: boolean): void;
/** Status line shown in place of the body — "Checking…", "not a repo",
* "choose a directory first". `null` once the body itself can render. */
hint: string | null;
/** The repo a worktree would branch from, once resolved. */
baseRepo: string | null;
mode: WorktreeBranchMode;
onModeChange(mode: WorktreeBranchMode): void;
newBranch: string;
onNewBranchChange(value: string): void;
/** This cwd's local branches, or `undefined` while unknown/not yet loaded. */
branches: BranchInfo[] | undefined;
existingBranch: string;
onExistingBranchChange(value: string): void;
}
export function BranchPicker(props: BranchPickerProps) {
return (
<div
className={`flex flex-col gap-sp-3 border-l-2 pl-sp-3 ${
props.enabled ? "border-accent" : "border-border"
}`}
>
<label className="flex cursor-pointer items-center gap-sp-2 text-fs-14 text-ink">
<input
type="checkbox"
data-role="toggle"
className="size-[18px] accent-accent"
checked={props.enabled}
onChange={(e) => props.onToggleEnabled(e.currentTarget.checked)}
/>
Create git worktree
</label>
{props.hint && <p className="m-0 text-fs-12 text-muted">{props.hint}</p>}
{props.enabled && !props.hint && (
<div className="flex flex-col gap-sp-3">
{props.baseRepo && (
<p className="m-0 font-mono text-fs-12 text-muted">repo: {props.baseRepo}</p>
)}
<div role="radiogroup" aria-label="Branch source" className="flex gap-sp-4">
<label className="flex cursor-pointer items-center gap-sp-2 text-fs-14 text-ink">
<input
type="radio"
name="worktree_branch_mode"
checked={props.mode === "new"}
onChange={() => props.onModeChange("new")}
/>
New branch
</label>
<label className="flex cursor-pointer items-center gap-sp-2 text-fs-14 text-ink">
<input
type="radio"
name="worktree_branch_mode"
checked={props.mode === "existing"}
onChange={() => props.onModeChange("existing")}
/>
Existing branch
</label>
</div>
{props.mode === "new" ? (
<label className="flex flex-col gap-1 text-fs-12 text-muted">
New branch name
<input
name="worktree_branch"
type="text"
autoComplete="off"
placeholder="feat/foo"
className="w-full rounded-r-2 border border-border bg-surface-2 px-sp-3 py-2 font-mono text-fs-15 text-ink"
value={props.newBranch}
onChange={(e) => props.onNewBranchChange(e.currentTarget.value)}
/>
</label>
) : (
<label className="flex flex-col gap-1 text-fs-12 text-muted">
Pick a branch
<select
className="w-full rounded-r-2 border border-border bg-surface-2 px-sp-3 py-2 font-mono text-fs-15 text-ink"
value={props.existingBranch}
onChange={(e) => props.onExistingBranchChange(e.currentTarget.value)}
>
{branchOptions(props.branches)}
</select>
</label>
)}
</div>
)}
</div>
);
}
function branchOptions(branches: BranchInfo[] | undefined) {
if (!branches || branches.length === 0) {
return <option value="">No local branches found.</option>;
}
return branches.map((b) => {
// A branch already checked out anywhere stays visible but unpickable —
// git refuses a second checkout. That includes the main checkout's own
// HEAD, which is commonly `main` and would otherwise be the default pick,
// making every existing-branch spawn fail (#54 review).
const held = b.worktree ?? (b.is_head ? "the main checkout" : null);
return (
<option key={b.name} value={b.name} disabled={held !== null} title={held ?? ""}>
{held ? `${b.name} — held by ${held}` : b.name}
</option>
);
});
}
@@ -0,0 +1,109 @@
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useState } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { DirAutocomplete } from "./DirAutocomplete.js";
vi.mock("../../api.js", () => ({
listDir: vi.fn(),
}));
import { listDir } from "../../api.js";
const mockListDir = vi.mocked(listDir);
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
function Harness({ initial = "" }: { initial?: string }) {
const [value, setValue] = useState(initial);
return <DirAutocomplete value={value} onChange={setValue} placeholder="cwd" />;
}
// `fireEvent.focus`/`.blur` dispatch the event without moving the browser's
// real focus, so `document.activeElement` (which the dropdown's fetch guards
// against) never changes — calling the DOM method directly does both.
function focus(el: HTMLElement): void {
act(() => el.focus());
}
function blur(el: HTMLElement): void {
act(() => el.blur());
}
describe("DirAutocomplete", () => {
it("lists entries matching the trailing segment on focus", async () => {
mockListDir.mockResolvedValue({ path: "/home/n", entries: ["alpha", "beta"] });
render(<Harness initial="/home/n/al" />);
focus(screen.getByPlaceholderText("cwd"));
expect(await screen.findByRole("option", { name: "alpha/" })).not.toBeNull();
expect(screen.queryByRole("option", { name: "beta/" })).toBeNull();
});
it("Tab commits the highlighted entry and does not close the field", async () => {
mockListDir.mockResolvedValue({ path: "/home/n", entries: ["alpha"] });
render(<Harness initial="/home/n/a" />);
const input = screen.getByPlaceholderText("cwd") as HTMLInputElement;
focus(input);
await screen.findByRole("option", { name: "alpha/" });
fireEvent.keyDown(input, { key: "Tab" });
expect(input.value).toBe("/home/n/alpha/");
});
it("arrows move the highlight and Enter accepts it without submitting", async () => {
mockListDir.mockResolvedValue({ path: "/home/n", entries: ["alpha", "beta"] });
render(<Harness initial="/home/n/" />);
const input = screen.getByPlaceholderText("cwd") as HTMLInputElement;
focus(input);
await screen.findByRole("option", { name: "alpha/" });
fireEvent.keyDown(input, { key: "ArrowDown" });
const notPrevented = fireEvent.keyDown(input, { key: "Enter" });
expect(input.value).toBe("/home/n/beta/");
expect(notPrevented).toBe(false); // preventDefault called -> event "processed"
});
it("Escape closes the dropdown and stops the key from bubbling", async () => {
mockListDir.mockResolvedValue({ path: "/home/n", entries: ["alpha"] });
const onKeyDown = vi.fn();
render(
<div onKeyDown={onKeyDown}>
<Harness initial="/home/n/a" />
</div>,
);
const input = screen.getByPlaceholderText("cwd");
focus(input);
await screen.findByRole("option", { name: "alpha/" });
fireEvent.keyDown(input, { key: "Escape" });
await waitFor(() => expect(screen.queryByRole("option")).toBeNull());
expect(onKeyDown).not.toHaveBeenCalled();
});
it("does not reopen on refocus until the operator types again", async () => {
mockListDir.mockResolvedValue({ path: "/home/n", entries: ["alpha"] });
render(<Harness initial="/home/n/a" />);
const input = screen.getByPlaceholderText("cwd");
focus(input);
await screen.findByRole("option", { name: "alpha/" });
fireEvent.keyDown(input, { key: "Escape" });
await waitFor(() => expect(screen.queryByRole("option")).toBeNull());
blur(input);
focus(input);
await new Promise((r) => setTimeout(r, 10));
expect(screen.queryByRole("option")).toBeNull();
fireEvent.change(input, { target: { value: "/home/n/al" } });
expect(await screen.findByRole("option", { name: "alpha/" })).not.toBeNull();
});
});
@@ -0,0 +1,256 @@
// React port of ../../dirAutocomplete.ts: shell-style directory autocomplete
// on a path <input>. Lists one directory at a time over `GET /api/dirs`,
// caches per directory, and filters the trailing path segment locally.
//
// Keyboard model mirrors `cd dir/sub<tab>`:
// Tab commit the highlighted entry and let focus move on; with nothing
// highlighted, plain tab navigation
// ↑/↓ move the highlight
// Enter accept the highlighted entry (and suppress form submit); with
// nothing highlighted, bubble to the form unprevented
// Esc close the dropdown, refocus the input, and stop the key from
// bubbling — so dismissing the popup can't also close a parent modal
//
// Once dismissed (Esc or blur) the popup stays closed until the operator
// types again — refocusing alone doesn't reopen it.
import { useCallback, useEffect, useRef, useState, type InputHTMLAttributes, type Ref } from "react";
import { createPortal } from "react-dom";
import { listDir } from "../../api.js";
import { splitPath } from "../../paths.js";
/** Most entries rendered at once — a long list is unhelpful and slow. */
const MAX_VISIBLE = 60;
/** Debounce before a network fetch when the input crosses into a new directory. */
const FETCH_DEBOUNCE_MS = 90;
export interface DirAutocompleteProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, "value" | "onChange"> {
value: string;
onChange(value: string): void;
/** Access to the underlying `<input>` — e.g. to focus/select it. */
inputRef?: Ref<HTMLInputElement>;
}
function assignRef(ref: Ref<HTMLInputElement> | undefined, el: HTMLInputElement | null): void {
if (!ref) return;
if (typeof ref === "function") ref(el);
else (ref as { current: HTMLInputElement | null }).current = el;
}
export function DirAutocomplete({ value, onChange, inputRef, ...inputProps }: DirAutocompleteProps) {
const localRef = useRef<HTMLInputElement | null>(null);
const cache = useRef(new Map<string, string[]>());
const fetchSeq = useRef(0);
const debounceTimer = useRef<number | undefined>(undefined);
const dismissedRef = useRef(false);
const [filtered, setFiltered] = useState<string[]>([]);
const [highlight, setHighlight] = useState(-1);
const [open, setOpen] = useState(false);
const [rect, setRect] = useState<{ left: number; top: number; width: number } | null>(null);
const setInputRef = useCallback(
(el: HTMLInputElement | null) => {
localRef.current = el;
assignRef(inputRef, el);
},
[inputRef],
);
const reposition = useCallback(() => {
const el = localRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
setRect({ left: r.left, top: r.bottom + 4, width: r.width });
}, []);
const close = useCallback(() => {
setOpen(false);
setFiltered([]);
setHighlight(-1);
}, []);
const dismiss = useCallback(() => {
dismissedRef.current = true;
close();
}, [close]);
const applyFilter = useCallback(
(entries: string[], prefix: string) => {
// A fetch can outlive the dismissal it was meant to serve (Tab-commit
// re-arms a refresh, then blur dismisses 120ms later). Showing it now
// would anchor a dropdown under an unfocused input nothing closes.
if (dismissedRef.current || document.activeElement !== localRef.current) {
close();
return;
}
const lower = prefix.toLowerCase();
const next = entries.filter((e) => e.toLowerCase().startsWith(lower));
setFiltered(next);
setHighlight(next.length > 0 ? 0 : -1);
if (next.length === 0) {
close();
return;
}
setOpen(true);
reposition();
},
[close, reposition],
);
const refresh = useCallback(
(raw: string) => {
const { dir, prefix } = splitPath(raw);
const cached = cache.current.get(dir);
if (cached) {
applyFilter(cached, prefix);
return;
}
const seq = ++fetchSeq.current;
listDir(dir)
.then((listing) => {
cache.current.set(dir, listing.entries);
if (seq !== fetchSeq.current) return; // a newer fetch superseded this one
const cur = splitPath(localRef.current?.value ?? raw);
if (cur.dir === dir) applyFilter(listing.entries, cur.prefix);
})
.catch(() => {
if (seq === fetchSeq.current) close();
});
},
[applyFilter, close],
);
const scheduleRefresh = useCallback(
(raw: string) => {
window.clearTimeout(debounceTimer.current);
debounceTimer.current = window.setTimeout(() => refresh(raw), FETCH_DEBOUNCE_MS);
},
[refresh],
);
/** Complete `name` as the next path segment and append `/` so the child
* directory's listing loads on the next interaction — the drill-in
* gesture. Unlike `accept`, doesn't steal focus: Tab uses this to commit
* and move to the next field in one gesture. */
const commit = useCallback(
(name: string) => {
const { dir } = splitPath(value);
onChange(`${dir}${name}/`);
},
[value, onChange],
);
const accept = useCallback(
(name: string) => {
commit(name);
localRef.current?.focus();
},
[commit],
);
useEffect(() => {
if (!open) return;
window.addEventListener("scroll", reposition, true);
window.addEventListener("resize", reposition);
return () => {
window.removeEventListener("scroll", reposition, true);
window.removeEventListener("resize", reposition);
};
}, [open, reposition]);
useEffect(() => () => window.clearTimeout(debounceTimer.current), []);
return (
<>
<input
{...inputProps}
ref={setInputRef}
value={value}
autoComplete="off"
role="combobox"
aria-expanded={open}
aria-autocomplete="list"
onChange={(e) => {
dismissedRef.current = false;
const next = e.currentTarget.value;
onChange(next);
scheduleRefresh(next);
}}
onFocus={(e) => {
inputProps.onFocus?.(e);
if (dismissedRef.current) return;
refresh(value);
}}
onBlur={(e) => {
inputProps.onBlur?.(e);
// Delay so a dropdown mousedown can complete first.
window.setTimeout(dismiss, 120);
}}
onKeyDown={(e) => {
inputProps.onKeyDown?.(e);
if (e.defaultPrevented) return;
if (e.key === "Escape") {
if (open) {
e.preventDefault();
e.stopPropagation();
dismiss();
localRef.current?.focus();
}
return;
}
if (e.key === "Tab") {
if (!open || highlight < 0 || highlight >= filtered.length) return; // plain tab navigation
commit(filtered[highlight]); // no preventDefault — focus moves to the next field
return;
}
if (!open) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setHighlight((h) => Math.min(h + 1, filtered.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHighlight((h) => Math.max(h - 1, 0));
} else if (e.key === "Enter" && highlight >= 0 && highlight < filtered.length) {
e.preventDefault(); // accept the entry instead of submitting the form
accept(filtered[highlight]);
}
}}
/>
{open &&
rect &&
createPortal(
<ul
role="listbox"
className="dir-ac fixed z-modal max-h-[240px] overflow-y-auto rounded-r-2 border border-border-strong bg-surface py-sp-1 font-mono shadow-2"
style={{ left: rect.left, top: rect.top, width: rect.width }}
>
{filtered.slice(0, MAX_VISIBLE).map((name, i) => (
<li
key={name}
role="option"
aria-selected={i === highlight}
className={`cursor-pointer truncate px-sp-3 py-1 text-fs-12 text-ink ${
i === highlight ? "bg-accent-tint" : ""
}`}
onMouseDown={(e) => {
e.preventDefault(); // accept before the input's blur fires
accept(name);
}}
>
{name}/
</li>
))}
{filtered.length > MAX_VISIBLE && (
<li className="px-sp-3 py-1 text-fs-11 text-faint">
+{filtered.length - MAX_VISIBLE} more — keep typing to narrow
</li>
)}
</ul>,
document.body,
)}
</>
);
}
+383
View File
@@ -0,0 +1,383 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { BranchInfo, DaemonInfo, Project, Session, SpawnPreset } from "../../types.js";
import { SpawnForm } from "./SpawnForm.js";
vi.mock("../../api.js", () => ({
createSession: vi.fn(),
fetchBranches: vi.fn(),
fetchNames: vi.fn(),
getDaemonInfo: vi.fn(),
gitRoot: vi.fn(),
listDir: vi.fn(),
projects: vi.fn(),
}));
import {
createSession,
fetchBranches,
fetchNames,
getDaemonInfo,
gitRoot,
listDir,
projects as fetchProjects,
} from "../../api.js";
const mockCreateSession = vi.mocked(createSession);
const mockFetchBranches = vi.mocked(fetchBranches);
const mockFetchNames = vi.mocked(fetchNames);
const mockGetDaemonInfo = vi.mocked(getDaemonInfo);
const mockGitRoot = vi.mocked(gitRoot);
const mockListDir = vi.mocked(listDir);
const mockFetchProjects = vi.mocked(fetchProjects);
const presets: SpawnPreset[] = [
{ id: "claude", label: "Claude", command: "claude" },
{ id: "opencode", label: "opencode", command: "opencode" },
{ id: "zsh", label: "Shell", command: "zsh" },
{ id: "custom", label: "Custom…", command: "" },
];
function info(over: Partial<DaemonInfo> = {}): DaemonInfo {
return { hostname: "h", version: "1", home_dir: "/home/n", presets, ...over };
}
function project(over: Partial<Project> = {}): Project {
return { path: "/repos/p", name: "p", last_activity: new Date().toISOString(), ...over };
}
function session(over: Partial<Session> = {}): Session {
return {
id: "s1",
cwd: "/repos/p",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-01-01T00:00:00Z",
...over,
};
}
/** Defaults every test starts from: one known project, no branches, a fresh
* generated handle. Individual tests override what they care about. */
function stub(over: { projects?: Project[]; info?: DaemonInfo; names?: string[]; branches?: BranchInfo[] } = {}) {
mockGetDaemonInfo.mockResolvedValue(over.info ?? info());
mockFetchProjects.mockResolvedValue(over.projects ?? [project()]);
mockFetchNames.mockResolvedValue({ names: over.names ?? ["amber-fox"] });
mockFetchBranches.mockResolvedValue(over.branches ?? []);
mockGitRoot.mockResolvedValue({});
mockListDir.mockResolvedValue({ path: "", entries: [] });
}
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
async function settle() {
await waitFor(() => expect(screen.queryByText("Loading projects…")).toBeNull());
}
/** Everything worktree/name/structured-related lives behind the "Advanced"
* disclosure, collapsed by default (#93) — mirrors the vanilla form exactly. */
function openAdvanced() {
fireEvent.click(screen.getByRole("button", { name: /Advanced/ }));
}
describe("SpawnForm harness picker", () => {
it("selects the first non-custom preset by default and reveals the custom input on Custom…", async () => {
stub();
render(<SpawnForm onSuccess={vi.fn()} />);
await settle();
expect(screen.getByRole("button", { name: /^Claude/ }).className).toContain("border-accent");
fireEvent.click(screen.getByRole("button", { name: "Custom…" }));
expect(screen.getByPlaceholderText("bash -l").hidden).toBe(false);
});
it("resolves a seeded command to its matching preset", async () => {
stub();
render(<SpawnForm seed={{ command: "opencode" }} onSuccess={vi.fn()} />);
await settle();
const opencode = screen.getByRole("button", { name: /opencode/ });
expect(opencode.className).toContain("border-accent");
});
it("drops an unmatched seeded command into the custom field", async () => {
stub();
render(<SpawnForm seed={{ command: "hx src/" }} onSuccess={vi.fn()} />);
await settle();
const raw = screen.getByPlaceholderText("bash -l") as HTMLInputElement;
expect(raw.hidden).toBe(false);
expect(raw.value).toBe("hx src/");
});
});
describe("SpawnForm project picker", () => {
it("lists discovered projects and reveals the worktree step on pick", async () => {
stub({ projects: [project({ path: "/repos/a", name: "a" }), project({ path: "/repos/b", name: "b" })] });
render(<SpawnForm onSuccess={vi.fn()} />);
await settle();
openAdvanced();
expect(screen.queryByText("Worktree")).toBeNull();
fireEvent.click(screen.getByText("a"));
expect(screen.getByText("Worktree")).not.toBeNull();
expect(screen.getByText("+ new").closest("button")?.getAttribute("data-active")).toBe("true");
});
it("filters the project grid by name", async () => {
stub({ projects: [project({ path: "/repos/alpha", name: "alpha" }), project({ path: "/repos/beta", name: "beta" })] });
render(<SpawnForm onSuccess={vi.fn()} />);
await settle();
fireEvent.change(screen.getByPlaceholderText("Filter projects…"), { target: { value: "alp" } });
expect(screen.getByText("alpha")).not.toBeNull();
expect(screen.queryByText("beta")).toBeNull();
});
it("falls back to the raw directory input when no projects are discovered", async () => {
stub({ projects: [] });
render(<SpawnForm onSuccess={vi.fn()} />);
await settle();
expect((screen.getByPlaceholderText("/home/user/project") as HTMLInputElement).value).toBe("/home/n");
});
it("Other directory… clears the picker and opens the raw input", async () => {
stub({ projects: [project({ path: "/repos/a", name: "a" })] });
render(<SpawnForm onSuccess={vi.fn()} />);
await settle();
fireEvent.click(screen.getByText("a"));
expect(screen.getByText("a").closest("button")?.getAttribute("data-active")).toBe("true");
fireEvent.click(screen.getByText("Other directory…"));
expect(screen.getByText("a").closest("button")?.getAttribute("data-active")).toBeNull();
expect((screen.getByPlaceholderText("/home/user/project") as HTMLInputElement).value).toBe("/repos/a");
});
});
describe("SpawnForm worktree defaults", () => {
it("defaults the toggle on for a picked project and infers the repo without a gitroot probe", async () => {
stub({ projects: [project({ path: "/repos/a", name: "a" })] });
render(<SpawnForm onSuccess={vi.fn()} />);
await settle();
openAdvanced();
fireEvent.click(screen.getByText("a"));
expect(screen.getByText("repo: /repos/a")).not.toBeNull();
expect(mockGitRoot).not.toHaveBeenCalled();
});
it("defaults the toggle off for a project that is itself a worktree (#91)", async () => {
stub({
projects: [
project({ path: "/repos/main", name: "main" }),
project({ path: "/repos/wt", name: "wt", is_worktree: true, base_repo: "/repos/main" }),
],
});
render(<SpawnForm onSuccess={vi.fn()} />);
await settle();
openAdvanced();
fireEvent.click(screen.getByText("main"));
fireEvent.click(screen.getByText("wt"));
expect((screen.getByLabelText("Create git worktree") as HTMLInputElement).checked).toBe(false);
});
it("still probes gitroot for a free-text directory", async () => {
stub();
mockGitRoot.mockResolvedValue({ root: "/repos/found" });
render(<SpawnForm onSuccess={vi.fn()} />);
await settle();
openAdvanced();
fireEvent.click(screen.getByText("Other directory…"));
fireEvent.change(screen.getByPlaceholderText("/home/user/project"), {
target: { value: "/repos/found/sub" },
});
await waitFor(() => expect(mockGitRoot).toHaveBeenCalledWith("/repos/found/sub"));
expect(await screen.findByText("repo: /repos/found")).not.toBeNull();
});
it("populates existing branches, disabling the one held by another worktree", async () => {
stub({
projects: [project({ path: "/repos/a", name: "a" })],
branches: [
{ name: "main", is_head: true },
{ name: "feat/x", is_head: false, worktree: "/repos/other" },
{ name: "feat/y", is_head: false },
],
});
render(<SpawnForm onSuccess={vi.fn()} />);
await settle();
openAdvanced();
fireEvent.click(screen.getByText("a"));
fireEvent.click(screen.getByLabelText("Existing branch"));
const y = await screen.findByRole("option", { name: "feat/y" });
expect((y as HTMLOptionElement).disabled).toBe(false);
expect((screen.getByRole("option", { name: /main — held by the main checkout/ }) as HTMLOptionElement).disabled).toBe(
true,
);
});
it("submits the first pickable branch when the operator never opens the select", async () => {
stub({
projects: [project({ path: "/repos/a", name: "a" })],
branches: [
{ name: "main", is_head: true },
{ name: "feat/y", is_head: false },
],
});
mockCreateSession.mockResolvedValue(session());
const onSuccess = vi.fn();
render(<SpawnForm onSuccess={onSuccess} />);
await settle();
openAdvanced();
fireEvent.click(screen.getByText("a"));
fireEvent.click(screen.getByLabelText("Existing branch"));
await screen.findByRole("option", { name: "feat/y" });
fireEvent.click(screen.getByRole("button", { name: "Spawn" }));
await waitFor(() => expect(onSuccess).toHaveBeenCalled());
expect(mockCreateSession).toHaveBeenCalledWith(
expect.objectContaining({ worktree: expect.objectContaining({ branch: "feat/y", create_branch: false }) }),
);
});
});
describe("SpawnForm structured view", () => {
it("defaults on for a resolved Claude Code command and off for an unsupported one", async () => {
stub();
render(<SpawnForm onSuccess={vi.fn()} />);
await settle();
openAdvanced();
expect((screen.getByLabelText("Structured view") as HTMLInputElement).checked).toBe(true);
fireEvent.click(screen.getByRole("button", { name: /^Shell/ }));
expect(screen.queryByLabelText("Structured view")).toBeNull();
});
it("keeps an unchecked box unchecked across a command change between two supported tools", async () => {
stub();
render(<SpawnForm onSuccess={vi.fn()} />);
await settle();
openAdvanced();
fireEvent.click(screen.getByLabelText("Structured view"));
fireEvent.click(screen.getByRole("button", { name: /opencode/ }));
fireEvent.click(screen.getByRole("button", { name: /^Claude/ }));
expect((screen.getByLabelText("Structured view") as HTMLInputElement).checked).toBe(false);
});
});
describe("SpawnForm submit", () => {
it("builds and posts the request, reporting the created session", async () => {
stub({ projects: [project({ path: "/repos/a", name: "a" })] });
const created = session();
mockCreateSession.mockResolvedValue(created);
const onSuccess = vi.fn();
render(<SpawnForm onSuccess={onSuccess} />);
await settle();
fireEvent.click(screen.getByText("a"));
fireEvent.click(screen.getByRole("button", { name: "Spawn" }));
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(created));
expect(mockCreateSession).toHaveBeenCalledWith(
expect.objectContaining({ cwd: "/repos/a", command: "claude" }),
);
});
it("shows the daemon's rejection and leaves the form open", async () => {
stub({ projects: [project({ path: "/repos/a", name: "a" })] });
mockCreateSession.mockRejectedValue(new Error("cwd is not a directory"));
render(<SpawnForm onSuccess={vi.fn()} />);
await settle();
fireEvent.click(screen.getByText("a"));
fireEvent.click(screen.getByRole("button", { name: "Spawn" }));
expect(await screen.findByText("cwd is not a directory")).not.toBeNull();
});
it("rejects an existing-branch worktree with nothing picked", async () => {
stub({ projects: [project({ path: "/repos/a", name: "a" })], branches: [] });
render(<SpawnForm onSuccess={vi.fn()} />);
await settle();
openAdvanced();
fireEvent.click(screen.getByText("a"));
fireEvent.click(screen.getByLabelText("Existing branch"));
await screen.findByText("No local branches found.");
fireEvent.click(screen.getByRole("button", { name: "Spawn" }));
expect(await screen.findByText("Pick an existing branch.")).not.toBeNull();
expect(mockCreateSession).not.toHaveBeenCalled();
});
});
describe("SpawnForm Enter-to-submit (#97)", () => {
it("submits from the session name field", async () => {
stub({ projects: [project({ path: "/repos/a", name: "a" })] });
mockCreateSession.mockResolvedValue(session());
const onSuccess = vi.fn();
render(<SpawnForm onSuccess={onSuccess} />);
await settle();
fireEvent.click(screen.getByText("a"));
openAdvanced();
fireEvent.keyDown(screen.getByPlaceholderText("named after your first prompt"), { key: "Enter" });
await waitFor(() => expect(onSuccess).toHaveBeenCalled());
});
it("first Enter picks a not-yet-active project box, the second submits", async () => {
stub({ projects: [project({ path: "/repos/a", name: "a" }), project({ path: "/repos/b", name: "b" })] });
mockCreateSession.mockResolvedValue(session());
const onSuccess = vi.fn();
render(<SpawnForm onSuccess={onSuccess} />);
await settle();
const boxB = screen.getByText("b").closest("button")!;
fireEvent.keyDown(boxB, { key: "Enter" });
expect(onSuccess).not.toHaveBeenCalled();
expect(boxB.getAttribute("data-active")).toBe("true");
fireEvent.keyDown(boxB, { key: "Enter" });
await waitFor(() => expect(onSuccess).toHaveBeenCalled());
});
it("toggles the worktree checkbox on Enter instead of submitting", async () => {
stub({ projects: [project({ path: "/repos/a", name: "a" })] });
render(<SpawnForm onSuccess={vi.fn()} />);
await settle();
openAdvanced();
fireEvent.click(screen.getByText("a"));
const checkbox = screen.getByLabelText("Create git worktree") as HTMLInputElement;
expect(checkbox.checked).toBe(true);
fireEvent.keyDown(checkbox, { key: "Enter" });
expect(checkbox.checked).toBe(false);
expect(mockCreateSession).not.toHaveBeenCalled();
});
});
+853
View File
@@ -0,0 +1,853 @@
// React port of ../../spawnForm.ts: the spawn form itself — preset tiles,
// two-step project picker, name, structured-view checkbox, worktree. Mounted
// inside SpawnModal.
//
// Working-directory picker (#95), unchanged from the vanilla form:
// 1. Step one is one box per repository (name, then its parent directory).
// A worktree is a box only when its main checkout is missing from the
// scan.
// 2. The filter keeps a repository when the query matches it or any of its
// worktrees.
// 3. Picking a box reveals step two: "+ new", then that repository's
// worktrees newest-first. "Other directory…" clears both steps.
// 4. "+ new" is the default selection — except on a box that is itself a
// linked worktree, which defaults to that worktree.
// 5. "+ new" means create: cwd is the repository, create-worktree on. An
// existing worktree means run in it: cwd is that worktree, create-worktree
// off (the daemon never adopts, docs/WORKTREES.md).
// 6. Neither step probes /api/gitroot; only the free-text hatch does.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
createSession,
fetchBranches,
fetchNames,
getDaemonInfo,
gitRoot,
projects as fetchProjects,
} from "../../api.js";
import { parentDir } from "../../paths.js";
import {
filterProjects,
groupProjects,
isStale,
sortProjects,
type ProjectGroup,
} from "../../projectList.js";
import {
buildSpawnRequest,
FALLBACK_PRESETS,
supportsStructuredView,
type SpawnFormSeed,
type SpawnFormValues,
type WorktreeBranchMode,
} from "../../spawnForm.js";
import { splitCommand, toolIconSvg, TOOL_LABELS } from "../../toolIcon.js";
import { abbreviateCwd } from "../../types.js";
import type { BranchInfo, Project, Session, SpawnPreset } from "../../types.js";
import { BranchPicker } from "./BranchPicker.js";
import { DirAutocomplete } from "./DirAutocomplete.js";
/** Quiet period after raw-path typing before `/api/gitroot` fires (#74). Must
* stay equal to `GITROOT_PROBE_DEBOUNCE_MS` in crates/maestro-tui/src/draft.rs
* and to ../../spawnForm.ts's own constant, so every client debounces alike. */
const GITROOT_PROBE_DEBOUNCE_MS = 250;
/** "new" (create a worktree) or the path of an existing worktree to run in. */
type WorktreeChoice = "new" | string;
type BaseRepoResolution =
| { kind: "disabled" }
| { kind: "no-cwd" }
| { kind: "found"; repo: string }
| { kind: "probe" };
function resolveBaseRepoFor(cwd: string, wtEnabled: boolean, discovered: Project[]): BaseRepoResolution {
if (!wtEnabled) return { kind: "disabled" };
if (!cwd) return { kind: "no-cwd" };
const project = discovered.find((p) => p.path === cwd);
if (project) return { kind: "found", repo: project.base_repo ?? project.path };
return { kind: "probe" };
}
/** Every worktree of a group, including the group's own box when the scan
* found no main checkout to nest it under. */
function worktreesOf(group: ProjectGroup): Project[] {
return [group.main, ...group.worktrees].filter((p) => p.is_worktree);
}
/** Resolve a preset id to a tool icon id — the preset's command is the source
* of truth, the same way the sidebar derives one. The "Custom…" preset (empty
* command) has no tool to show. */
function presetToolId(p: SpawnPreset): string | null {
if (!p.command) return null;
return splitCommand(p.command).tool;
}
function harnessIcon(toolId: string): string | null {
const small = toolIconSvg(toolId);
if (!small) return null;
// toolIconSvg renders at 13x13 (viewBox 0 0 16 16) for the sidebar; the
// harness card wants the same mark at 24x24 so it breathes.
return small.replace(/width="13"/g, 'width="24"').replace(/height="13"/g, 'height="24"');
}
function pickerButtonClass(active: boolean): string {
return `flex min-h-[32px] items-baseline justify-between gap-sp-3 rounded-r-2 border px-sp-3 py-1.5 text-left font-mono ${
active ? "border-accent bg-accent-tint" : "border-border bg-surface-2 hover:border-border-strong"
}`;
}
export interface SpawnFormProps {
seed?: SpawnFormSeed;
/** Fired whenever any value changes, once the form's initial seed settles. */
onChange?(values: SpawnFormValues): void;
onSuccess(session: Session): void;
}
export function SpawnForm({ seed, onChange, onSuccess }: SpawnFormProps) {
const prefill = useRef(seed ?? {}).current;
const formRef = useRef<HTMLFormElement>(null);
const filterInputRef = useRef<HTMLInputElement>(null);
const customCommandRef = useRef<HTMLInputElement>(null);
const rawCwdRef = useRef<HTMLInputElement>(null);
const destroyedRef = useRef(false);
useEffect(() => () => {
destroyedRef.current = true;
}, []);
// ---- harness / command -------------------------------------------------
const [homeDir, setHomeDir] = useState("");
const [infoLoading, setInfoLoading] = useState(true);
const [presets, setPresets] = useState<SpawnPreset[]>(FALLBACK_PRESETS);
const [selectedPresetId, setSelectedPresetId] = useState("");
const [command, setCommand] = useState("");
const [customCommand, setCustomCommand] = useState("");
const [commandTouched, setCommandTouched] = useState(false);
const [structuredView, setStructuredView] = useState(false);
const [structuredTouched, setStructuredTouched] = useState(false);
const activePreset = useMemo(() => presets.find((p) => p.id === selectedPresetId), [presets, selectedPresetId]);
const isCustomSelected = !activePreset || activePreset.command === "";
useEffect(() => {
if (commandTouched && isCustomSelected) customCommandRef.current?.focus();
}, [isCustomSelected, commandTouched]);
useEffect(() => {
let cancelled = false;
getDaemonInfo()
.then((info) => {
if (cancelled) return;
setHomeDir(info.home_dir ?? "");
if (info.presets?.length) setPresets(info.presets);
})
.catch(() => {
// keep fallbacks
})
.finally(() => {
if (!cancelled) setInfoLoading(false);
});
return () => {
cancelled = true;
};
}, []);
// Pick a sensible starting command whenever the preset list changes and the
// operator hasn't touched it yet: a seed's command (matched to a preset, or
// dropped into custom), else the first non-custom preset.
useEffect(() => {
if (commandTouched) return;
const wanted = prefill.command;
if (wanted) {
const match = presets.find((p) => p.command === wanted && p.command !== "");
if (match) {
setSelectedPresetId(match.id);
setCommand(match.command);
return;
}
const custom = presets.find((p) => p.command === "");
setCustomCommand(wanted);
setSelectedPresetId(custom ? custom.id : "custom");
setCommand(wanted);
return;
}
const first = presets.find((p) => p.command !== "");
if (first) {
setSelectedPresetId(first.id);
setCommand(first.command);
}
// prefill is captured once at construction, not a reactive dependency.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [presets, commandTouched]);
const structuredSupported = useMemo(() => supportsStructuredView(command), [command]);
useEffect(() => {
if (!structuredSupported) setStructuredView(false);
else if (!structuredTouched) setStructuredView(true);
}, [structuredSupported, structuredTouched]);
// ---- project picker ------------------------------------------------------
const [discovered, setDiscovered] = useState<Project[]>([]);
const [projectsLoading, setProjectsLoading] = useState(true);
const [filterQuery, setFilterQuery] = useState("");
const [cwd, setCwd] = useState("");
const [cwdRawVisible, setCwdRawVisible] = useState(false);
const [rawFocusToken, setRawFocusToken] = useState(0);
const [selectedGroupPath, setSelectedGroupPath] = useState<string | null>(null);
const [worktreeChoice, setWorktreeChoice] = useState<WorktreeChoice>("new");
const groups = useMemo(() => groupProjects(sortProjects(discovered, new Date())), [discovered]);
const visibleGroups = useMemo(
() => groups.filter((g) => filterProjects([g.main, ...g.worktrees], filterQuery).length > 0),
[groups, filterQuery],
);
const selectedGroup = useMemo(
() => groups.find((g) => g.main.path === selectedGroupPath) ?? null,
[groups, selectedGroupPath],
);
useEffect(() => {
let cancelled = false;
fetchProjects()
.then((list) => {
if (!cancelled) setDiscovered(list);
})
.catch(() => {
if (!cancelled) setDiscovered([]);
})
.finally(() => {
if (!cancelled) setProjectsLoading(false);
});
return () => {
cancelled = true;
};
}, []);
// ---- worktree (branch-only; base_repo inferred via scan or /gitroot) ----
const [wtEnabled, setWtEnabled] = useState(prefill.worktreeEnabled ?? true);
const [inferredBaseRepo, setInferredBaseRepo] = useState<string | null>(null);
const [wtHint, setWtHint] = useState<string | null>(null);
const [name, setName] = useState(prefill.name ?? "");
const [branchEdited, setBranchEdited] = useState(Boolean(prefill.worktreeBranchEdited));
const [branchMode, setBranchMode] = useState<WorktreeBranchMode>(prefill.worktreeBranchMode ?? "new");
const [newBranch, setNewBranch] = useState(prefill.worktreeBranch ?? "");
const [existingBranch, setExistingBranch] = useState("");
const [branchCache, setBranchCache] = useState<{ cwd: string; branches: BranchInfo[] } | null>(null);
const [advancedCollapsed, setAdvancedCollapsed] = useState(true);
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
const [ready, setReady] = useState(false);
const probeTokenRef = useRef(0);
const probeTimerRef = useRef<number | undefined>(undefined);
const applyBaseRepoResolution = useCallback((res: BaseRepoResolution) => {
switch (res.kind) {
case "disabled":
setInferredBaseRepo(null);
setWtHint(null);
return;
case "no-cwd":
setInferredBaseRepo(null);
setWtHint("Choose a working directory first.");
return;
case "found":
setInferredBaseRepo(res.repo);
setWtHint(null);
setBranchCache(null);
setExistingBranch("");
return;
case "probe":
return; // caller schedules the probe
}
}, []);
const probeGitRoot = useCallback(async (path: string) => {
const token = ++probeTokenRef.current;
if (!path) {
setInferredBaseRepo(null);
setWtHint("Choose a working directory first.");
return;
}
setWtHint("Checking for a git repository…");
let root: string | undefined;
try {
root = (await gitRoot(path)).root;
} catch {
root = undefined;
}
if (token !== probeTokenRef.current || destroyedRef.current) return; // a newer probe superseded us
if (root) {
setInferredBaseRepo(root);
setWtHint(null);
setBranchCache(null);
setExistingBranch("");
} else {
setInferredBaseRepo(null);
// Publish the untick too, or a later submit fails with an inferred repo
// that no longer matches an unticked box.
setWtEnabled(false);
setWtHint("Not inside a git repository — pick a dir in a repo to use a worktree.");
}
}, []);
const scheduleGitRootProbe = useCallback(
(path: string) => {
window.clearTimeout(probeTimerRef.current);
probeTimerRef.current = window.setTimeout(() => {
probeTimerRef.current = undefined;
void probeGitRoot(path);
}, GITROOT_PROBE_DEBOUNCE_MS);
},
[probeGitRoot],
);
useEffect(() => () => window.clearTimeout(probeTimerRef.current), []);
// Seed the form once the initial project scan and daemon info have both
// settled — mirrors createSpawnForm's `loadProjects()`. Computed from local
// values rather than reading state back, since the setState calls below
// won't have committed yet within this same effect.
const seededRef = useRef(false);
useEffect(() => {
if (infoLoading || projectsLoading || seededRef.current) return;
seededRef.current = true;
const seedCwd = prefill.cwd ?? null;
let finalCwd = "";
let finalCwdRawVisible = false;
let finalGroupPath: string | null = null;
let finalChoice: WorktreeChoice = "new";
const restore = (path: string): boolean => {
for (const group of groups) {
const wt = worktreesOf(group).find((p) => p.path === path);
const choice: WorktreeChoice | null = wt ? wt.path : group.main.path === path ? "new" : null;
if (choice === null) continue;
finalGroupPath = group.main.path;
finalChoice = choice;
return true;
}
return false;
};
if (seedCwd) {
if (prefill.cwdRawVisible === false) {
finalCwd = seedCwd;
if (!restore(seedCwd)) finalCwdRawVisible = true;
} else {
finalCwd = seedCwd;
finalCwdRawVisible = true;
}
} else if (discovered.length === 0) {
finalCwd = homeDir;
finalCwdRawVisible = true;
}
setCwd(finalCwd);
setCwdRawVisible(finalCwdRawVisible);
setSelectedGroupPath(finalGroupPath);
setWorktreeChoice(finalChoice);
const res = resolveBaseRepoFor(finalCwd, wtEnabled, discovered);
applyBaseRepoResolution(res);
if (res.kind === "probe") scheduleGitRootProbe(finalCwd);
setReady(true);
// groups/wtEnabled/discovered/homeDir are read for this one settle pass;
// re-running on their later changes would re-seed a form the operator
// has since edited.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [infoLoading, projectsLoading]);
// ---- name (also seeds the worktree branch) ------------------------------
const skipInitialBranchSync = useRef(Boolean(prefill.worktreeBranch));
useEffect(() => {
// One-time construction seeding, mirroring spawnForm.ts precedence: an
// explicit seed wins outright; otherwise the prefilled name; otherwise a
// freshly generated handle (#53).
if (prefill.worktreeBranch) return;
if (!branchEdited && branchMode === "new" && prefill.name) return; // handled below, mount-only
if (!branchEdited && branchMode === "new") {
void fetchNames(1)
.then((batch) => {
const handle = batch.names[0];
if (destroyedRef.current || !handle) return;
setNewBranch((cur) => cur || handle);
})
.catch(() => {
// the operator types their own branch
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!skipInitialBranchSync.current && !branchEdited && branchMode === "new" && name) {
setNewBranch(name);
}
skipInitialBranchSync.current = false;
// A cleared name leaves the branch on its last value; only `name` changes
// (typed or the mount-time prefill) drive this sync.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [name]);
// ---- existing-branch picker (#61): fetch once per cwd -------------------
useEffect(() => {
if (branchMode !== "existing" || !cwd || !inferredBaseRepo) return;
if (branchCache?.cwd === cwd) return;
let cancelled = false;
const target = cwd;
fetchBranches(target)
.then((branches) => {
if (cancelled || destroyedRef.current) return;
setBranchCache({ cwd: target, branches });
})
.catch(() => {
// the picker just stays empty; "new" mode still works
});
return () => {
cancelled = true;
};
}, [branchMode, cwd, inferredBaseRepo, branchCache]);
const branchesForCwd = branchCache?.cwd === cwd ? branchCache.branches : undefined;
// A native <select> defaults its displayed (and reported) value to the
// first enabled option once nothing is explicitly selected. Derived at
// render/submit time rather than synced via an effect, so there is no
// window where the control shows one branch while state still holds "".
const effectiveExistingBranch =
existingBranch || branchesForCwd?.find((b) => !b.worktree && !b.is_head)?.name || "";
// ---- emit (#observability parity with spawnForm.ts's onChange) ---------
useEffect(() => {
if (!ready) return;
onChange?.({
command,
cwd,
name,
structuredView,
autoApprove: activePreset?.auto_approve ?? false,
worktreeEnabled: wtEnabled,
worktreeBranch: branchMode === "existing" ? effectiveExistingBranch : newBranch,
worktreeBranchEdited: branchEdited,
worktreeBranchMode: branchMode,
cwdRawVisible,
});
}, [
ready,
command,
cwd,
name,
structuredView,
activePreset,
wtEnabled,
branchMode,
effectiveExistingBranch,
newBranch,
branchEdited,
cwdRawVisible,
onChange,
]);
// ---- handlers -------------------------------------------------------------
const selectPreset = useCallback(
(id: string) => {
setCommandTouched(true);
setSelectedPresetId(id);
const preset = presets.find((p) => p.id === id);
if (!preset || preset.command === "") setCommand(customCommand.trim());
else setCommand(preset.command);
},
[presets, customCommand],
);
const clearProjectSelection = useCallback(() => {
setSelectedGroupPath(null);
setWorktreeChoice("new");
}, []);
const showRawCwd = useCallback(
(prefillValue?: string, focus = true) => {
setCwdRawVisible(true);
if (prefillValue != null) setCwd(prefillValue);
clearProjectSelection();
if (focus) setRawFocusToken((t) => t + 1);
},
[clearProjectSelection],
);
useEffect(() => {
if (rawFocusToken === 0) return;
rawCwdRef.current?.focus();
rawCwdRef.current?.select();
}, [rawFocusToken]);
const applyWorktreeChoice = useCallback(
(group: ProjectGroup, choice: WorktreeChoice) => {
const target = choice === "new" ? group.main : worktreesOf(group).find((p) => p.path === choice);
if (!target) return;
setWorktreeChoice(choice);
setCwd(target.path);
setBranchCache(null);
setExistingBranch("");
if (choice === "new") {
setWtEnabled(true);
applyBaseRepoResolution({ kind: "found", repo: group.main.base_repo ?? group.main.path });
} else {
setWtEnabled(false);
applyBaseRepoResolution({ kind: "disabled" });
}
},
[applyBaseRepoResolution],
);
const selectProject = useCallback(
(group: ProjectGroup) => {
setSelectedGroupPath(group.main.path);
setCwdRawVisible(false);
// Rule 4: a box that is itself a linked worktree defaults to running there.
applyWorktreeChoice(group, group.main.is_worktree ? group.main.path : "new");
},
[applyWorktreeChoice],
);
const onWtEnabledChange = useCallback(
(next: boolean) => {
setWtEnabled(next);
const res = resolveBaseRepoFor(cwd, next, discovered);
applyBaseRepoResolution(res);
if (res.kind === "probe") scheduleGitRootProbe(cwd);
},
[cwd, discovered, applyBaseRepoResolution, scheduleGitRootProbe],
);
const onRawCwdChange = useCallback(
(v: string) => {
setCwd(v);
setBranchCache(null);
setExistingBranch("");
if (wtEnabled) scheduleGitRootProbe(v);
},
[wtEnabled, scheduleGitRootProbe],
);
const onSubmit = useCallback(
async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setError("");
const values: SpawnFormValues = {
command,
cwd,
name,
structuredView,
autoApprove: activePreset?.auto_approve ?? false,
worktreeEnabled: wtEnabled,
worktreeBranch: branchMode === "existing" ? effectiveExistingBranch : newBranch,
worktreeBranchEdited: branchEdited,
worktreeBranchMode: branchMode,
};
const built = buildSpawnRequest(values, inferredBaseRepo);
if ("error" in built) {
setError(built.error);
return;
}
setSubmitting(true);
try {
const session = await createSession(built.req);
onSuccess(session);
} catch (err) {
setError((err as Error).message);
setSubmitting(false);
}
},
[
command,
cwd,
name,
structuredView,
activePreset,
wtEnabled,
branchMode,
effectiveExistingBranch,
newBranch,
branchEdited,
inferredBaseRepo,
onSuccess,
],
);
// ---- Enter submits from anywhere (#97) ------------------------------------
const onFormKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLFormElement>) => {
if (e.key !== "Enter" || e.defaultPrevented) return;
const target = e.target as HTMLElement;
if (target.dataset.role === "toggle") {
e.preventDefault();
(target as HTMLInputElement).click();
return;
}
if (target instanceof HTMLButtonElement && target.dataset.picker) {
e.preventDefault();
if (target.dataset.active === "true") {
formRef.current?.requestSubmit();
} else {
target.click();
}
return;
}
e.preventDefault();
if (target === filterInputRef.current) {
// Resolve the filter to a project only when the current pick isn't
// one of the matches: a filter typed past it means the operator
// moved on, while a pick that still matches keeps its worktree choice.
const stillMatches =
selectedGroup != null && visibleGroups.some((g) => g.main.path === selectedGroup.main.path);
if (!stillMatches && visibleGroups[0]) selectProject(visibleGroups[0]);
}
formRef.current?.requestSubmit();
},
[selectedGroup, visibleGroups, selectProject],
);
return (
<form
ref={formRef}
onSubmit={onSubmit}
onKeyDown={onFormKeyDown}
className="mx-auto flex w-full max-w-[60ch] flex-col gap-sp-2 overflow-auto p-sp-3"
>
<div className="flex flex-col gap-sp-2">
<span className="text-fs-13 text-ink">Harness</span>
<div className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-sp-2">
{presets.map((p) => {
const isCustom = p.command === "";
const toolId = presetToolId(p);
const icon = isCustom ? null : toolId ? harnessIcon(toolId) : harnessIcon("shell");
const label = TOOL_LABELS[toolId ?? ""] ?? p.label;
const active = p.id === selectedPresetId;
return (
<button
key={p.id}
type="button"
className={`flex flex-col items-center gap-sp-2 rounded-r-2 border px-sp-2 py-sp-3 text-center ${
active
? "border-accent bg-accent-tint"
: isCustom
? "border-dashed border-border text-muted"
: "border-border bg-surface-2 hover:border-border-strong"
}`}
onClick={() => selectPreset(p.id)}
>
<span
className={`flex h-8 w-8 items-center justify-center ${active ? "text-ink" : "text-muted"}`}
aria-hidden={icon ? undefined : true}
dangerouslySetInnerHTML={icon ? { __html: icon } : undefined}
>
{icon ? undefined : "…"}
</span>
<span className="max-w-full truncate text-fs-13 font-semibold">
{isCustom ? "Custom…" : label}
</span>
</button>
);
})}
</div>
<input
ref={customCommandRef}
className="w-full rounded-r-2 border border-border bg-surface-2 px-sp-3 py-2.5 font-mono text-fs-15 text-ink"
name="command_raw"
type="text"
hidden={!isCustomSelected}
placeholder="bash -l"
autoComplete="off"
value={customCommand}
onChange={(e) => {
const v = e.currentTarget.value;
setCustomCommand(v);
if (isCustomSelected) {
setCommandTouched(true);
setCommand(v.trim());
}
}}
/>
</div>
<div className="flex flex-col gap-sp-2">
<span className="text-fs-13 text-ink">Project</span>
<input
ref={filterInputRef}
className="w-full rounded-r-2 border border-border bg-surface-2 px-sp-3 py-2.5 font-mono text-fs-15 text-ink"
type="text"
placeholder="Filter projects…"
autoComplete="off"
value={filterQuery}
onChange={(e) => setFilterQuery(e.currentTarget.value)}
/>
{!ready ? (
<p className="m-0 px-sp-3 py-1.5 text-fs-13 text-faint">Loading projects…</p>
) : (
<div className="grid max-h-[300px] grid-cols-2 gap-sp-2 overflow-y-auto p-px">
{visibleGroups.map((group) => {
const active = selectedGroup?.main.path === group.main.path;
return (
<button
key={group.main.path}
type="button"
data-picker="project"
data-active={active || undefined}
className={`flex min-w-0 flex-col gap-0.5 rounded-r-2 border px-sp-3 py-sp-2 text-left ${
active ? "border-accent bg-accent-tint" : "border-border bg-surface-2 hover:border-border-strong"
} ${isStale(group.main, new Date()) ? "opacity-55" : ""}`}
onClick={() => selectProject(group)}
>
<span className="truncate text-fs-13 font-semibold text-ink">{group.main.name}</span>
<span className="break-words font-mono text-fs-12 text-muted">
{abbreviateCwd(parentDir(group.main.path), homeDir)}
</span>
</button>
);
})}
{discovered.length > 0 && visibleGroups.length === 0 && (
<p className="col-span-2 m-0 px-sp-3 py-1.5 text-fs-13 text-faint">No projects match.</p>
)}
</div>
)}
<button
type="button"
className="self-start bg-transparent px-0 py-1 text-fs-13 italic text-muted hover:text-ink"
onClick={() => showRawCwd(cwd || homeDir)}
>
Other directory…
</button>
{cwdRawVisible && (
<DirAutocomplete
inputRef={rawCwdRef}
className="w-full rounded-r-2 border border-border bg-surface-2 px-sp-3 py-2.5 font-mono text-fs-15 text-ink"
name="cwd_raw"
type="text"
placeholder="/home/user/project"
value={cwd}
onChange={onRawCwdChange}
/>
)}
</div>
<div className="flex flex-col border-t border-border pt-sp-2">
<button
type="button"
className="flex w-full items-center gap-sp-1 py-sp-2 text-left text-fs-11 text-muted"
aria-expanded={!advancedCollapsed}
onClick={() => setAdvancedCollapsed((c) => !c)}
>
<span className={`inline-block transition-transform ${advancedCollapsed ? "-rotate-90" : ""}`}>▾</span>
<span className="font-semibold">Advanced</span>
<span className="ml-auto text-fs-12 text-muted">{wtEnabled ? "creates a worktree" : "no worktree"}</span>
</button>
{!advancedCollapsed && (
<div className="flex flex-col gap-sp-3 py-sp-2">
<label className="flex flex-col gap-1 text-fs-12 text-muted">
Session name
<input
name="name"
type="text"
autoComplete="off"
placeholder="named after your first prompt"
className="w-full rounded-r-2 border border-border bg-surface-2 px-sp-3 py-2.5 font-mono text-fs-15 text-ink"
value={name}
onChange={(e) => setName(e.currentTarget.value)}
/>
</label>
{structuredSupported && (
<label className="flex cursor-pointer items-center gap-sp-2 text-fs-14 text-ink">
<input
type="checkbox"
data-role="toggle"
className="size-[18px] accent-accent"
checked={structuredView}
onChange={(e) => {
setStructuredTouched(true);
setStructuredView(e.currentTarget.checked);
}}
/>
Structured view
</label>
)}
{selectedGroup && (
<div className="flex flex-col gap-sp-2">
<span className="text-fs-13 text-ink">Worktree</span>
<div className="flex max-h-[200px] flex-col gap-sp-1 overflow-y-auto">
<button
type="button"
data-picker="worktree"
data-active={worktreeChoice === "new" || undefined}
className={pickerButtonClass(worktreeChoice === "new")}
onClick={() => applyWorktreeChoice(selectedGroup, "new")}
>
<span className="text-fs-13 font-semibold text-accent">+ new</span>
<span className="text-right text-fs-12 text-muted">
new worktree of {selectedGroup.main.name}
</span>
</button>
{worktreesOf(selectedGroup).map((wt) => (
<button
key={wt.path}
type="button"
data-picker="worktree"
data-active={worktreeChoice === wt.path || undefined}
className={pickerButtonClass(worktreeChoice === wt.path)}
onClick={() => applyWorktreeChoice(selectedGroup, wt.path)}
>
<span className="text-fs-13 font-semibold text-ink">{wt.name}</span>
<span className="text-right text-fs-12 text-muted">{wt.path}</span>
</button>
))}
</div>
</div>
)}
<BranchPicker
enabled={wtEnabled}
onToggleEnabled={onWtEnabledChange}
hint={wtHint}
baseRepo={inferredBaseRepo}
mode={branchMode}
onModeChange={setBranchMode}
newBranch={newBranch}
onNewBranchChange={(v) => {
setBranchEdited(true);
setNewBranch(v);
}}
branches={branchesForCwd}
existingBranch={effectiveExistingBranch}
onExistingBranchChange={setExistingBranch}
/>
</div>
)}
</div>
<p role="alert" aria-live="polite" className={`m-0 text-fs-13 text-danger ${error ? "rounded-r-2 bg-danger-tint px-sp-3 py-sp-2" : "min-h-[1.2em]"}`}>
{error}
</p>
<div className="flex justify-end">
<button
type="submit"
disabled={submitting}
className="rounded-r-1 bg-accent px-sp-4 py-sp-2 text-fs-13 font-semibold text-accent-ink disabled:opacity-60"
>
Spawn
</button>
</div>
</form>
);
}
@@ -0,0 +1,108 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DaemonInfo, Project, Session } from "../../types.js";
import { SpawnModal } from "./SpawnModal.js";
vi.mock("../../api.js", () => ({
createSession: vi.fn(),
fetchBranches: vi.fn(),
fetchNames: vi.fn(),
getDaemonInfo: vi.fn(),
gitRoot: vi.fn(),
listDir: vi.fn(),
projects: vi.fn(),
}));
import { createSession, fetchNames, getDaemonInfo, listDir, projects as fetchProjects } from "../../api.js";
function info(): DaemonInfo {
return { hostname: "h", version: "1", home_dir: "/home/n", presets: [{ id: "claude", label: "Claude", command: "claude" }] };
}
function project(over: Partial<Project> = {}): Project {
return { path: "/repos/p", name: "p", ...over };
}
beforeEach(() => {
vi.mocked(getDaemonInfo).mockResolvedValue(info());
vi.mocked(fetchProjects).mockResolvedValue([project()]);
vi.mocked(fetchNames).mockResolvedValue({ names: ["amber-fox"] });
vi.mocked(listDir).mockResolvedValue({ path: "", entries: [] });
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
async function settle() {
await waitFor(() => expect(screen.queryByText("Loading projects…")).toBeNull());
}
describe("SpawnModal", () => {
it("mounts the spawn form inside a dialog", async () => {
render(<SpawnModal onSuccess={vi.fn()} onClose={vi.fn()} />);
await settle();
expect(screen.getByRole("dialog")).not.toBeNull();
expect(screen.getByRole("button", { name: "Spawn" })).not.toBeNull();
});
it("titles a seeded open as a clone", async () => {
render(<SpawnModal seed={{ command: "claude" }} onSuccess={vi.fn()} onClose={vi.fn()} />);
await settle();
expect(screen.getByText("Spawn similar")).not.toBeNull();
});
it("titles a plain open as a new session", async () => {
render(<SpawnModal onSuccess={vi.fn()} onClose={vi.fn()} />);
await settle();
expect(screen.getByText("Spawn session")).not.toBeNull();
});
it("closes on Escape, on Cancel and on a backdrop click", async () => {
const onClose = vi.fn();
const { unmount } = render(<SpawnModal onSuccess={vi.fn()} onClose={onClose} />);
await settle();
fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onClose).toHaveBeenCalledTimes(2);
unmount();
render(<SpawnModal onSuccess={vi.fn()} onClose={onClose} />);
await settle();
fireEvent.click(document.querySelector(".modal-backdrop")!);
expect(onClose).toHaveBeenCalledTimes(3);
});
it("closes before handing the created session to its caller", async () => {
const created: Session = {
id: "s1",
cwd: "/repos/p",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-01-01T00:00:00Z",
};
vi.mocked(createSession).mockResolvedValue(created);
const calls: string[] = [];
const onClose = vi.fn(() => calls.push("close"));
const onSuccess = vi.fn(() => calls.push("success"));
render(<SpawnModal onSuccess={onSuccess} onClose={onClose} />);
await settle();
fireEvent.click(screen.getByText("p"));
fireEvent.click(screen.getByRole("button", { name: "Spawn" }));
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(created));
expect(calls).toEqual(["close", "success"]);
});
});
+82
View File
@@ -0,0 +1,82 @@
// React port of ../../spawnModal.ts: dialog chrome around SpawnForm. Escape,
// the ✕ and a click on the backdrop all close it; SpawnForm's own DirAutocomplete
// swallows Escape first, so dismissing a dropdown never also discards the form.
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import type { Session } from "../../types.js";
import { SpawnForm, type SpawnFormProps } from "./SpawnForm.js";
export interface SpawnModalProps {
seed?: SpawnFormProps["seed"];
/** Called with the created session after a successful submit — the modal
* has already closed by then, so the caller decides where it lands. */
onSuccess(session: Session): void;
onClose(): void;
}
export function SpawnModal({ seed, onSuccess, onClose }: SpawnModalProps) {
const filterInputRef = useRef<HTMLInputElement>(null);
const handleSuccess = (session: Session) => {
onClose();
onSuccess(session);
};
// The project filter owns the keyboard on open: typing narrows the list and
// Tab from here reaches the harness tiles, matching the TUI's draft pane.
useEffect(() => {
const host = filterInputRef.current;
host?.querySelector<HTMLInputElement>("input[placeholder='Filter projects…']")?.focus();
}, []);
return createPortal(
<div
className="modal-backdrop fixed inset-0 z-modal flex items-center justify-center bg-bg/70 backdrop-blur-sm"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="spawn-title"
className="flex max-h-[92vh] w-full max-w-[560px] flex-col rounded-r-3 border border-border bg-surface shadow-2"
onKeyDown={(e) => {
if (e.key === "Escape" && !e.defaultPrevented) {
e.preventDefault();
onClose();
}
}}
>
<header className="flex items-center justify-between border-b border-border p-sp-3">
<h2 id="spawn-title" className="m-0 text-fs-15 font-semibold text-ink">
{seed ? "Spawn similar" : "Spawn session"}
</h2>
<button
type="button"
aria-label="Close"
className="rounded-r-1 px-sp-2 py-1 text-fs-15 text-muted hover:text-ink"
onClick={onClose}
>
✕
</button>
</header>
<div ref={filterInputRef} className="min-h-0 flex-1 overflow-auto">
<SpawnForm seed={seed} onSuccess={handleSuccess} />
</div>
<div className="flex justify-end gap-sp-2 border-t border-border p-sp-3">
<button
type="button"
className="rounded-r-1 border border-border px-sp-3 py-sp-1 text-fs-12 text-ink"
onClick={onClose}
>
Cancel
</button>
</div>
</div>
</div>,
document.body,
);
}
+8
View File
@@ -0,0 +1,8 @@
export { SpawnModal, type SpawnModalProps } from "./SpawnModal.js";
export { SpawnForm, type SpawnFormProps } from "./SpawnForm.js";
export { DirAutocomplete, type DirAutocompleteProps } from "./DirAutocomplete.js";
export { BranchPicker, type BranchPickerProps } from "./BranchPicker.js";
export { useFastForward, type UseFastForwardOptions } from "./useFastForward.js";
// spawnSimilar.ts is pure seed-building logic with no DOM dependency — reused
// as-is rather than re-implemented for React (#248).
export { spawnSimilarSeed, spawnSimilarProjectSeed } from "../../spawnSimilar.js";
@@ -0,0 +1,114 @@
// @vitest-environment happy-dom
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Project, Session, SpawnPreset } from "../../types.js";
import { useFastForward } from "./useFastForward.js";
vi.mock("../../api.js", () => ({
createSession: vi.fn(),
fetchNames: vi.fn(),
getDaemonInfo: vi.fn(),
projects: vi.fn(),
}));
import { createSession, fetchNames, getDaemonInfo, projects as fetchProjects } from "../../api.js";
const mockCreateSession = vi.mocked(createSession);
const mockFetchNames = vi.mocked(fetchNames);
const mockGetDaemonInfo = vi.mocked(getDaemonInfo);
const mockFetchProjects = vi.mocked(fetchProjects);
const presets: SpawnPreset[] = [{ id: "claude", label: "Claude", command: "claude" }];
function project(over: Partial<Project> = {}): Project {
return { path: "/repos/p", name: "p", base_repo: "/repos/p", last_command: "claude", ...over };
}
function session(): Session {
return {
id: "s1",
cwd: "/repos/p",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-01-01T00:00:00Z",
};
}
afterEach(() => vi.clearAllMocks());
describe("useFastForward", () => {
it("spawns immediately once every default resolves", async () => {
mockFetchProjects.mockResolvedValue([project()]);
mockFetchNames.mockResolvedValue({ names: ["amber-fox"] });
mockGetDaemonInfo.mockResolvedValue({ hostname: "h", version: "1", presets });
const created = session();
mockCreateSession.mockResolvedValue(created);
const onSpawned = vi.fn();
const onFallback = vi.fn();
const { result } = renderHook(() => useFastForward({ onSpawned, onFallback }));
await act(() => result.current());
expect(mockCreateSession).toHaveBeenCalledWith(
expect.objectContaining({
cwd: "/repos/p",
command: "claude",
worktree: { base_repo: "/repos/p", branch: "amber-fox", create_branch: true },
}),
);
expect(onSpawned).toHaveBeenCalledWith(created);
expect(onFallback).not.toHaveBeenCalled();
});
it("falls back to a pre-filled seed when the daemon rejects the spawn", async () => {
mockFetchProjects.mockResolvedValue([project()]);
mockFetchNames.mockResolvedValue({ names: ["amber-fox"] });
mockGetDaemonInfo.mockResolvedValue({ hostname: "h", version: "1", presets });
mockCreateSession.mockRejectedValue(new Error("boom"));
const onSpawned = vi.fn();
const onFallback = vi.fn();
const { result } = renderHook(() => useFastForward({ onSpawned, onFallback }));
await act(() => result.current());
expect(onSpawned).not.toHaveBeenCalled();
expect(onFallback).toHaveBeenCalledWith(
expect.objectContaining({ cwd: "/repos/p", command: "claude", worktreeBranch: "amber-fox" }),
);
});
it("falls back when nothing is known yet (no projects, no handle)", async () => {
mockFetchProjects.mockResolvedValue([]);
mockFetchNames.mockRejectedValue(new Error("exhausted"));
mockGetDaemonInfo.mockResolvedValue({ hostname: "h", version: "1", presets });
const onSpawned = vi.fn();
const onFallback = vi.fn();
const { result } = renderHook(() => useFastForward({ onSpawned, onFallback }));
await act(() => result.current());
expect(mockCreateSession).not.toHaveBeenCalled();
expect(onFallback).toHaveBeenCalledWith(expect.objectContaining({ cwd: "" }));
});
it("prefers the focused session's project over the freshest one", async () => {
mockFetchProjects.mockResolvedValue([
project({ path: "/repos/fresh", name: "fresh" }),
project({ path: "/repos/current", name: "current", last_activity: "2020-01-01T00:00:00Z" }),
]);
mockFetchNames.mockResolvedValue({ names: ["h"] });
mockGetDaemonInfo.mockResolvedValue({ hostname: "h", version: "1", presets });
mockCreateSession.mockResolvedValue(session());
const onSpawned = vi.fn();
const { result } = renderHook(() =>
useFastForward({ focusedCwd: "/repos/current", onSpawned, onFallback: vi.fn() }),
);
await act(() => result.current());
expect(mockCreateSession).toHaveBeenCalledWith(expect.objectContaining({ cwd: "/repos/current" }));
});
});
@@ -0,0 +1,57 @@
// React port of main.ts's `requestFastForward` (#67): one keystroke, every
// spawn default resolved — most recent project (or the one the operator is
// on), its last command, a fresh generated branch handle, worktree on with a
// new branch. A failed POST, or any missing default, falls back to the same
// pre-filled form `onFallback` opens, rather than a dead keystroke.
import { useCallback } from "react";
import { createSession, fetchNames, getDaemonInfo, projects as fetchProjects } from "../../api.js";
import { buildFastForwardRequest, fastForwardSeed, resolveFastForward } from "../../fastForward.js";
import { FALLBACK_PRESETS, type SpawnFormSeed } from "../../spawnForm.js";
import type { Project, Session, SpawnPreset } from "../../types.js";
export interface UseFastForwardOptions {
/** The project of the session the operator is currently on, if any — its
* base repo for a worktree session, else its cwd. Same rule the plain
* spawn modal's seed uses, so `N` lands where the operator currently is. */
focusedCwd?: string;
onSpawned(session: Session): void;
onFallback(seed: SpawnFormSeed): void;
}
async function resolvePresets(): Promise<SpawnPreset[]> {
try {
const info = await getDaemonInfo();
if (info.presets?.length) return info.presets;
} catch {
// keep fallbacks
}
return FALLBACK_PRESETS;
}
/** Returns a function to call on the fast-forward keystroke. */
export function useFastForward(opts: UseFastForwardOptions): () => Promise<void> {
const { focusedCwd, onSpawned, onFallback } = opts;
return useCallback(async () => {
const [discovered, handle] = await Promise.all([
fetchProjects().catch(() => [] as Project[]),
fetchNames(1)
.then((batch) => batch.names[0])
.catch(() => undefined),
]);
const presets = await resolvePresets();
const resolved = resolveFastForward(discovered, presets, handle, focusedCwd);
const req = buildFastForwardRequest(resolved);
if (req) {
try {
onSpawned(await createSession(req));
return;
} catch {
// degrades to the pre-filled form below, never a silent dead key
}
}
onFallback(fastForwardSeed(resolved));
}, [focusedCwd, onSpawned, onFallback]);
}
+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";
+2 -2
View File
@@ -15,9 +15,9 @@ import { splitCommand, toolIconSvg, TOOL_LABELS } from "./toolIcon.js";
import { abbreviateCwd } from "./types.js";
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
const STRUCTURED_VIEW_TOOLS = new Set(["claude", "codex"]);
export const STRUCTURED_VIEW_TOOLS = new Set(["claude", "codex"]);
function supportsStructuredView(command: string): boolean {
export function supportsStructuredView(command: string): boolean {
const tool = splitCommand(command).tool;
return tool !== null && STRUCTURED_VIEW_TOOLS.has(tool);
}
+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>;
+27 -5
View File
@@ -468,18 +468,40 @@ describe("structured connection: data flow", () => {
},
});
ws.emit({ type: "reduced_state", state: respondingState });
// While the turn is active, the last intermediate text (agent:2) stays
// outside the collapsed group so it's visible as current narration.
expect(conn.el.querySelector('[data-row-id="agent:2"]')?.closest(".st-tool-group")).toBeNull();
// Earlier intermediate text (agent:1) is still inside the group.
// 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();
// When the turn ends, all intermediate text is absorbed.
// 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] });
+13 -18
View File
@@ -488,24 +488,14 @@ export function createStructuredConnection(
groups.set(currentGid, group);
}
}
// pending at end of a turn = final text/approvals, stays outside (not flushed).
// While the turn is still active, keep the last intermediate text/thought
// row outside the collapsed group so it's visible as the agent's current
// narration. It gets absorbed on the next rebuild once newer content
// arrives or the turn ends.
const turnActive = state.turn.responding || state.turn.thinking;
if (turnActive) {
const lastGroup = groups.get(currentGid);
if (lastGroup) {
for (let i = lastGroup.rows.length - 1; i >= 0; i--) {
const kind = lastGroup.rows[i].kind;
if (kind === "agent_text" || kind === "agent_thought") {
lastGroup.rows.splice(i, 1);
break;
}
}
}
// 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;
@@ -634,10 +624,15 @@ export function createStructuredConnection(
// 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;
+2 -1
View File
@@ -2507,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;
}
+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",