Compare commits

..
Author SHA1 Message Date
Miguel PalhasandClaude Sonnet 5 c9562ab5eb Closes #252: port vanilla-DOM tests to React Testing Library
Rewrites the 27 DOM-behavior test files (dialogs, sidebar, spawn form,
command bar, structured pane, catalog, multiview, theme, etc.) to use
screen/within/fireEvent/waitFor instead of manual querySelector and
dispatchEvent. Pure-logic test files (matcher, tree, activity, paths,
reconnect, keys, webgl, paneTitle, commands, daemonLink, events, layout,
persistence, previewSchedule, projectList, spawnSimilar, structuredDiff,
toolIcon, fastForward, spawnForm) are untouched, and preview.test.ts
needed no changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 02:06:31 +01:00
Miguel Palhas 8304ef9a49 Closes #251: port preview mode to React 2026-09-09 01:39:37 +01:00
Miguel Palhas cdba6d07e3 Closes #250: port dialogs, context menu, help overlay, mobile drawer 2026-09-09 01:39:37 +01:00
Miguel Palhas ea8c42bfb2 feat(maestro): port preview mode to React (#251)
Extends layoutStore with previewSession/commitPreview, mirroring
multiview.ts's preview controller: a throwaway tab for a session no pane
holds, byte-for-byte restore on end, and every real layout-changing action
drops an active preview first. Never schedules a save while a preview is up.

Adds usePreview, wrapping the store actions with previewSchedule.ts's
120ms leading-edge throttle, plus a touch guard and an immediate `end()`
for real changes. MultiView/TabBar render the preview hue on the pane
titlebar, canvas ring/chip, and the throwaway tab (dashed, unclickable).
2026-09-09 01:39:05 +01:00
Miguel Palhas e0edbc09d2 feat(maestro): port kill dialog, help overlay, context menu, drawer to React
Closes #250: kill confirmation/force-kill (dialogs/KillDialog), the ?
shortcuts overlay (dialogs/HelpOverlay), the general-purpose right-click
menu (common/ContextMenu, closes on scroll/resize unlike the sidebar's
own), the mobile drawer with swipe support (layout/Drawer), and the
visual-viewport hook (hooks/useViewport). Each is a standalone, tested
port of its vanilla-DOM source; wiring into the shell is a later issue.
2026-09-09 01:33:56 +01:00
Miguel Palhas e9e98e0c49 Closes #249: port layout system to React 2026-09-09 01:23:04 +01:00
Miguel Palhas 02f1844650 Closes #247: port command bar (ctrl-k) to React 2026-09-09 01:23:01 +01:00
Miguel Palhas 1b7ec1f54c feat(maestro): port layout system (tabs, splits, multiview) to React
Closes #249: layoutStore.ts wraps the existing layout.ts tree model
in Zustand, persisting to localStorage on a 400ms debounce and adding
boot reconciliation to prune leaves for sessions no longer live.
MultiView/TabBar/PaneContainer/PaneTitle render tabs, nested splits
with draggable dividers, drag-and-drop tab reordering, and dispatch
each pane to TerminalPane or StructuredPane by session view.

Preview mode (#251) and sidebar drag-to-dock stay out of scope here.
2026-09-09 01:21:37 +01:00
Miguel Palhas ee61cea64f feat(maestro): port the command bar to React (#247)
CommandBar, ResultList, ActionPage and useCommandBar over the same
model docs/COMMAND-BAR.md specifies: sigils, empty-query session order,
the drill-in action page, and the keys.

The highlight now walks the rows in the order they render. The legacy
bar indexed the highlight by display order but activated by rank order,
so Enter could act on a different row than the one lit.
2026-09-09 01:14:28 +01:00
Miguel Palhas 7e21371e47 refactor(maestro): lift the matcher into a shared utils module
The match-and-rank algorithm is pure and both the legacy bar and the
React port rank with it (#247).
2026-09-09 01:14:28 +01:00
Miguel Palhas 52ae636dbb Closes #248: port spawn dialog and fast-forward to React 2026-09-09 01:05:53 +01:00
Miguel Palhas 9d93e6e60e Closes #246: port structured pane (ACP transcript) to React 2026-09-09 01:05:50 +01:00
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 5 9ba1078386 feat(maestro): port the structured pane to React
Closes #246. The transcript, composer and approval controls move from
structured.ts/structuredRender.ts into components under
web/src/components/structured/, against docs/STRUCTURED-VIEW.md: eight row
kinds, both projections, the single-active approval nonce, and diff rendering
with its own line counts.

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

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

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

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

Pane chrome (title, disconnect, sessions drawer) stays out of scope;
composition happens one level up.
2026-09-09 00:24:08 +01:00
97 changed files with 12479 additions and 852 deletions
+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>
+17 -17
View File
@@ -3,6 +3,8 @@
// The sidebar's half of the command bar's action page (#151): the bar picks
// kill or rename and raises the flow the card already has, so what the page
// runs is what the ✕ button and the `r` key run.
import "@testing-library/jest-dom/vitest";
import { fireEvent, screen, within } from "@testing-library/react";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { KILL_SESSION_EVENT, RENAME_SESSION_EVENT, renderSidebar } from "./catalog.js";
@@ -18,7 +20,7 @@ beforeAll(() => {
let mounted: { el: HTMLElement; dispose: () => void } | null = null;
afterEach(() => {
document.querySelector(".kill-dialog")?.remove();
screen.queryByRole("dialog")?.remove();
mounted?.dispose();
mounted = null;
document.body.replaceChildren();
@@ -75,14 +77,12 @@ describe("kill from the action page", () => {
mount(s);
raise(KILL_SESSION_EVENT, s);
await flush();
expect(document.querySelector(".kill-dialog")?.getAttribute("aria-label")).toBe(
"Kill amber-falcon",
);
expect(screen.getByRole("dialog", { name: "Kill amber-falcon" })).toBeTruthy();
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
fireEvent.click(screen.getByRole("button", { name: "Kill" }));
await flush();
expect(calls).toContain(`DELETE /api/sessions/${s.id}`);
expect(document.querySelector(".kill-dialog")).toBeNull();
expect(screen.queryByRole("dialog")).toBeNull();
});
it("raises the existing confirm-and-blockers dialog on a guarded refusal", async () => {
@@ -107,11 +107,10 @@ describe("kill from the action page", () => {
mount(s);
raise(KILL_SESSION_EVENT, s);
await flush();
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
fireEvent.click(screen.getByRole("button", { name: "Kill" }));
await flush();
const dialog = document.querySelector(".kill-dialog");
expect(dialog?.getAttribute("aria-label")).toBe("Kill amber-falcon");
expect(dialog?.textContent).toContain("1 untracked file");
const dialog = screen.getByRole("dialog", { name: "Kill amber-falcon" });
expect(dialog.textContent).toContain("1 untracked file");
});
});
@@ -120,11 +119,12 @@ describe("rename from the action page", () => {
window.fetch = (() => Promise.reject(new Error("offline"))) as typeof fetch;
const s = session();
const el = mount(s);
const folder = el.querySelector<HTMLElement>(".tree-folder-header");
folder?.click();
expect(el.querySelector(`[data-session-id="${s.id}"]`)).toBeNull();
fireEvent.click(within(el).getByRole("button", { name: /maestro/ }));
expect(within(el).queryByRole("link", { name: /amber-falcon/ })).toBeNull();
raise(RENAME_SESSION_EVENT, s);
expect(el.querySelector<HTMLInputElement>(".leaf-rename")?.value).toBe("amber-falcon");
expect(within(el).getByRole("textbox", { name: "Rename amber-falcon" })).toHaveValue(
"amber-falcon",
);
});
it("raises the card's own prompt, pre-filled with the session's name", () => {
@@ -132,8 +132,8 @@ describe("rename from the action page", () => {
const s = session();
const el = mount(s);
raise(RENAME_SESSION_EVENT, s);
const input = el.querySelector<HTMLInputElement>(".leaf-rename");
expect(input?.value).toBe("amber-falcon");
expect(input?.getAttribute("aria-label")).toBe("Rename amber-falcon");
expect(within(el).getByRole("textbox", { name: "Rename amber-falcon" })).toHaveValue(
"amber-falcon",
);
});
});
+148 -128
View File
@@ -1,5 +1,7 @@
// @vitest-environment happy-dom
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { renderSidebar } from "./catalog.js";
import type { CatalogListener, CatalogStore } from "./events.js";
@@ -17,7 +19,7 @@ beforeAll(() => {
});
afterEach(() => {
document.querySelector(".kill-dialog")?.remove();
document.body.replaceChildren();
});
function session(overrides: Partial<Session>): Session {
@@ -46,13 +48,20 @@ function storeWith(sessions: Session[]): CatalogStore {
} as unknown as CatalogStore;
}
/** The session's card, scoped by id: rows carry no other stable, queryable
* handle (names/aria-labels routinely collide across the fixtures below), so
* this stays a raw DOM lookup. Every assertion made *within* the row it
* returns goes through `within(row)`. */
function rowFor(el: HTMLElement, id: string): HTMLElement | null {
return el.querySelector<HTMLElement>(`[data-session-id="${id}"]`);
}
/** Let the kill chain's awaits (fetch, its json body) settle. */
async function flush(): Promise<void> {
for (let i = 0; i < 8; i++) await Promise.resolve();
/** Mount a sidebar and attach it to the document, the way the shell does —
* screen/within queries and `:focus` both need a connected tree. */
function mount(store: CatalogStore): HTMLElement {
const el = renderSidebar(store).el;
document.body.appendChild(el);
return el;
}
describe("blocked-work indicator (#83)", () => {
@@ -66,13 +75,12 @@ describe("blocked-work indicator (#83)", () => {
deletions: 18,
},
});
const el = renderSidebar(storeWith([blocked])).el;
const badge = rowFor(el, blocked.id)?.querySelector(".leaf-blocked");
expect(badge?.textContent).toBe("+124-18↑2");
const el = mount(storeWith([blocked]));
const row = rowFor(el, blocked.id)!;
const badge = within(row).getByTitle(/Kill will be refused unless forced/);
expect(badge.textContent).toBe("+124-18↑2");
// The spelled-out breakdown survives in the tooltip.
expect(badge?.getAttribute("title")).toContain(
"3 uncommitted files, 2 unpushed commits",
);
expect(badge.getAttribute("title")).toContain("3 uncommitted files, 2 unpushed commits");
});
it("falls back to the file count when tracked changes carry no lines", () => {
@@ -80,9 +88,10 @@ describe("blocked-work indicator (#83)", () => {
id: "aaaaaaaa-0000-0000-0000-00000000001a",
blocked_work: { uncommitted_files: 2, untracked_files: 1 },
});
const el = renderSidebar(storeWith([blocked])).el;
const badge = rowFor(el, blocked.id)?.querySelector(".leaf-blocked");
expect(badge?.textContent).toBe("~2?1");
const el = mount(storeWith([blocked]));
const row = rowFor(el, blocked.id)!;
const badge = within(row).getByTitle(/Kill will be refused unless forced/);
expect(badge.textContent).toBe("~2?1");
});
it("does not mark clear or unprobed sessions", () => {
@@ -93,9 +102,13 @@ describe("blocked-work indicator (#83)", () => {
const unprobed = session({
id: "aaaaaaaa-0000-0000-0000-000000000003",
});
const el = renderSidebar(storeWith([clear, unprobed])).el;
expect(rowFor(el, clear.id)?.querySelector(".leaf-blocked")).toBeNull();
expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull();
const el = mount(storeWith([clear, unprobed]));
expect(
within(rowFor(el, clear.id)!).queryByTitle(/Kill will be refused/),
).toBeNull();
expect(
within(rowFor(el, unprobed.id)!).queryByTitle(/Kill will be refused/),
).toBeNull();
});
it("confirms before an unguarded kill (#160)", async () => {
@@ -105,16 +118,16 @@ describe("blocked-work indicator (#83)", () => {
return Promise.resolve(new Response(null, { status: 204 }));
}) as typeof fetch;
const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000005" });
const el = renderSidebar(storeWith([plain])).el;
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush();
const dialog = document.querySelector(".kill-dialog");
expect(dialog?.getAttribute("aria-label")).toBe("Kill claude");
const el = mount(storeWith([plain]));
const row = rowFor(el, plain.id)!;
fireEvent.click(within(row).getByRole("button", { name: "Kill claude" }));
const dialog = screen.getByRole("dialog", { name: "Kill claude" });
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
dialog?.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
await flush();
expect(calls).toContain(`DELETE /api/sessions/${plain.id}`);
expect(document.querySelector(".kill-dialog")).toBeNull();
fireEvent.click(within(dialog).getByRole("button", { name: "Kill" }));
await waitFor(() => {
expect(calls).toContain(`DELETE /api/sessions/${plain.id}`);
expect(screen.queryByRole("dialog")).toBeNull();
});
});
it("kills nothing when the confirmation is cancelled (#160)", async () => {
@@ -124,12 +137,11 @@ describe("blocked-work indicator (#83)", () => {
return Promise.resolve(new Response(null, { status: 204 }));
}) as typeof fetch;
const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000007" });
const el = renderSidebar(storeWith([plain])).el;
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush();
document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click();
await flush();
expect(document.querySelector(".kill-dialog")).toBeNull();
const el = mount(storeWith([plain]));
const row = rowFor(el, plain.id)!;
fireEvent.click(within(row).getByRole("button", { name: "Kill claude" }));
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(screen.queryByRole("dialog")).toBeNull();
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
});
@@ -139,12 +151,12 @@ describe("blocked-work indicator (#83)", () => {
id: "aaaaaaaa-0000-0000-0000-000000000008",
blocked_work: { untracked_files: 1 },
});
const el = renderSidebar(storeWith([blocked])).el;
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush();
const dialog = document.querySelector(".kill-dialog");
expect(dialog?.textContent).toContain("1 untracked file");
expect(dialog?.textContent).toContain("refused unless it is forced");
const el = mount(storeWith([blocked]));
const row = rowFor(el, blocked.id)!;
fireEvent.click(within(row).getByRole("button", { name: "Kill claude" }));
const dialog = screen.getByRole("dialog", { name: "Kill claude" });
expect(dialog.textContent).toContain("1 untracked file");
expect(dialog.textContent).toContain("refused unless it is forced");
});
it("offers the override when the daemon refuses a guarded kill", async () => {
@@ -170,17 +182,20 @@ describe("blocked-work indicator (#83)", () => {
command: "bash",
blocked_work: { untracked_files: 1 },
});
const el = renderSidebar(storeWith([blocked])).el;
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush();
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
await flush();
const dialog = document.querySelector(".kill-dialog");
expect(dialog?.textContent).toContain("1 untracked file");
dialog?.querySelector<HTMLButtonElement>(".kill-dialog-force")?.click();
await flush();
expect(calls).toContain(`DELETE /api/sessions/${blocked.id}?force=true`);
expect(document.querySelector(".kill-dialog")).toBeNull();
const el = mount(storeWith([blocked]));
const row = rowFor(el, blocked.id)!;
fireEvent.click(within(row).getByRole("button", { name: "Kill bash" }));
fireEvent.click(screen.getByRole("button", { name: "Kill" }));
// The guarded refusal replaces the confirm dialog with the force prompt;
// wait for the swap rather than the (name-identical) dialog role alone.
const forceBtn = await screen.findByRole("button", { name: "Force kill" });
const dialog = screen.getByRole("dialog");
expect(dialog.textContent).toContain("1 untracked file");
fireEvent.click(forceBtn);
await waitFor(() => {
expect(calls).toContain(`DELETE /api/sessions/${blocked.id}?force=true`);
expect(screen.queryByRole("dialog")).toBeNull();
});
});
it("closes the override prompt without killing on cancel", async () => {
@@ -200,13 +215,15 @@ describe("blocked-work indicator (#83)", () => {
id: "aaaaaaaa-0000-0000-0000-000000000006",
blocked_work: { unpushed_commits: 2 },
});
const el = renderSidebar(storeWith([blocked])).el;
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush();
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
await flush();
document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click();
expect(document.querySelector(".kill-dialog")).toBeNull();
const el = mount(storeWith([blocked]));
const row = rowFor(el, blocked.id)!;
fireEvent.click(within(row).getByRole("button", { name: "Kill claude" }));
fireEvent.click(screen.getByRole("button", { name: "Kill" }));
// Same reasoning as above: wait for the force prompt's own button before
// trusting "the" Cancel button is the one that belongs to it.
await screen.findByRole("button", { name: "Force kill" });
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(screen.queryByRole("dialog")).toBeNull();
expect(calls.filter((c) => c.includes("force=true"))).toHaveLength(0);
});
});
@@ -222,10 +239,11 @@ describe("agent-reported detail (#112)", () => {
activity_since: ago(90_000),
activity_detail: "cargo build failed",
});
const el = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
expect(line2?.querySelector(".activity-detail")?.textContent).toBe("cargo build failed");
const order = [...line2!.children].map((c) => c.className);
const el = mount(storeWith([s]));
const row = rowFor(el, s.id)!;
const line2 = row.querySelector<HTMLElement>(".card-line2")!;
expect(within(line2).getByText("cargo build failed")).toHaveClass("activity-detail");
const order = [...line2.children].map((c) => c.className);
expect(order.indexOf("activity-detail")).toBeGreaterThan(order.indexOf("activity-duration"));
expect(order.indexOf("activity-detail")).toBeLessThan(
order.findIndex((c) => c.startsWith("card-flag")),
@@ -238,8 +256,11 @@ describe("agent-reported detail (#112)", () => {
activity: "Active",
activity_since: ago(90_000),
});
const el = renderSidebar(storeWith([s])).el;
expect(rowFor(el, s.id)?.querySelector(".activity-detail")).toBeNull();
const el = mount(storeWith([s]));
const row = rowFor(el, s.id)!;
// No text is rendered for a report that isn't in force, so there is
// nothing an accessible query can key off — this stays a raw DOM check.
expect(row.querySelector(".activity-detail")).toBeNull();
});
});
@@ -248,12 +269,13 @@ describe("session cards (#107)", () => {
it("keeps the name and the actions in the same flow line, actions reserving their width", () => {
const s = session({ id: "aaaaaaaa-0000-0000-0000-000000000010" });
const el = renderSidebar(storeWith([s])).el;
const line1 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line1");
const el = mount(storeWith([s]));
const row = rowFor(el, s.id)!;
const line1 = row.querySelector<HTMLElement>(".card-line1")!;
expect(line1).not.toBeNull();
// Same flex line ⇒ the name truncates before it can ever reach the buttons.
expect(line1?.querySelector(".leaf-name")).not.toBeNull();
expect(line1?.querySelector(".leaf-actions")).toBe(line1?.lastElementChild);
expect(within(line1).getByText("claude")).toHaveClass("leaf-name");
expect(line1.querySelector(".leaf-actions")).toBe(line1.lastElementChild);
});
it("always renders line 2 for a freshly spawned session", () => {
@@ -262,13 +284,13 @@ describe("session cards (#107)", () => {
activity: "Active",
activity_since: ago(5_000),
});
const el = renderSidebar(storeWith([s])).el;
const row = rowFor(el, s.id);
const line2 = row?.querySelector<HTMLElement>(".card-line2");
const el = mount(storeWith([s]));
const row = rowFor(el, s.id)!;
const line2 = row.querySelector<HTMLElement>(".card-line2");
expect(line2).not.toBeNull();
expect(row?.querySelector(".activity-rail")).not.toBeNull();
expect(line2?.querySelector(".activity-duration")).toBeNull();
expect(line2?.querySelector(".leaf-cmd")).not.toBeNull();
expect(within(row).getByRole("img", { name: "Active" })).toHaveClass("activity-rail");
expect(line2!.querySelector(".activity-duration")).toBeNull();
expect(within(row).getByTitle("claude")).toHaveClass("leaf-cmd");
});
it("shows dot + floored duration once the state has been held past 30s", () => {
@@ -277,12 +299,12 @@ describe("session cards (#107)", () => {
activity: "Active",
activity_since: ago(4 * 60_000 + 30_000),
});
const el = renderSidebar(storeWith([s])).el;
const row = rowFor(el, s.id);
const line2 = row?.querySelector<HTMLElement>(".card-line2");
expect(line2?.dataset.activity).toBe("active");
expect(row?.querySelector(".activity-rail")).not.toBeNull();
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("4m");
const el = mount(storeWith([s]));
const row = rowFor(el, s.id)!;
const line2 = row.querySelector<HTMLElement>(".card-line2")!;
expect(line2.dataset.activity).toBe("active");
expect(within(row).getByRole("img", { name: "Active" })).toHaveClass("activity-rail");
expect(within(row).getByText("4m")).toHaveClass("activity-duration");
});
it("dims a name the daemon generated, until it settles (#131)", () => {
@@ -304,13 +326,12 @@ describe("session cards (#107)", () => {
metadata: { name: "older-session" },
});
const el = renderSidebar(storeWith([generated, haiku, chosen, legacy])).el;
const provisional = (s: Session) =>
rowFor(el, s.id)?.querySelector(".leaf-name")?.classList.contains("is-provisional");
expect(provisional(generated)).toBe(true);
expect(provisional(haiku)).toBe(false);
expect(provisional(chosen)).toBe(false);
expect(provisional(legacy)).toBe(false);
const el = mount(storeWith([generated, haiku, chosen, legacy]));
const nameEl = (s: Session, text: string) => within(rowFor(el, s.id)!).getByText(text);
expect(nameEl(generated, "amber-falcon")).toHaveClass("is-provisional");
expect(nameEl(haiku, "sidebar-rail")).not.toHaveClass("is-provisional");
expect(nameEl(chosen, "my-session")).not.toHaveClass("is-provisional");
expect(nameEl(legacy, "older-session")).not.toHaveClass("is-provisional");
});
it("renders the command as its tool icon when the tool is known", () => {
@@ -319,12 +340,13 @@ describe("session cards (#107)", () => {
command: "claude",
metadata: { name: "claude" },
});
const el = renderSidebar(storeWith([s])).el;
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
expect(cmd?.querySelector(".tool-icon")?.getAttribute("data-tool")).toBe("claude");
expect(cmd?.getAttribute("title")).toBe("claude");
const el = mount(storeWith([s]));
const row = rowFor(el, s.id)!;
const cmd = within(row).getByTitle("claude");
expect(cmd).toHaveClass("leaf-cmd");
expect(cmd.querySelector<HTMLElement>(".tool-icon")?.getAttribute("data-tool")).toBe("claude");
// The icon said it all: no text left over.
expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull();
expect(cmd.querySelector(".leaf-cmd-rest")).toBeNull();
});
it("drops the command's arguments, keeping them in the tooltip", () => {
@@ -332,11 +354,12 @@ describe("session cards (#107)", () => {
id: "aaaaaaaa-0000-0000-0000-00000000001c",
command: "claude --dangerously-skip-permissions",
});
const el = renderSidebar(storeWith([s])).el;
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
expect(cmd?.querySelector(".tool-icon")?.getAttribute("data-tool")).toBe("claude");
expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull();
expect(cmd?.getAttribute("title")).toBe("claude --dangerously-skip-permissions");
const el = mount(storeWith([s]));
const row = rowFor(el, s.id)!;
const cmd = within(row).getByTitle("claude --dangerously-skip-permissions");
expect(cmd).toHaveClass("leaf-cmd");
expect(cmd.querySelector<HTMLElement>(".tool-icon")?.getAttribute("data-tool")).toBe("claude");
expect(cmd.querySelector(".leaf-cmd-rest")).toBeNull();
});
it("keeps an unknown command as plain text", () => {
@@ -344,11 +367,11 @@ describe("session cards (#107)", () => {
id: "aaaaaaaa-0000-0000-0000-00000000001d",
command: "hx src/main.rs",
});
const el = renderSidebar(storeWith([s])).el;
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
expect(cmd?.querySelector(".tool-icon")).toBeNull();
expect(cmd?.querySelector(".leaf-cmd-rest")?.textContent).toBe("hx");
expect(cmd?.getAttribute("title")).toBe("hx src/main.rs");
const el = mount(storeWith([s]));
const row = rowFor(el, s.id)!;
const cmd = within(row).getByTitle("hx src/main.rs");
expect(cmd.querySelector(".tool-icon")).toBeNull();
expect(within(cmd).getByText("hx")).toHaveClass("leaf-cmd-rest");
});
it("maps each activity state to its contract slug", () => {
@@ -362,16 +385,14 @@ describe("session cards (#107)", () => {
const idle = mk("aaaaaaaa-0000-0000-0000-000000000013", "Idle");
const wait = mk("aaaaaaaa-0000-0000-0000-000000000014", "AwaitingInput");
const error = mk("aaaaaaaa-0000-0000-0000-000000000015", "Error");
const el = renderSidebar(storeWith([idle, wait, error])).el;
expect(rowFor(el, idle.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
"idle",
);
expect(rowFor(el, wait.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
"awaiting-input",
);
expect(rowFor(el, error.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
"error",
);
const el = mount(storeWith([idle, wait, error]));
// `data-activity` is a styling hook with no accessible surface of its
// own (the rail's role/name cover the state word, not the slug), so this
// stays a plain attribute assertion.
const activityOf = (s: Session) => rowFor(el, s.id)!.querySelector(".card-line2");
expect(activityOf(idle)).toHaveAttribute("data-activity", "idle");
expect(activityOf(wait)).toHaveAttribute("data-activity", "awaiting-input");
expect(activityOf(error)).toHaveAttribute("data-activity", "error");
});
it("treats an unknown activity variant as idle instead of failing to render", () => {
@@ -380,10 +401,10 @@ describe("session cards (#107)", () => {
activity: "Teleported" as Session["activity"],
activity_since: ago(120_000),
});
const el = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector(".card-line2");
expect(line2?.getAttribute("data-activity")).toBe("idle");
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("2m");
const el = mount(storeWith([s]));
const row = rowFor(el, s.id)!;
expect(row.querySelector(".card-line2")).toHaveAttribute("data-activity", "idle");
expect(within(row).getByText("2m")).toHaveClass("activity-duration");
});
it("moves blocked work onto line 2 as diff notation", () => {
@@ -396,12 +417,12 @@ describe("session cards (#107)", () => {
deletions: 18,
},
});
const el = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
expect(line2?.textContent).toContain("+124");
expect(line2?.textContent).toContain("-18");
const el = mount(storeWith([s]));
const row = rowFor(el, s.id)!;
expect(within(row).getByText("+124")).toBeTruthy();
expect(within(row).getByText("-18")).toBeTruthy();
// Line 1 carries no badges.
expect(rowFor(el, s.id)?.querySelector(".leaf-badge")).toBeNull();
expect(row.querySelector(".leaf-badge")).toBeNull();
});
it("shows exited on line 2 for dead sessions", () => {
@@ -409,9 +430,9 @@ describe("session cards (#107)", () => {
id: "aaaaaaaa-0000-0000-0000-000000000018",
status: "Dead",
});
const el = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
expect(line2?.textContent).toContain("exited");
const el = mount(storeWith([s]));
const row = rowFor(el, s.id)!;
expect(within(row).getByText("exited")).toBeTruthy();
});
it("carries the state word only in tooltips and aria labels, never as text", () => {
@@ -422,19 +443,18 @@ describe("session cards (#107)", () => {
activity: "AwaitingInput",
activity_since: ago(90_000),
});
const el = renderSidebar(storeWith([s])).el;
const row = rowFor(el, s.id) as HTMLElement;
const rail = row.querySelector<HTMLElement>(".activity-rail");
expect(rail?.getAttribute("title")).toContain("Awaiting input");
expect(rail?.getAttribute("aria-label")).toContain("Awaiting input");
const el = mount(storeWith([s]));
const row = rowFor(el, s.id)!;
const rail = within(row).getByRole("img", { name: "Awaiting input" });
expect(rail).toHaveAttribute("title", "Awaiting input");
expect(row.textContent).not.toMatch(/awaiting.?input/i);
});
it("keeps the attach navigation and depth indentation", () => {
const s = session({ id: "aaaaaaaa-0000-0000-0000-00000000001a" });
const el = renderSidebar(storeWith([s])).el;
const el = mount(storeWith([s]));
const row = rowFor(el, s.id) as HTMLAnchorElement;
expect(row.getAttribute("href")).toBe("#/attach/aaaaaaaa-0000-0000-0000-00000000001a");
expect(row).toHaveAttribute("href", "#/attach/aaaaaaaa-0000-0000-0000-00000000001a");
expect(row.style.paddingLeft).toMatch(/^\d+(\.\d+)?px$/);
});
});
+5 -4
View File
@@ -1,5 +1,6 @@
// @vitest-environment happy-dom
import { beforeAll, describe, expect, it } from "vitest";
import { within } from "@testing-library/react";
import { renderSidebar } from "./catalog.js";
import type { CatalogListener, CatalogStore } from "./events.js";
@@ -61,9 +62,9 @@ describe("sidebar refresh keeps rows in place (docs/SIDEBAR.md)", () => {
// the row's transitions, which is the flicker this avoids.
expect(rowOf(el)).toBe(before);
// …and the card still tells the truth about the session.
expect(before.querySelector(".activity-rail")?.getAttribute("data-activity")).toBe(
"awaiting-input",
);
expect(
within(before).getByRole("img", { name: "Awaiting input" }).getAttribute("data-activity"),
).toBe("awaiting-input");
expect(before.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
"awaiting-input",
);
@@ -100,6 +101,6 @@ describe("sidebar refresh keeps rows in place (docs/SIDEBAR.md)", () => {
push([session({ metadata: { name: "harmony" } })]);
expect(rowOf(el)).not.toBe(before);
expect(rowOf(el).querySelector(".leaf-name")?.textContent).toBe("harmony");
expect(within(rowOf(el)).getByText("harmony").textContent).toBe("harmony");
});
});
+35 -36
View File
@@ -1,4 +1,6 @@
// @vitest-environment happy-dom
import "@testing-library/jest-dom/vitest";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
@@ -45,23 +47,29 @@ afterEach(() => {
document.body.replaceChildren();
});
function input(): HTMLInputElement {
return screen.getByRole("combobox") as HTMLInputElement;
}
function press(key: string, init: KeyboardEventInit = {}): void {
const input = document.querySelector(".cmdbar-input") as HTMLInputElement;
input.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, ...init }));
fireEvent.keyDown(input(), { key, ...init });
}
function type(value: string): void {
const input = document.querySelector(".cmdbar-input") as HTMLInputElement;
input.value = value;
input.dispatchEvent(new Event("input", { bubbles: true }));
// The bar re-renders off a native "input" listener, not React's "change" —
// fireEvent.input is the one that actually reaches it.
fireEvent.input(input(), { target: { value } });
}
function rowNames(): string[] {
return [...document.querySelectorAll(".cmdbar-row-name")].map((r) => r.textContent ?? "");
return screen
.queryAllByRole("option")
.map((row) => row.querySelector(".cmdbar-row-name")?.textContent ?? "");
}
function activeName(): string | undefined {
return document.querySelector(".cmdbar-row.is-active .cmdbar-row-name")?.textContent ?? undefined;
const active = screen.queryByRole("option", { selected: true });
return active?.querySelector(".cmdbar-row-name")?.textContent ?? undefined;
}
describe("empty-query ordering", () => {
@@ -211,7 +219,7 @@ describe("the overlay", () => {
it("opening twice leaves one overlay", () => {
open();
open();
expect(document.querySelectorAll(".cmdbar-overlay")).toHaveLength(1);
expect(screen.getAllByRole("dialog")).toHaveLength(1);
expect(isCommandBarOpen()).toBe(true);
});
@@ -226,8 +234,7 @@ describe("the overlay", () => {
it("highlights inside the directory when that is the field that matched", () => {
open();
type("notes");
const row = document.querySelector(".cmdbar-row") as HTMLElement;
expect(row.dataset.sessionId).toBe("notes");
expect(screen.getByRole("option")).toHaveAttribute("data-session-id", "notes");
type("dev/wor");
expect(rowNames()).toEqual(["worker"]);
expect(
@@ -239,7 +246,7 @@ describe("the overlay", () => {
open();
type("zzz");
expect(rowNames()).toEqual([]);
expect(document.querySelector(".cmdbar-empty")?.textContent).toBe("No matches");
expect(screen.getByText("No matches")).toBeDefined();
});
it("moves the highlight with the arrows and ctrl-p/ctrl-n, without wrapping", () => {
@@ -261,7 +268,7 @@ describe("the overlay", () => {
press("ArrowDown");
expect(activeName()).toBe("worker");
type("o");
expect(activeName()).toBe(document.querySelector(".cmdbar-row-name")?.textContent);
expect(activeName()).toBe(rowNames()[0]);
});
it("opens the highlighted session through the shared open path on Enter", () => {
@@ -291,17 +298,13 @@ describe("the overlay", () => {
expect(isCommandBarOpen()).toBe(false);
open();
(document.querySelector(".cmdbar-overlay") as HTMLElement).dispatchEvent(
new MouseEvent("mousedown", { bubbles: true }),
);
fireEvent.mouseDown(screen.getByRole("dialog"));
expect(isCommandBarOpen()).toBe(false);
});
it("stays open for a press inside the panel", () => {
open();
(document.querySelector(".cmdbar-panel") as HTMLElement).dispatchEvent(
new MouseEvent("mousedown", { bubbles: true }),
);
fireEvent.mouseDown(document.querySelector(".cmdbar-panel") as HTMLElement);
expect(isCommandBarOpen()).toBe(true);
});
@@ -310,7 +313,7 @@ describe("the overlay", () => {
document.body.appendChild(before);
before.focus();
open();
expect(document.activeElement).toBe(document.querySelector(".cmdbar-input"));
expect(screen.getByRole("combobox")).toHaveFocus();
press("Escape");
expect(document.activeElement).toBe(before);
});
@@ -318,7 +321,7 @@ describe("the overlay", () => {
it("clicking a row opens that session", () => {
const onOpenSession = vi.fn();
open({ sessions, homeDir: "/home/me", commands: [], onOpenSession });
(document.querySelectorAll(".cmdbar-row")[1] as HTMLElement).click();
fireEvent.click(screen.getAllByRole("option")[1]);
expect(onOpenSession).toHaveBeenCalledWith("worker");
expect(isCommandBarOpen()).toBe(false);
});
@@ -330,12 +333,10 @@ function command(id: string, label: string, keys: string[], run = () => {}): Com
return { id, label, group: "Test", keys, scope: "global", run };
}
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
function rowKeys(): string[][] {
return [...document.querySelectorAll(".cmdbar-row-keys")].map((el) =>
[...el.querySelectorAll("kbd")].map((k) => k.textContent ?? ""),
);
return screen
.queryAllByRole("option")
.map((row) => [...row.querySelectorAll("kbd")].map((k) => k.textContent ?? ""));
}
describe("parsing the query line", () => {
@@ -460,8 +461,7 @@ describe("the sigils in the overlay", () => {
it("lists recent dirs then projects under `~`", async () => {
open({ loadDirectories: async () => ["/home/me/dev/api", "/srv/www"] });
type("~");
await flush();
expect(rowNames()).toEqual(["~/dev/api", "/srv/www"]);
await waitFor(() => expect(rowNames()).toEqual(["~/dev/api", "/srv/www"]));
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
});
@@ -476,9 +476,8 @@ describe("the sigils in the overlay", () => {
});
// The second `~` is a literal query character and reaches the matcher.
type("~~/dev/mae");
await flush();
await waitFor(() => expect(rowNames()).toEqual(["~/dev/maestro"]));
expect(asked).toEqual(["~/dev/"]);
expect(rowNames()).toEqual(["~/dev/maestro"]);
});
it("spawns in the highlighted directory on Enter", async () => {
@@ -488,7 +487,7 @@ describe("the sigils in the overlay", () => {
onSpawnDirectory: (path) => spawned.push(path),
});
type("~api");
await flush();
await waitFor(() => expect(rowNames()).toEqual(["~/dev/api"]));
press("Enter");
expect(spawned).toEqual(["/home/me/dev/api"]);
expect(isCommandBarOpen()).toBe(false);
@@ -507,7 +506,7 @@ describe("the sigils in the overlay", () => {
listDirEntries: async () => [],
});
type("~");
await flush();
await waitFor(() => expect(rowNames()).toEqual(["~/dev/api"]));
press("Enter");
document.removeEventListener(SPAWN_DIRECTORY_EVENT, listener);
expect(seen).toEqual(["/home/me/dev/api"]);
@@ -526,7 +525,7 @@ describe("the focus round-trip over a pane (#152)", () => {
const ta = terminalTextarea();
ta.focus();
openCommandBar({ sessions: [], commands: [] });
expect(document.activeElement).toBe(document.querySelector(".cmdbar-input"));
expect(screen.getByRole("combobox")).toHaveFocus();
press("Escape");
expect(document.activeElement).toBe(ta);
});
@@ -605,7 +604,7 @@ describe("the session action page (#151)", () => {
type("spwn");
expect(rowNames()).toEqual(["Spawn similar"]);
type("zzz");
expect(document.querySelector(".cmdbar-empty")?.textContent).toBe("No matches");
expect(screen.getByText("No matches")).toBeDefined();
});
it("does nothing on a command row", () => {
@@ -625,7 +624,7 @@ describe("the session action page (#151)", () => {
it("does nothing on a directory row", async () => {
open({ loadDirectories: () => Promise.resolve(["/home/me/dev/api"]) });
type("~");
await Promise.resolve();
await waitFor(() => expect(rowNames()).toEqual(["~/dev/api"]));
press("Tab");
expect(rowNames()).toEqual(["~/dev/api"]);
expect(headerText()).toBeUndefined();
@@ -639,7 +638,7 @@ describe("the session action page (#151)", () => {
press("Escape");
expect(isCommandBarOpen()).toBe(true);
expect(headerText()).toBeUndefined();
expect((document.querySelector(".cmdbar-input") as HTMLInputElement).value).toBe("wor");
expect(input().value).toBe("wor");
expect(rowNames()).toEqual(["worker"]);
});
@@ -650,7 +649,7 @@ describe("the session action page (#151)", () => {
type("");
press("Backspace");
expect(headerText()).toBeUndefined();
expect((document.querySelector(".cmdbar-input") as HTMLInputElement).value).toBe("wor");
expect(input().value).toBe("wor");
expect(rowNames()).toEqual(["worker"]);
});
+1 -1
View File
@@ -21,7 +21,7 @@ import {
type MatchRange,
type MatchResult,
type SessionCandidate,
} from "./matcher.js";
} from "./utils/matcher.js";
import { splitPath } from "./paths.js";
import { splitCommand } from "./toolIcon.js";
import { abbreviateCwd, sessionName, sessionTreePath, type Session } from "./types.js";
@@ -0,0 +1,34 @@
// A session's action page (docs/COMMAND-BAR.md, "Drill-in"): the same overlay
// over kill, rename and spawn similar, still fuzzy searchable. The page names
// the session it acts on, so the target is never ambiguous.
import { sessionName, type Session } from "../../types.js";
import { ResultList } from "./ResultList.js";
import type { BarGroup } from "./model.js";
export interface ActionPageProps {
session: Session;
/** The three actions, ranked by the query line — one kind, so no headers. */
groups: BarGroup[];
highlighted: number;
onActivate(index: number): void;
}
export function ActionPage({ session, groups, highlighted, onActivate }: ActionPageProps) {
return (
<>
<div
role="presentation"
className="cmdbar-page-header border-b border-border px-sp-3 py-sp-1 text-fs-11 text-muted"
>
{`Actions for "${sessionName(session)}"`}
</div>
<ResultList
groups={groups}
showHeaders={false}
highlighted={highlighted}
onActivate={onActivate}
/>
</>
);
}
@@ -0,0 +1,407 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
KILL_SESSION_EVENT,
RENAME_SESSION_EVENT,
SESSION_SELECT_EVENT,
SPAWN_SIMILAR_EVENT,
} from "../../catalog.js";
import { COMMAND_SPECS, type Command } from "../../commands.js";
import type { Session } from "../../types.js";
import { CommandBar, SPAWN_DIRECTORY_EVENT, type CommandBarProps } from "./CommandBar.js";
vi.mock("../../api.js", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
listDir: vi.fn(async () => ({ path: "", entries: [] })),
projects: vi.fn(async () => []),
recentDirs: vi.fn(async () => ({ dirs: [] })),
}));
function session(id: string, over: Partial<Session> = {}): Session {
return {
id,
cwd: "/home/me/dev/api",
command: "bash",
env: {},
metadata: { name: id },
status: "Running",
created_at: "2026-01-01T00:00:00Z",
...over,
};
}
function command(over: Partial<Command> = {}): Command {
return {
id: "cmd",
label: "A command",
group: "Test",
keys: ["a"],
scope: "global",
run: () => {},
...over,
};
}
const sessions = [
session("api", { current_cwd: "/home/me/dev/api", last_focused_at: "2026-01-01T11:00:00Z" }),
session("worker", {
current_cwd: "/home/me/dev/worker",
last_focused_at: "2026-01-01T10:00:00Z",
}),
session("notes", { current_cwd: "/home/me/notes" }),
];
function open(over: Partial<CommandBarProps> = {}) {
return render(
<CommandBar
sessions={sessions}
homeDir="/home/me"
commands={[]}
onOpenSession={() => {}}
onClose={() => {}}
{...over}
/>,
);
}
function input(): HTMLInputElement {
return screen.getByRole("combobox") as HTMLInputElement;
}
function type(value: string): void {
fireEvent.change(input(), { target: { value } });
}
function press(key: string, init: Record<string, unknown> = {}): void {
fireEvent.keyDown(input(), { key, ...init });
}
function rowNames(): string[] {
return [...document.querySelectorAll(".cmdbar-row-name")].map((r) => r.textContent ?? "");
}
function activeName(): string | undefined {
return document.querySelector(".cmdbar-row.is-active .cmdbar-row-name")?.textContent ?? undefined;
}
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("the overlay", () => {
it("is a registry command, so the help overlay lists it", () => {
const spec = COMMAND_SPECS.find((s) => s.id === "bar.open");
expect(spec?.keys).toEqual(["ctrl-k"]);
expect(spec?.scope).toBe("global");
});
it("lists sessions by recency under a kind header on an empty query", () => {
open();
expect([...document.querySelectorAll(".cmdbar-group-title")].map((h) => h.textContent)).toEqual([
"Sessions",
]);
expect(rowNames()).toEqual(["api", "worker", "notes"]);
expect(activeName()).toBe("api");
});
it("ranks a typed query through the matcher and highlights the matched range", () => {
open();
type("wor");
expect(rowNames()).toEqual(["worker"]);
expect([...document.querySelectorAll(".cmdbar-row-name mark")].map((m) => m.textContent)).toEqual(
["wor"],
);
});
it("highlights inside the directory when that is the field that matched", () => {
open();
type("dev/wor");
expect(rowNames()).toEqual(["worker"]);
expect(
[...document.querySelectorAll(".cmdbar-row-meta mark")].map((m) => m.textContent).join(""),
).toBe("dev/wor");
});
it("renders a no-results state rather than an empty list", () => {
open();
type("zzz");
expect(rowNames()).toEqual([]);
expect(screen.getByText("No matches")).toBeDefined();
});
it("moves the highlight with the arrows and ctrl-p/ctrl-n, without wrapping", () => {
open();
press("ArrowUp");
expect(activeName()).toBe("api");
press("ArrowDown");
expect(activeName()).toBe("worker");
press("n", { ctrlKey: true });
expect(activeName()).toBe("notes");
press("ArrowDown");
expect(activeName()).toBe("notes");
press("p", { ctrlKey: true });
expect(activeName()).toBe("worker");
});
it("puts the highlight back on the best row whenever the query changes", () => {
open();
press("ArrowDown");
expect(activeName()).toBe("worker");
type("o");
expect(activeName()).toBe(rowNames()[0]);
});
it("opens the highlighted session through the caller's open path on Enter", () => {
const onOpenSession = vi.fn();
const onClose = vi.fn();
open({ onOpenSession, onClose });
press("ArrowDown");
press("Enter");
expect(onOpenSession).toHaveBeenCalledWith("worker");
expect(onClose).toHaveBeenCalled();
});
it("raises the shared select event when the caller supplies no handler", () => {
const listener = vi.fn();
document.addEventListener(SESSION_SELECT_EVENT, listener);
open({ onOpenSession: undefined });
press("Enter");
document.removeEventListener(SESSION_SELECT_EVENT, listener);
expect((listener.mock.calls[0][0] as CustomEvent).detail).toBe("api");
});
it("Enter on an empty result list does nothing", () => {
const onOpenSession = vi.fn();
const onClose = vi.fn();
open({ onOpenSession, onClose });
type("zzz");
press("Enter");
expect(onOpenSession).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
});
it("closes on Escape and on a press outside the panel", () => {
const onClose = vi.fn();
open({ onClose });
press("Escape");
expect(onClose).toHaveBeenCalledTimes(1);
fireEvent.mouseDown(document.querySelector(".cmdbar-overlay") as HTMLElement);
expect(onClose).toHaveBeenCalledTimes(2);
});
it("stays open for a press inside the panel", () => {
const onClose = vi.fn();
open({ onClose });
fireEvent.mouseDown(document.querySelector(".cmdbar-panel") as HTMLElement);
expect(onClose).not.toHaveBeenCalled();
});
it("returns the keyboard to whatever held it when it opened", () => {
const before = document.createElement("input");
document.body.appendChild(before);
before.focus();
const view = open();
expect(document.activeElement).toBe(input());
view.unmount();
expect(document.activeElement).toBe(before);
before.remove();
});
it("clicking a row opens that session", () => {
const onOpenSession = vi.fn();
open({ onOpenSession });
fireEvent.click(document.querySelectorAll(".cmdbar-row")[1]);
expect(onOpenSession).toHaveBeenCalledWith("worker");
});
});
describe("the sigils", () => {
const commands = [
command({ id: "help.toggle", label: "Show help", keys: ["?"] }),
command({ id: "spawn.open", label: "Open the spawn form", keys: ["n"], run: vi.fn() }),
];
it("lists every command in registry order under `>`, with its chord and no header", () => {
open({ commands });
type(">");
expect(rowNames()).toEqual(["Show help", "Open the spawn form"]);
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
expect([...document.querySelectorAll(".cmdbar-row-keys kbd")].map((k) => k.textContent)).toEqual(
["?", "n"],
);
});
it("matches a command on its label and never on its chord", () => {
open({ commands });
type(">help");
expect(rowNames()).toEqual(["Show help"]);
type(">?");
expect(rowNames()).toEqual([]);
});
it("runs the highlighted command on Enter", () => {
const run = vi.fn();
open({ commands: [command({ label: "Run me", run })] });
type(">run");
press("Enter");
expect(run).toHaveBeenCalled();
});
it("keeps the list mixed with no sigil, and returns to it when the sigil goes", () => {
open({ commands });
type(">show");
expect(rowNames()).toEqual(["Show help"]);
type("");
expect(rowNames()).toEqual(["api", "worker", "notes"]);
});
it("lists recent dirs then projects under `~`", async () => {
open({ loadDirectories: async () => ["/home/me/dev/api", "/srv/x"] });
type("~");
await waitFor(() => expect(rowNames()).toEqual(["~/dev/api", "/srv/x"]));
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
});
it("fills the list in from /api/dirs as a path is typed", async () => {
const listDirEntries = vi.fn(async () => ["maestro", "api"]);
open({ loadDirectories: async () => ["/home/me/notes"], listDirEntries });
// The second `~` is a literal query character and reaches the matcher.
type("~~/dev/mae");
await waitFor(() => expect(rowNames()).toEqual(["~/dev/maestro"]));
expect(listDirEntries).toHaveBeenCalledWith("~/dev/");
});
it("spawns in the highlighted directory on Enter", async () => {
const onSpawnDirectory = vi.fn();
open({ loadDirectories: async () => ["/srv/x"], onSpawnDirectory });
type("~");
await waitFor(() => expect(rowNames()).toEqual(["/srv/x"]));
press("Enter");
expect(onSpawnDirectory).toHaveBeenCalledWith("/srv/x");
});
it("raises the spawn event when the caller supplies no handler", async () => {
const listener = vi.fn();
document.addEventListener(SPAWN_DIRECTORY_EVENT, listener);
open({ loadDirectories: async () => ["/srv/x"] });
type("~");
await waitFor(() => expect(rowNames()).toEqual(["/srv/x"]));
press("Enter");
document.removeEventListener(SPAWN_DIRECTORY_EVENT, listener);
expect((listener.mock.calls[0][0] as CustomEvent).detail).toBe("/srv/x");
});
});
describe("the session action page", () => {
function drill(over: Partial<CommandBarProps> = {}) {
const view = open(over);
press("Tab");
return view;
}
it("lists kill, rename and spawn similar for the highlighted session", () => {
drill();
expect(rowNames()).toEqual(["Kill session", "Rename session", "Spawn similar"]);
});
it("names the session being acted on", () => {
drill();
expect(screen.getByText('Actions for "api"')).toBeDefined();
});
it("drops the kind headers, since the page is one kind", () => {
drill();
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
});
it("stays fuzzy-searchable over the three actions", () => {
drill();
type("ren");
expect(rowNames()).toEqual(["Rename session"]);
});
it("does nothing on a command row", () => {
open({ commands: [command({ label: "Show help" })] });
type(">show");
press("Tab");
expect(rowNames()).toEqual(["Show help"]);
});
it("does nothing on a directory row", async () => {
open({ loadDirectories: async () => ["/srv/x"] });
type("~");
await waitFor(() => expect(rowNames()).toEqual(["/srv/x"]));
press("Tab");
expect(rowNames()).toEqual(["/srv/x"]);
});
it("returns to the result list on Escape, with the previous query intact", () => {
const onClose = vi.fn();
open({ onClose });
type("wor");
press("Tab");
expect(rowNames()).toEqual(["Kill session", "Rename session", "Spawn similar"]);
press("Escape");
expect(onClose).not.toHaveBeenCalled();
expect(input().value).toBe("wor");
expect(rowNames()).toEqual(["worker"]);
});
it("returns to the result list on backspace at an empty query", () => {
drill();
fireEvent.keyDown(input(), { key: "Backspace" });
expect(rowNames()).toEqual(["api", "worker", "notes"]);
});
it("keeps backspace ordinary while the page's query has text", () => {
drill();
type("ren");
fireEvent.keyDown(input(), { key: "Backspace" });
expect(rowNames()).toEqual(["Rename session"]);
});
it("closes the bar when the page's Escape has already been used", () => {
const onClose = vi.fn();
drill({ onClose });
press("Escape");
press("Escape");
expect(onClose).toHaveBeenCalledTimes(1);
});
it("raises kill, rename and spawn similar with the session, closing the bar", () => {
for (const [query, key] of [
["kill", "onKillSession"],
["rename", "onRenameSession"],
["spawn", "onSpawnSimilar"],
] as const) {
const handler = vi.fn();
const onClose = vi.fn();
drill({ [key]: handler, onClose });
type(query);
press("Enter");
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ id: "api" }));
expect(onClose).toHaveBeenCalled();
cleanup();
}
});
it("hands each action to the flow it already has, by default", () => {
for (const [query, event] of [
["kill", KILL_SESSION_EVENT],
["rename", RENAME_SESSION_EVENT],
["spawn", SPAWN_SIMILAR_EVENT],
] as const) {
const listener = vi.fn();
document.addEventListener(event, listener);
drill();
type(query);
press("Enter");
document.removeEventListener(event, listener);
expect((listener.mock.calls[0][0] as CustomEvent).detail.id).toBe("api");
cleanup();
}
});
});
@@ -0,0 +1,379 @@
// The `ctrl-k` command bar (docs/COMMAND-BAR.md): a query line over a result
// list. Sessions and commands rank together by default; `>` narrows to
// commands, `~` to directories. `tab` on a session drills into its action page.
//
// The bar picks an action and hands off — it never reimplements one. Each
// handler defaults to the event the flow that already implements it listens
// on, so the React bar and the legacy shell raise the same flows.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import {
KILL_SESSION_EVENT,
RENAME_SESSION_EVENT,
SESSION_SELECT_EVENT,
SPAWN_SIMILAR_EVENT,
} from "../../catalog.js";
import { publishedCommands, type Command } from "../../commands.js";
import { sessionName, type Session } from "../../types.js";
import { ActionPage } from "./ActionPage.js";
import { ResultList } from "./ResultList.js";
import {
barGroups,
barRows,
commandBarCandidates,
dirEntryPaths,
directoryBarCandidates,
isActionCandidate,
listDirEntries as defaultListDirEntries,
loadBarDirectories,
parseBarQuery,
probeDir,
sessionActionCandidates,
sessionBarCandidates,
PLACEHOLDERS,
type BarCandidate,
type BarResult,
type DirectoryBarCandidate,
type SessionAction,
} from "./model.js";
/** Raised when Enter lands on a directory row and no handler was given. The
* bar picks the directory; the shell owns the spawn, which is the same
* fast-forward path `N` takes. */
export const SPAWN_DIRECTORY_EVENT = "cmdbar-spawn-directory";
export interface CommandBarProps {
/** Sessions to build candidates from, as the store has them right now. */
sessions: readonly Session[];
/** The daemon's home dir, for the directory field's `~/…` abbreviation. */
homeDir?: string;
/** The command registry to list under `>`. Defaults to every bound command,
* in registry order. */
commands?: readonly Command[];
/** The directory list behind `~`, fetched once on first use. */
loadDirectories?: () => Promise<string[]>;
/** One directory's subdirectories, for filling the list in as a path is
* typed. Defaults to `GET /api/dirs`. */
listDirEntries?: (dir: string) => Promise<string[]>;
onOpenSession?(sessionId: string): void;
onSpawnDirectory?(path: string): void;
onKillSession?(session: Session): void;
onRenameSession?(session: Session): void;
onSpawnSimilar?(session: Session): void;
/** Closing returns the keyboard to whatever held it when the bar opened. */
onClose(): void;
}
function raise(name: string, detail: unknown): void {
document.dispatchEvent(new CustomEvent(name, { detail }));
}
export function CommandBar(props: CommandBarProps) {
const {
sessions,
homeDir,
commands,
loadDirectories = loadBarDirectories,
listDirEntries = defaultListDirEntries,
onClose,
} = props;
const inputRef = useRef<HTMLInputElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [raw, setRaw] = useState("");
const [highlighted, setHighlighted] = useState(0);
// The action page: `page` is the session being acted on, `stashed` the
// result-list query it was drilled into from — backing out restores it, so
// the round trip costs the operator nothing.
const [page, setPage] = useState<Session | null>(null);
const [stashed, setStashed] = useState("");
// `~`'s candidates: the recents-and-projects list, fetched once, plus
// whatever `/api/dirs` has filled in for the directory being typed.
const [directories, setDirectories] = useState<DirectoryBarCandidate[]>([]);
const [probed, setProbed] = useState<DirectoryBarCandidate[]>([]);
const requestedRef = useRef(false);
const listingsRef = useRef(new Map<string, string[]>());
// On the action page the query line is over the three actions, so there is
// no sigil to read: `>` and `~` are ordinary characters there.
const parsed = useMemo(() => (page ? { sigil: null, query: raw } : parseBarQuery(raw)), [page, raw]);
const sessionCandidates = useMemo(
() => sessionBarCandidates(sessions, homeDir),
[sessions, homeDir],
);
const commandCandidates = useMemo(
() => commandBarCandidates(commands ?? publishedCommands()),
[commands],
);
// Closing returns the keyboard to whatever held it when the bar opened: a
// round trip through the bar never silently moves focus.
useEffect(() => {
const previouslyFocused = document.activeElement as HTMLElement | null;
inputRef.current?.focus();
return () => {
if (previouslyFocused?.isConnected) previouslyFocused.focus();
};
}, []);
// The recents-and-projects list, the first time `~` is used. No directories
// is a shorter list, not an error state.
useEffect(() => {
if (parsed.sigil !== "~" || requestedRef.current) return;
requestedRef.current = true;
let live = true;
loadDirectories()
.then((paths) => {
if (live) setDirectories(directoryBarCandidates(paths, homeDir));
})
.catch(() => {});
return () => {
live = false;
};
}, [parsed.sigil, loadDirectories, homeDir]);
// Fill the list in from `GET /api/dirs` once the query looks like a path: the
// directory it names is listed and its children join the candidates, so `~`
// reaches a directory that was never spawned in.
const dir = parsed.sigil === "~" ? probeDir(parsed.query) : "";
useEffect(() => {
if (dir === "") {
setProbed([]);
return;
}
const cached = listingsRef.current.get(dir);
if (cached) {
setProbed(directoryBarCandidates(dirEntryPaths(dir, cached), homeDir));
return;
}
let live = true;
setProbed([]);
listDirEntries(dir)
.then((entries) => {
listingsRef.current.set(dir, entries);
if (live) setProbed(directoryBarCandidates(dirEntryPaths(dir, entries), homeDir));
})
.catch(() => {
/* an unreadable directory contributes nothing */
});
return () => {
live = false;
};
}, [dir, listDirEntries, homeDir]);
/** What the matcher sees for the query line as it stands. With no sigil the
* list is mixed — sessions and commands together — except on an empty query,
* where the bar is a session switcher. */
const candidates: BarCandidate[] = useMemo(() => {
if (page) return sessionActionCandidates();
switch (parsed.sigil) {
case ">":
return [...commandCandidates];
case "~": {
const seen = new Set<string>();
return [...directories, ...probed].filter((c) =>
seen.has(c.path) ? false : (seen.add(c.path), true),
);
}
default:
return parsed.query === ""
? [...sessionCandidates]
: [...sessionCandidates, ...commandCandidates];
}
}, [page, parsed.sigil, parsed.query, sessionCandidates, commandCandidates, directories, probed]);
const groups = useMemo(
() => barGroups(barRows(parsed.query, candidates)),
[parsed.query, candidates],
);
// Grouping is a rendering of the ranking, so the flattened groups are the
// rows in the order they appear — which is the order the highlight walks and
// Enter acts on.
const rows: BarResult[] = useMemo(() => groups.flatMap((g) => g.results), [groups]);
// The highlight starts on the globally best row every time the list changes:
// an old index would point at a different result.
useEffect(() => {
setHighlighted(0);
}, [rows]);
/** The highlight does not wrap: it stops at the first and last row. */
const move = useCallback(
(delta: number) => {
setHighlighted((at) => Math.min(rows.length - 1, Math.max(0, at + delta)));
},
[rows.length],
);
const runAction = useCallback(
(action: SessionAction, session: Session) => {
onClose();
switch (action.id) {
case "kill":
(props.onKillSession ?? ((s: Session) => raise(KILL_SESSION_EVENT, s)))(session);
return;
case "rename":
(props.onRenameSession ?? ((s: Session) => raise(RENAME_SESSION_EVENT, s)))(session);
return;
case "spawn-similar":
(props.onSpawnSimilar ?? ((s: Session) => raise(SPAWN_SIMILAR_EVENT, s)))(session);
return;
}
},
[onClose, props.onKillSession, props.onRenameSession, props.onSpawnSimilar],
);
const activate = useCallback(
(index: number) => {
const result = rows[index];
if (!result) return;
const candidate = result.candidate;
if (isActionCandidate(candidate)) {
// The page cannot be up without a session to act on.
if (page) runAction(candidate.action, page);
return;
}
onClose();
switch (candidate.kind) {
case "session":
(props.onOpenSession ?? ((id: string) => raise(SESSION_SELECT_EVENT, id)))(
candidate.session.id,
);
return;
case "command":
candidate.command.run();
return;
case "directory":
(props.onSpawnDirectory ?? ((p: string) => raise(SPAWN_DIRECTORY_EVENT, p)))(
candidate.path,
);
return;
}
},
[rows, page, runAction, onClose, props.onOpenSession, props.onSpawnDirectory],
);
/** `tab`: drill into the highlighted session's action page. No effect on any
* other kind, and none on the page itself, which has no session rows. */
const drillIn = useCallback(() => {
const candidate = rows[highlighted]?.candidate;
if (page || candidate?.kind !== "session") return;
setPage(candidate.session);
setStashed(raw);
setRaw("");
}, [rows, highlighted, page, raw]);
/** Escape, or backspace at an empty query: back to the result list with the
* query the drill-in was made from. */
const leavePage = useCallback(() => {
setPage(null);
setRaw(stashed);
setStashed("");
}, [stashed]);
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case "Escape":
e.preventDefault();
// On an action page Escape is one level up, not the way out.
if (page) leavePage();
else onClose();
return;
case "Tab":
e.preventDefault();
drillIn();
return;
case "Backspace":
// Ordinary editing, except at an empty query on the action page.
if (page && raw === "") {
e.preventDefault();
leavePage();
}
return;
case "Enter":
e.preventDefault();
activate(highlighted);
return;
case "ArrowDown":
e.preventDefault();
move(1);
return;
case "ArrowUp":
e.preventDefault();
move(-1);
return;
case "n":
if (e.ctrlKey) {
e.preventDefault();
move(1);
}
return;
case "p":
if (e.ctrlKey) {
e.preventDefault();
move(-1);
}
return;
}
};
const activeRowId = rows.length > 0 ? `cmdbar-row-${highlighted}` : undefined;
return createPortal(
<div
className="cmdbar-overlay fixed inset-0 z-modal flex items-start justify-center bg-bg/70 pt-[12vh] backdrop-blur-sm max-[600px]:items-end max-[600px]:pt-0"
role="dialog"
aria-modal="true"
aria-label="Command bar"
onMouseDown={(e) => {
// A press on the scrim closes; one inside the panel is left alone.
if (!panelRef.current?.contains(e.target as Node)) onClose();
}}
>
<div
ref={panelRef}
className="cmdbar-panel flex w-full max-w-[560px] flex-col overflow-hidden rounded-r-3 border border-border bg-surface shadow-2 max-[600px]:max-w-none max-[600px]:rounded-b-none"
>
<input
ref={inputRef}
type="text"
role="combobox"
aria-expanded="true"
aria-controls="cmdbar-results"
aria-label="Search sessions, commands and directories"
aria-activedescendant={activeRowId}
autoComplete="off"
spellCheck={false}
className="cmdbar-input w-full border-b border-border bg-transparent px-sp-3 py-sp-2 text-fs-15 text-ink outline-none"
placeholder={page ? `Act on ${sessionName(page)}…` : PLACEHOLDERS[parsed.sigil ?? "mixed"]}
value={raw}
onChange={(e) => setRaw(e.currentTarget.value)}
onKeyDown={onKeyDown}
/>
{page ? (
<ActionPage
session={page}
groups={groups}
highlighted={highlighted}
onActivate={activate}
/>
) : (
<ResultList
groups={groups}
// While a sigil is in force there is one kind, so the header is
// dropped.
showHeaders={parsed.sigil === null}
highlighted={highlighted}
onActivate={activate}
/>
)}
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,171 @@
// The bar's result list: kind headers over rank-ordered rows
// (docs/COMMAND-BAR.md, "Structure"). Headers are labels, never rows — the
// highlight walks the rows alone, in the order they are rendered.
import { useEffect, useRef } from "react";
import { keyCaps } from "../../commands.js";
import {
highlightSegments,
isActionCandidate,
metaText,
rowName,
type BarGroup,
type BarResult,
} from "./model.js";
export interface ResultListProps {
groups: BarGroup[];
/** Dropped while a sigil is in force or on the action page: one kind needs
* no header. */
showHeaders: boolean;
/** Index into the flattened rows, in the order they render. */
highlighted: number;
onActivate(index: number): void;
}
function Highlighted({ text, ranges }: { text: string; ranges: BarResult["ranges"] }) {
return (
<>
{highlightSegments(text, ranges).map((segment, i) =>
segment.hit ? (
<mark key={i} className="cmdbar-hit bg-transparent font-semibold text-accent">
{segment.text}
</mark>
) : (
<span key={i}>{segment.text}</span>
),
)}
</>
);
}
function Row({
result,
index,
active,
onActivate,
}: {
result: BarResult;
index: number;
active: boolean;
onActivate(index: number): void;
}) {
const ref = useRef<HTMLDivElement>(null);
const candidate = result.candidate;
useEffect(() => {
if (active) ref.current?.scrollIntoView({ block: "nearest" });
}, [active]);
const name = rowName(candidate);
const nameMatched =
result.field === "name" || result.field === "label" || result.field === "path";
const meta = metaText(result);
const dead = candidate.kind === "session" && candidate.session.status === "Dead";
const chord =
candidate.kind === "command" && !isActionCandidate(candidate)
? candidate.command.keys[0]
: undefined;
return (
<div
ref={ref}
role="option"
id={`cmdbar-row-${index}`}
aria-selected={active}
data-index={index}
data-kind={candidate.kind}
data-session-id={candidate.kind === "session" ? candidate.session.id : undefined}
data-action-id={isActionCandidate(candidate) ? candidate.action.id : undefined}
data-command-id={
candidate.kind === "command" && !isActionCandidate(candidate)
? candidate.command.id
: undefined
}
data-path={candidate.kind === "directory" ? candidate.path : undefined}
className={[
"cmdbar-row flex cursor-pointer items-baseline gap-sp-2 rounded-r-1 px-sp-3 py-sp-1 text-fs-13",
active ? "is-active bg-surface-2 text-ink" : "text-ink",
dead ? "is-dead opacity-60" : "",
]
.filter(Boolean)
.join(" ")}
onClick={() => onActivate(index)}
>
<span className="cmdbar-row-name truncate">
{nameMatched ? <Highlighted text={name} ranges={result.ranges} /> : name}
</span>
{chord !== undefined ? (
<span className="cmdbar-row-keys ml-auto flex gap-1">
{keyCaps(chord).map((cap, i) => (
<kbd
key={i}
className="rounded-r-1 border border-border px-1 text-fs-11 text-muted"
>
{cap}
</kbd>
))}
</span>
) : meta ? (
<span className="cmdbar-row-meta ml-auto truncate text-fs-11 text-muted">
{result.field !== undefined && result.field !== "name" ? (
<Highlighted text={meta} ranges={result.ranges} />
) : (
meta
)}
</span>
) : null}
</div>
);
}
export function ResultList({ groups, showHeaders, highlighted, onActivate }: ResultListProps) {
if (groups.length === 0) {
return (
<div
id="cmdbar-results"
role="listbox"
aria-label="Results"
className="cmdbar-results max-h-[50vh] overflow-auto p-sp-1"
>
<div className="cmdbar-empty px-sp-3 py-sp-2 text-fs-12 text-muted">No matches</div>
</div>
);
}
let index = 0;
return (
<div
id="cmdbar-results"
role="listbox"
aria-label="Results"
className="cmdbar-results max-h-[50vh] overflow-auto p-sp-1"
>
{groups.map((group) => (
<div key={group.kind}>
{showHeaders ? (
<div
role="presentation"
className="cmdbar-group-title px-sp-3 pt-sp-2 pb-1 text-fs-11 uppercase tracking-wide text-muted"
>
{group.title}
</div>
) : null}
{group.results.map((result) => {
const at = index++;
return (
<Row
key={at}
result={result}
index={at}
active={at === highlighted}
onActivate={onActivate}
/>
);
})}
</div>
))}
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
export { CommandBar, SPAWN_DIRECTORY_EVENT, type CommandBarProps } from "./CommandBar.js";
export { ResultList, type ResultListProps } from "./ResultList.js";
export { ActionPage, type ActionPageProps } from "./ActionPage.js";
export { useCommandBar, type CommandBarControls, type UseCommandBarOptions } from "./useCommandBar.js";
export * from "./model.js";
+197
View File
@@ -0,0 +1,197 @@
import { describe, expect, it } from "vitest";
import type { Command } from "../../commands.js";
import type { Session } from "../../types.js";
import {
barGroups,
barRows,
commandBarCandidates,
dedupeCommands,
directoryBarCandidates,
directoryCandidate,
highlightSegments,
orderSessionsForBar,
parseBarQuery,
sessionActionCandidates,
sessionBarCandidates,
sessionCandidate,
} from "./model.js";
function session(id: string, over: Partial<Session> = {}): Session {
return {
id,
cwd: "/home/me/dev/api",
command: "bash",
env: {},
metadata: { name: id },
status: "Running",
created_at: "2026-01-01T00:00:00Z",
...over,
};
}
function command(over: Partial<Command> = {}): Command {
return {
id: "a",
label: "A command",
group: "Test",
keys: ["a"],
scope: "global",
run: () => {},
...over,
};
}
describe("empty-query ordering", () => {
it("sorts most recently focused first", () => {
const older = session("older", { last_focused_at: "2026-01-01T10:00:00Z" });
const newer = session("newer", { last_focused_at: "2026-01-01T11:00:00Z" });
expect(orderSessionsForBar([older, newer]).map((s) => s.id)).toEqual(["newer", "older"]);
});
it("sorts a never-focused session after every focused one, newest created first", () => {
const focused = session("focused", { last_focused_at: "2026-01-01T09:00:00Z" });
const old = session("old", { created_at: "2026-01-01T00:00:00Z" });
const fresh = session("fresh", { created_at: "2026-01-02T00:00:00Z" });
expect(orderSessionsForBar([old, fresh, focused]).map((s) => s.id)).toEqual([
"focused",
"fresh",
"old",
]);
});
it("sorts dead sessions after live ones regardless of recency", () => {
const dead = session("dead", { status: "Dead", last_focused_at: "2026-01-01T12:00:00Z" });
const live = session("live", { last_focused_at: "2026-01-01T10:00:00Z" });
expect(orderSessionsForBar([dead, live]).map((s) => s.id)).toEqual(["live", "dead"]);
});
it("treats an empty last_focused_at as never focused", () => {
const blank = session("blank", { last_focused_at: "" });
const focused = session("focused", { last_focused_at: "2026-01-01T10:00:00Z" });
expect(orderSessionsForBar([blank, focused]).map((s) => s.id)).toEqual(["focused", "blank"]);
});
});
describe("session candidates", () => {
it("carries the candidate table's fields, with the directory abbreviated", () => {
const s = session("api", {
metadata: { name: "api" },
current_cwd: "/home/me/dev/api",
command: "claude --resume",
});
expect(sessionCandidate(s, "/home/me").fields).toEqual({
name: "api",
directory: "~/dev/api",
command: "claude --resume",
tool: "claude",
});
});
it("leaves the tool out when the program has no mark, and groups a worktree by its repo", () => {
const s = session("edit", {
metadata: {},
command: "hx src/",
worktree: { path: "/home/me/dev/api/wt/x", base_repo: "/home/me/dev/api" },
});
const candidate = sessionCandidate(s, "/home/me");
expect(candidate.fields.tool).toBeUndefined();
expect(candidate.fields.directory).toBe("~/dev/api");
// No `metadata.name`: the sidebar's fallback is the command line.
expect(candidate.fields.name).toBe("hx src/");
});
it("builds candidates in the empty-query order", () => {
const a = session("a", { last_focused_at: "2026-01-01T10:00:00Z" });
const b = session("b", { last_focused_at: "2026-01-01T11:00:00Z" });
expect(sessionBarCandidates([a, b]).map((c) => c.session.id)).toEqual(["b", "a"]);
});
});
describe("rows and grouping", () => {
it("an empty query keeps every candidate in input order", () => {
const candidates = sessionBarCandidates([session("a"), session("b")]);
expect(barRows("", candidates).map((r) => r.score)).toEqual([0, 0]);
});
it("groups by kind without reordering the ranking", () => {
const rows = barRows("kill", [
...sessionBarCandidates([session("killer-app", { metadata: { name: "killer-app" } })]),
...commandBarCandidates([command({ label: "Kill selected session" })]),
]);
const groups = barGroups(rows);
expect(groups.map((g) => g.title)).toEqual(["Sessions", "Commands"]);
expect(groups.flatMap((g) => g.results)).toEqual(rows);
});
});
describe("parsing the query line", () => {
it("reads a sigil only in first position", () => {
expect(parseBarQuery(">help")).toEqual({ sigil: ">", query: "help" });
expect(parseBarQuery("~dev")).toEqual({ sigil: "~", query: "dev" });
});
it("leaves a sigil character elsewhere in the query literal", () => {
expect(parseBarQuery("~dev/~scratch")).toEqual({ sigil: "~", query: "dev/~scratch" });
expect(parseBarQuery("a>b")).toEqual({ sigil: null, query: "a>b" });
});
it("returns to the mixed list when the sigil is backspaced away", () => {
expect(parseBarQuery("")).toEqual({ sigil: null, query: "" });
});
});
describe("command candidates", () => {
it("dedupes by action, not by chord", () => {
const run = () => {};
const cmds = [
command({ id: "one", label: "Do it", keys: ["j"], run }),
command({ id: "two", label: "Do it", keys: ["down"], run }),
];
expect(commandBarCandidates(cmds)).toHaveLength(1);
expect(dedupeCommands(cmds)).toHaveLength(1);
});
it("matches on the label only, never on the chord", () => {
const candidates = commandBarCandidates([command({ label: "Show help", keys: ["ctrl-q"] })]);
expect(barRows("ctrlq", candidates)).toEqual([]);
expect(barRows("show", candidates)).toHaveLength(1);
});
});
describe("directory candidates", () => {
it("abbreviates the path it shows and keeps the one it spawns in", () => {
const candidate = directoryCandidate("/home/me/dev/api", "/home/me");
expect(candidate.fields.path).toBe("~/dev/api");
expect(candidate.path).toBe("/home/me/dev/api");
});
it("expands a typed `~` and dedupes by the directory spawned in", () => {
const candidates = directoryBarCandidates(
["~/dev/api", "/home/me/dev/api", "", "/srv/x"],
"/home/me",
);
expect(candidates.map((c) => c.path)).toEqual(["/home/me/dev/api", "/srv/x"]);
});
});
describe("the action page's candidates", () => {
it("offers no action beyond the three", () => {
expect(sessionActionCandidates().map((c) => c.action.id)).toEqual([
"kill",
"rename",
"spawn-similar",
]);
});
});
describe("highlight segments", () => {
it("splits the text on the matcher's ranges", () => {
expect(highlightSegments("web-search", [[0, 1], [4, 5]])).toEqual([
{ text: "w", hit: true },
{ text: "eb-", hit: false },
{ text: "s", hit: true },
{ text: "earch", hit: false },
]);
});
});
+366
View File
@@ -0,0 +1,366 @@
// The command bar's pure model (docs/COMMAND-BAR.md), lifted from
// ../../commandBar.ts for the React port (#247): what a candidate is, the
// order an empty query lists them in, how a query line splits into a sigil and
// a query, and how a ranked list groups for rendering.
//
// Nothing here touches the DOM or the network apart from `loadBarDirectories`,
// which is the one place the `~` list comes from.
import { listDir, projects as fetchProjects, recentDirs } from "../../api.js";
import type { Command } from "../../commands.js";
import { splitPath } from "../../paths.js";
import { splitCommand } from "../../toolIcon.js";
import { abbreviateCwd, sessionName, sessionTreePath, type Session } from "../../types.js";
import {
matchAndRank,
type CandidateKind,
type CommandCandidate,
type DirectoryCandidate,
type MatchRange,
type MatchResult,
type SessionCandidate,
} from "../../utils/matcher.js";
/** A matcher candidate that remembers what it came from, so a result can be
* acted on without a second lookup. The extra property is invisible to the
* matcher, which only ever reads `kind` and `fields`. */
export type SessionBarCandidate = SessionCandidate & { session: Session };
export type CommandBarCandidate = CommandCandidate & { command: Command };
/** `fields.path` is the abbreviated path the operator reads and the matcher
* ranks; `path` is the one a spawn is made in. */
export type DirectoryBarCandidate = DirectoryCandidate & { path: string };
/** One row of a session's action page. It is a `command` to the matcher — a
* single `label` field, ranked exactly like a registry command — and carries
* the action it stands for instead of a registry entry. */
export type ActionBarCandidate = CommandCandidate & { action: SessionAction };
export type BarCandidate =
| SessionBarCandidate
| CommandBarCandidate
| ActionBarCandidate
| DirectoryBarCandidate;
export type BarResult = MatchResult<BarCandidate>;
/** One kind's slice of the ranked list, in rank order. */
export interface BarGroup<C extends BarCandidate = BarCandidate> {
kind: CandidateKind;
title: string;
results: MatchResult<C>[];
}
const KIND_TITLES: Record<CandidateKind, string> = {
session: "Sessions",
command: "Commands",
directory: "Directories",
};
// ---- the sigils ----
export type BarSigil = ">" | "~";
export interface BarQuery {
/** The sigil in force, or null for the mixed list. */
sigil: BarSigil | null;
/** What the matcher sees: everything after a sigil, or the whole query. */
query: string;
}
export const PLACEHOLDERS: Record<"mixed" | BarSigil, string> = {
mixed: "Go to a session…",
">": "Run a command…",
"~": "Spawn in a directory…",
};
/** Split a raw query line into its sigil and the query proper.
*
* A sigil is only a sigil in first position: `>` or `~` anywhere else is a
* literal query character, which is what a directory query with a second `~`
* in it depends on. Backspacing past a sigil needs no special case either —
* the first character goes and the query is a mixed one again. */
export function parseBarQuery(raw: string): BarQuery {
const first = raw[0];
if (first === ">" || first === "~") return { sigil: first, query: raw.slice(1) };
return { sigil: null, query: raw };
}
// ---- the session action page ----
/** The actions a session's action page offers, and no others: the bar picks
* one and hands off to the flow that already implements it
* (docs/COMMAND-BAR.md, "Drill-in"). */
export type SessionActionId = "kill" | "rename" | "spawn-similar";
export interface SessionAction {
id: SessionActionId;
label: string;
}
export const SESSION_ACTIONS: readonly SessionAction[] = [
{ id: "kill", label: "Kill session" },
{ id: "rename", label: "Rename session" },
{ id: "spawn-similar", label: "Spawn similar" },
];
/** The action page's candidates, in the order above — which is the order an
* empty query lists them in, since the matcher keeps input order. */
export function sessionActionCandidates(): ActionBarCandidate[] {
return SESSION_ACTIONS.map((action) => ({
kind: "command",
action,
fields: { label: action.label },
}));
}
/** Narrow a `command` candidate to an action row. Both share the matcher's
* `command` kind, so what separates them is what they carry. */
export function isActionCandidate(candidate: BarCandidate): candidate is ActionBarCandidate {
return candidate.kind === "command" && "action" in candidate;
}
// ---- candidates ----
function focusedAt(s: Session): number | null {
const stamp = s.last_focused_at;
if (typeof stamp !== "string" || stamp === "") return null;
const ms = Date.parse(stamp);
return Number.isNaN(ms) ? null : ms;
}
function createdAt(s: Session): number {
const ms = Date.parse(s.created_at ?? "");
return Number.isNaN(ms) ? 0 : ms;
}
/** The empty-query order (docs/COMMAND-BAR.md, "Empty query"): most recently
* focused first; a session that has never been focused sorts after every
* session that has, most recently created first among those; and a dead
* session sorts after every live one regardless of recency, because a session
* you cannot attach to is not where you just were.
*
* This is also the input order for a non-empty query, which is what the
* matcher's last tie-break preserves. */
export function orderSessionsForBar(sessions: readonly Session[]): Session[] {
return [...sessions].sort((a, b) => {
const deadA = a.status === "Dead" ? 1 : 0;
const deadB = b.status === "Dead" ? 1 : 0;
if (deadA !== deadB) return deadA - deadB;
const focusA = focusedAt(a);
const focusB = focusedAt(b);
if ((focusA === null) !== (focusB === null)) return focusA === null ? 1 : -1;
if (focusA !== null && focusB !== null && focusA !== focusB) return focusB - focusA;
return createdAt(b) - createdAt(a);
});
}
/** Build one session's candidate: the fields docs/COMMAND-BAR.md's candidate
* table names, with the directory abbreviated the way the sidebar shows it. */
export function sessionCandidate(s: Session, homeDir?: string): SessionBarCandidate {
const treePath = sessionTreePath(s);
const directory = treePath ? abbreviateCwd(treePath, homeDir) : undefined;
const tool = splitCommand(s.command).tool ?? undefined;
return {
kind: "session",
session: s,
fields: {
name: sessionName(s),
...(directory ? { directory } : {}),
...(s.command ? { command: s.command } : {}),
...(tool ? { tool } : {}),
},
};
}
/** Every session as a candidate, in the empty-query order. */
export function sessionBarCandidates(
sessions: readonly Session[],
homeDir?: string,
): SessionBarCandidate[] {
return orderSessionsForBar(sessions).map((s) => sessionCandidate(s, homeDir));
}
/** Drop the commands that would list twice. Several chords can be bound to one
* action — the registry already carries its aliases on one entry, but nothing
* stops two entries running the same handler, and listed naively that is two
* identical rows. The dedupe is by **action**, never by chord: the chord is
* displayed, so a command with two of them is still one row. */
export function dedupeCommands(commands: readonly Command[]): Command[] {
const seenRun = new Set<Command["run"]>();
const seenLabel = new Set<string>();
const kept: Command[] = [];
for (const command of commands) {
if (seenRun.has(command.run) || seenLabel.has(command.label)) continue;
seenRun.add(command.run);
seenLabel.add(command.label);
kept.push(command);
}
return kept;
}
/** Every bound command as a candidate, in registry order. Only the label is a
* field: the chord is displayed beside the row, never matched, because
* matching it would make `>` results depend on a user's rebinds. */
export function commandBarCandidates(commands: readonly Command[]): CommandBarCandidate[] {
return dedupeCommands(commands).map((command) => ({
kind: "command",
command,
fields: { label: command.label },
}));
}
/** `~/dev/api` back to `/home/me/dev/api` — the inverse of `abbreviateCwd`, for
* a path the operator typed or `/api/dirs` completed. */
export function expandHome(path: string, homeDir?: string): string {
if (!homeDir) return path;
if (path === "~") return homeDir;
return path.startsWith("~/") ? `${homeDir}${path.slice(1)}` : path;
}
export function directoryCandidate(path: string, homeDir?: string): DirectoryBarCandidate {
const absolute = expandHome(path, homeDir);
return { kind: "directory", path: absolute, fields: { path: abbreviateCwd(absolute, homeDir) } };
}
/** Candidates for `~`, deduped by the directory they spawn in and keeping the
* first mention of each — recent dirs (newest first) ahead of the project
* list, which is the empty-query order the contract asks for. */
export function directoryBarCandidates(
paths: readonly string[],
homeDir?: string,
): DirectoryBarCandidate[] {
const seen = new Set<string>();
const out: DirectoryBarCandidate[] = [];
for (const path of paths) {
if (path === "") continue;
const candidate = directoryCandidate(path, homeDir);
if (seen.has(candidate.path)) continue;
seen.add(candidate.path);
out.push(candidate);
}
return out;
}
/** The directory list the bar starts from: what the spawn form already knows —
* `GET /api/recent-dirs` and the project list — either alone if the other
* fails. */
export async function loadBarDirectories(): Promise<string[]> {
const [recents, discovered] = await Promise.all([
recentDirs()
.then((r) => r.dirs.map((d) => d.cwd))
.catch(() => [] as string[]),
fetchProjects()
.then((list) => list.map((p) => p.path))
.catch(() => [] as string[]),
]);
return [...recents, ...discovered];
}
/** One directory's children as candidate paths, for filling the `~` list in as
* a path is typed. The directory being listed comes from the query itself. */
export function dirEntryPaths(dir: string, entries: readonly string[]): string[] {
return entries.map((entry) => `${dir}${entry}`);
}
/** The directory a `~` query names, or `""` when it names none yet. */
export function probeDir(query: string): string {
return splitPath(query).dir;
}
/** `GET /api/dirs`, in the shape the bar wants it. */
export function listDirEntries(dir: string): Promise<string[]> {
return listDir(dir).then((listing) => listing.entries);
}
// ---- grouping ----
/** Bucket a ranked list by kind for rendering. Grouping is a rendering of the
* ranking, never a reordering of it: a group holds its matches in rank order,
* and groups appear in the order their best-scoring member does — so the first
* row under the first header is the globally best result. */
export function barGroups<C extends BarCandidate>(
results: readonly MatchResult<C>[],
): BarGroup<C>[] {
const groups: BarGroup<C>[] = [];
for (const result of results) {
const kind = result.candidate.kind;
let group = groups.find((g) => g.kind === kind);
if (!group) {
group = { kind, title: KIND_TITLES[kind], results: [] };
groups.push(group);
}
group.results.push(result);
}
return groups;
}
/** The rows a query produces, flat and in rank order — what the highlight walks
* and what Enter acts on. Headers are labels, not rows, so they are never in
* this list. */
export function barRows<C extends BarCandidate>(
query: string,
candidates: readonly C[],
): MatchResult<C>[] {
return matchAndRank(query, candidates);
}
// ---- row text ----
/** The name line of a row: the session's name, the command's label, or the
* directory's path. */
export function rowName(candidate: BarCandidate): string {
switch (candidate.kind) {
case "session":
return candidate.fields.name;
case "command":
return candidate.fields.label;
case "directory":
return candidate.fields.path;
}
}
/** The secondary line under a session row: the field the match was found in
* when that was not the name, so a session found through its directory says
* so. Only sessions have one — a command shows keycaps instead, and a
* directory's path is already the name. */
export function metaText(result: BarResult): string | undefined {
if (result.candidate.kind !== "session") return undefined;
const fields = result.candidate.fields;
switch (result.field) {
case "directory":
return fields.directory;
case "command":
return fields.command;
case "tool":
return fields.tool;
default:
return fields.directory;
}
}
/** One run of a row's text, flagged when the matcher matched inside it. The
* React rendering of `renderHighlighted`: ranges come from the matcher as
* `[start, end)` character pairs over the field that won. */
export interface HighlightSegment {
text: string;
hit: boolean;
}
export function highlightSegments(
text: string,
ranges: readonly MatchRange[],
): HighlightSegment[] {
const out: HighlightSegment[] = [];
let cursor = 0;
for (const [start, end] of ranges) {
if (start > cursor) out.push({ text: text.slice(cursor, start), hit: false });
out.push({ text: text.slice(start, end), hit: true });
cursor = end;
}
if (cursor < text.length) out.push({ text: text.slice(cursor), hit: false });
return out;
}
export { sessionName };
export type { CandidateKind, MatchRange, MatchResult };
@@ -0,0 +1,76 @@
// @vitest-environment happy-dom
import { act, cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { OPEN_COMMAND_BAR_EVENT } from "../../catalog.js";
import { useCommandBar, type UseCommandBarOptions } from "./useCommandBar.js";
let controls: ReturnType<typeof useCommandBar>;
function Probe(opts: UseCommandBarOptions) {
controls = useCommandBar(opts);
return <div>{controls.open ? "open" : "closed"}</div>;
}
function ctrlK(target: EventTarget = window): void {
act(() => {
target.dispatchEvent(
new KeyboardEvent("keydown", { key: "k", ctrlKey: true, bubbles: true, cancelable: true }),
);
});
}
afterEach(cleanup);
describe("useCommandBar", () => {
it("opens on ctrl-k, from the capture phase so an attached xterm never sees it", () => {
const view = render(<Probe />);
ctrlK();
expect(view.container.textContent).toBe("open");
});
it("stays deaf while the bar is already up — its own input owns the keys", () => {
const onOpen = vi.fn();
render(<Probe onOpen={onOpen} />);
ctrlK();
ctrlK();
expect(onOpen).toHaveBeenCalledTimes(1);
});
it("leaves a form field's own Ctrl-K alone", () => {
const field = document.createElement("input");
document.body.appendChild(field);
const view = render(<Probe />);
ctrlK(field);
expect(view.container.textContent).toBe("closed");
field.remove();
});
it("opens from the sidebar's search field, the touch entry point", () => {
const view = render(<Probe />);
act(() => {
document.dispatchEvent(new CustomEvent(OPEN_COMMAND_BAR_EVENT));
});
expect(view.container.textContent).toBe("open");
});
it("runs the caller's hook before opening, so a preview is discarded first", () => {
const onOpen = vi.fn();
render(<Probe onOpen={onOpen} />);
ctrlK();
expect(onOpen).toHaveBeenCalledTimes(1);
});
it("closes on demand", () => {
const view = render(<Probe />);
ctrlK();
act(() => controls.closeBar());
expect(view.container.textContent).toBe("closed");
});
it("binds nothing while disabled — another overlay owns the keyboard", () => {
const view = render(<Probe enabled={false} />);
ctrlK();
expect(view.container.textContent).toBe("closed");
});
});
@@ -0,0 +1,65 @@
// Opening and closing the bar (docs/COMMAND-BAR.md, "Opening"). `ctrl-k` is a
// registry command like any other, so the `?` overlay lists it without a
// second list; the touch entry point — the sidebar's search field — raises
// `OPEN_COMMAND_BAR_EVENT` and lands on the same open.
import { useCallback, useEffect, useState } from "react";
import { OPEN_COMMAND_BAR_EVENT } from "../../catalog.js";
import { CommandSet, defineCommands, isBarTypingTarget } from "../../commands.js";
export interface UseCommandBarOptions {
/** Run before the bar opens — the shell discards a preview here, since the
* bar is not previewed into (docs/PREVIEW.md). */
onOpen?(): void;
/** Off while another overlay owns the keyboard (the restart overlay, the
* help dialog). Defaults to on. */
enabled?: boolean;
}
export interface CommandBarControls {
open: boolean;
openBar(): void;
closeBar(): void;
}
export function useCommandBar(opts: UseCommandBarOptions = {}): CommandBarControls {
const { onOpen, enabled = true } = opts;
const [open, setOpen] = useState(false);
const openBar = useCallback(() => {
onOpen?.();
setOpen(true);
}, [onOpen]);
const closeBar = useCallback(() => setOpen(false), []);
useEffect(() => {
if (!enabled) return;
const keys = new CommandSet(defineCommands({ "bar.open": openBar }));
// Capture phase: an attached xterm swallows every keystroke it is given, so
// the bar's binding has to be seen ahead of the terminal.
const onKeydown = (e: KeyboardEvent) => {
// The bar owns the keyboard while it is up; its own input handles the keys.
if (open) return;
// The pane's hidden textarea is not a typing target for the bar: ctrl-k
// is seen ahead of xterm and opens the bar. A real form field — the spawn
// form's inputs — keeps its own Ctrl+K.
if (isBarTypingTarget(e.target)) return;
keys.handle(e);
};
const onTouchEntry = () => {
if (!open) openBar();
};
window.addEventListener("keydown", onKeydown, true);
document.addEventListener(OPEN_COMMAND_BAR_EVENT, onTouchEntry);
return () => {
window.removeEventListener("keydown", onKeydown, true);
document.removeEventListener(OPEN_COMMAND_BAR_EVENT, onTouchEntry);
};
}, [enabled, open, openBar]);
return { open, openBar, closeBar };
}
@@ -0,0 +1,68 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ContextMenu } from "./ContextMenu.js";
afterEach(() => {
cleanup();
});
function labels(): string[] {
return [...document.querySelectorAll(".context-menu-item")].map((b) => b.textContent ?? "");
}
describe("ContextMenu (#125)", () => {
it("renders one item per entry, in order", () => {
render(
<ContextMenu
x={10}
y={20}
items={[
{ label: "Rename…", onSelect: () => {} },
{ label: "Kill session", onSelect: () => {}, danger: true },
]}
onClose={vi.fn()}
/>,
);
expect(labels()).toEqual(["Rename…", "Kill session"]);
expect(document.querySelector(".context-menu-item.is-danger")?.textContent).toBe("Kill session");
});
it("runs the selected entry and closes", () => {
const chosen = vi.fn();
const onClose = vi.fn();
render(<ContextMenu x={0} y={0} items={[{ label: "Rename…", onSelect: chosen }]} onClose={onClose} />);
fireEvent.click(screen.getByRole("menuitem"));
expect(chosen).toHaveBeenCalledOnce();
expect(onClose).toHaveBeenCalledOnce();
});
it("closes on Escape and on a press outside it", () => {
const onClose = vi.fn();
render(<ContextMenu x={0} y={0} items={[{ label: "Rename…", onSelect: () => {} }]} onClose={onClose} />);
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
fireEvent(document.body, new Event("pointerdown", { bubbles: true }));
expect(onClose).toHaveBeenCalledTimes(2);
});
it("stays open for a press inside itself", () => {
const onClose = vi.fn();
render(<ContextMenu x={0} y={0} items={[{ label: "Rename…", onSelect: () => {} }]} onClose={onClose} />);
const menu = document.querySelector(".context-menu") as HTMLElement;
fireEvent(menu, new Event("pointerdown", { bubbles: true }));
expect(onClose).not.toHaveBeenCalled();
});
it("closes on scroll and on resize", () => {
const onClose = vi.fn();
render(<ContextMenu x={0} y={0} items={[{ label: "Rename…", onSelect: () => {} }]} onClose={onClose} />);
fireEvent.scroll(window);
expect(onClose).toHaveBeenCalledTimes(1);
fireEvent.resize(window);
expect(onClose).toHaveBeenCalledTimes(2);
});
});
+86
View File
@@ -0,0 +1,86 @@
// React port of ../../contextMenu.ts (#125), the general-purpose version —
// unlike the sidebar's own menu, this one also closes on scroll/resize, since
// a menu anchored to coordinates goes stale the moment the page moves under it.
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
export interface ContextMenuItem {
label: string;
onSelect(): void;
/** Renders the entry in the destructive style. */
danger?: boolean;
}
export interface ContextMenuProps {
x: number;
y: number;
items: ContextMenuItem[];
onClose(): void;
}
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState({ left: x, top: y });
// Nudge back inside the viewport once the menu has a measurable size.
useLayoutEffect(() => {
const rect = ref.current?.getBoundingClientRect();
if (!rect) return;
const maxX = window.innerWidth - rect.width;
const maxY = window.innerHeight - rect.height;
setPos({ left: Math.max(0, Math.min(x, maxX)), top: Math.max(0, Math.min(y, maxY)) });
}, [x, y]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
const onPointerDown = (e: Event) => {
if (!ref.current?.contains(e.target as Node)) onClose();
};
document.addEventListener("keydown", onKeyDown, true);
window.addEventListener("pointerdown", onPointerDown, true);
window.addEventListener("scroll", onClose, true);
window.addEventListener("resize", onClose);
return () => {
document.removeEventListener("keydown", onKeyDown, true);
window.removeEventListener("pointerdown", onPointerDown, true);
window.removeEventListener("scroll", onClose, true);
window.removeEventListener("resize", onClose);
};
}, [onClose]);
useEffect(() => {
(ref.current?.firstElementChild as HTMLElement | null)?.focus();
}, []);
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: pos.left, top: pos.top }}
>
{items.map((item) => (
<button
key={item.label}
type="button"
role="menuitem"
className={`context-menu-item block w-full px-sp-3 py-sp-1 text-left text-fs-12 hover:bg-surface-2 ${
item.danger ? "is-danger text-danger" : "text-ink"
}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onClose();
item.onSelect();
}}
>
{item.label}
</button>
))}
</div>,
document.body,
);
}
+1
View File
@@ -0,0 +1 @@
export { ContextMenu, type ContextMenuItem, type ContextMenuProps } from "./ContextMenu.js";
@@ -0,0 +1,82 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { keyCaps } from "../../commands.js";
import { groupedCommands } from "../../help.js";
import { HelpOverlay } from "./HelpOverlay.js";
afterEach(() => {
cleanup();
});
function press(key: string, init: KeyboardEventInit = {}): void {
fireEvent.keyDown(window, { key, ...init });
}
describe("HelpOverlay", () => {
it("opens on ? and closes on a second ?", () => {
render(<HelpOverlay />);
expect(screen.queryByRole("dialog")).toBeNull();
press("?", { shiftKey: true });
expect(screen.queryByRole("dialog")).not.toBeNull();
press("?", { shiftKey: true });
expect(screen.queryByRole("dialog")).toBeNull();
});
it("ignores ? while typing and when a non-shift modifier is held", () => {
render(<HelpOverlay />);
const input = document.createElement("input");
document.body.appendChild(input);
fireEvent.keyDown(input, { key: "?", shiftKey: true });
expect(screen.queryByRole("dialog")).toBeNull();
press("?", { ctrlKey: true });
expect(screen.queryByRole("dialog")).toBeNull();
input.remove();
});
it("closes on Escape, on the close button, and on a click outside the panel", () => {
render(<HelpOverlay />);
press("?", { shiftKey: true });
press("Escape");
expect(screen.queryByRole("dialog")).toBeNull();
press("?", { shiftKey: true });
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(screen.queryByRole("dialog")).toBeNull();
press("?", { shiftKey: true });
fireEvent.click(screen.getByRole("dialog"));
expect(screen.queryByRole("dialog")).toBeNull();
});
it("stays open for a click inside the panel", () => {
render(<HelpOverlay />);
press("?", { shiftKey: true });
fireEvent.click(document.querySelector(".help-panel") as HTMLElement);
expect(screen.queryByRole("dialog")).not.toBeNull();
});
it("renders exactly the commands the registry has bound, grouped", () => {
render(<HelpOverlay />);
press("?", { shiftKey: true });
const groups = groupedCommands();
expect([...document.querySelectorAll(".help-group-title")].map((h) => h.textContent)).toEqual(
groups.map((g) => g.title),
);
expect(
[...document.querySelectorAll<HTMLElement>(".help-row")].map((r) => r.dataset.commandId),
).toEqual(groups.flatMap((g) => g.items.map((c) => c.id)));
expect([...document.querySelectorAll(".help-desc")].map((d) => d.textContent)).toEqual(
groups.flatMap((g) => g.items.map((c) => c.label)),
);
expect(
[...document.querySelectorAll(".help-row")].map((r) =>
[...r.querySelectorAll("kbd")].map((k) => k.textContent).join(" "),
),
).toEqual(groups.flatMap((g) => g.items.map((c) => keyCaps(c.keys[0]).join(" "))));
});
});
@@ -0,0 +1,78 @@
// React port of ../../help.ts: the `?` overlay renders the command registry
// (commands.ts) directly — the same list that dispatches the keys — so a
// binding can never be documented as one thing and do another.
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { CommandSet, defineCommands, keyCaps } from "../../commands.js";
import { groupedCommands } from "../../help.js";
export function HelpOverlay() {
const [open, setOpen] = useState(false);
useEffect(() => {
const helpKeys = new CommandSet(defineCommands({ "help.toggle": () => setOpen((o) => !o) }));
const overlayKeys = new CommandSet(defineCommands({ "help.close": () => setOpen(false) }));
const onKeydown = (e: KeyboardEvent) => {
if (open && overlayKeys.handle(e)) return;
helpKeys.handle(e);
};
window.addEventListener("keydown", onKeydown);
return () => window.removeEventListener("keydown", onKeydown);
}, [open]);
if (!open) return null;
const close = () => setOpen(false);
return createPortal(
<div
role="dialog"
aria-modal="true"
aria-label="Keyboard shortcuts"
className="help-overlay fixed inset-0 z-modal flex items-center justify-center bg-bg/70"
onClick={(e) => {
if (e.target === e.currentTarget) close();
}}
>
<div className="help-panel max-h-[80vh] w-full max-w-[560px] overflow-auto rounded-r-3 border border-border bg-surface p-sp-4 shadow-2">
<header className="help-head flex items-center justify-between">
<h2 className="help-title m-0 text-fs-15 font-semibold text-ink">Keyboard shortcuts</h2>
<button
type="button"
aria-label="Close"
className="help-close rounded-r-1 px-sp-2 py-1 text-fs-15 text-muted hover:text-ink"
onClick={close}
>
✕
</button>
</header>
{groupedCommands().map((group) => (
<section key={group.title} className="help-group mt-sp-3">
<h3 className="help-group-title text-fs-12 font-semibold text-muted">{group.title}</h3>
<dl className="help-list mt-sp-1">
{group.items.map((cmd) => (
<div
key={cmd.id}
className="help-row flex items-center justify-between gap-sp-3 py-sp-1"
data-command-id={cmd.id}
>
<dt className="help-keys m-0 flex gap-sp-1">
{keyCaps(cmd.keys[0]).map((cap, i) => (
<kbd key={i} className="rounded-r-1 border border-border px-sp-1 text-fs-11">
{cap}
</kbd>
))}
</dt>
<dd className="help-desc m-0 text-fs-12 text-ink">{cmd.label}</dd>
</div>
))}
</dl>
</section>
))}
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,74 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { KillDialog } from "./KillDialog.js";
afterEach(() => {
cleanup();
});
describe("KillDialog", () => {
it("focuses the kill button, so Enter confirms", () => {
render(<KillDialog name="alpha" blockers={null} onConfirm={() => Promise.resolve()} onCancel={vi.fn()} />);
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Kill" }));
});
it("focuses the force button too", () => {
render(
<KillDialog
name="alpha"
blockers={null}
force
reason="worktree is dirty."
onConfirm={() => Promise.resolve()}
onCancel={vi.fn()}
/>,
);
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Force kill" }));
});
it("names the blockers in the confirm body", () => {
render(
<KillDialog
name="alpha"
blockers={{ uncommitted_files: 2, untracked_files: 0, unpushed_commits: 0 }}
onConfirm={() => Promise.resolve()}
onCancel={vi.fn()}
/>,
);
expect(screen.getByText(/will be refused unless it is forced/)).not.toBeNull();
});
it("cancels on Cancel, on Escape and on a backdrop click", () => {
const onCancel = vi.fn();
const first = render(<KillDialog name="alpha" blockers={null} onConfirm={() => Promise.resolve()} onCancel={onCancel} />);
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onCancel).toHaveBeenCalledTimes(1);
first.unmount();
const second = render(<KillDialog name="alpha" blockers={null} onConfirm={() => Promise.resolve()} onCancel={onCancel} />);
fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" });
expect(onCancel).toHaveBeenCalledTimes(2);
second.unmount();
render(<KillDialog name="alpha" blockers={null} onConfirm={() => Promise.resolve()} onCancel={onCancel} />);
fireEvent.click(screen.getByRole("dialog"));
expect(onCancel).toHaveBeenCalledTimes(3);
});
it("disables the button while pending and shows a rejection inline, so the press can be retried", async () => {
let reject: (e: Error) => void = () => {};
const onConfirm = vi.fn(() => new Promise<void>((_, r) => (reject = r)));
render(<KillDialog name="alpha" blockers={null} onConfirm={onConfirm} onCancel={vi.fn()} />);
const kill = screen.getByRole("button", { name: "Kill" }) as HTMLButtonElement;
fireEvent.click(kill);
expect(onConfirm).toHaveBeenCalledOnce();
expect(kill.disabled).toBe(true);
reject(new Error("daemon unreachable"));
await waitFor(() => expect(kill.disabled).toBe(false));
expect(screen.getByText("Failed to kill: daemon unreachable")).not.toBeNull();
});
});
+95
View File
@@ -0,0 +1,95 @@
// React port of ../../killDialog.ts: the two kill prompts (#81, #160). The
// daemon refuses an unforced kill over a worktree holding unsaved work; the
// same component in `force` mode is the override that follows that refusal.
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { formatInTheWay, type InTheWay } from "../../types.js";
export interface KillDialogProps {
/** Session name, as the sidebar shows it. */
name: string;
/** What the card last saw in the way, when anything was. */
blockers: InTheWay | null;
/** Set once the daemon has refused an unforced kill: this pass forces it,
* and `reason` carries the daemon's refusal message. */
force?: boolean;
/** Message from the daemon's refusal, shown when there are no blockers to
* spell out. */
reason?: string;
/** Run the kill (or the forced kill). A rejection is rendered inline, and
* the dialog stays open so the press can be retried. */
onConfirm(): Promise<void>;
onCancel(): void;
}
export function KillDialog(props: KillDialogProps) {
const { name, blockers, force, reason, onConfirm, onCancel } = props;
const confirmRef = useRef<HTMLButtonElement>(null);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
// The acting button takes the focus in both modes: the operator already
// asked for the kill, so Enter confirms it.
useEffect(() => confirmRef.current?.focus(), []);
const handleConfirm = () => {
setPending(true);
setError(null);
void onConfirm().catch((e: unknown) => {
setPending(false);
setError(`Failed to kill: ${(e as Error).message}`);
});
};
const body = blockers
? force
? `Its worktree still has ${formatInTheWay(blockers)}. Forcing the kill removes the session and leaves the worktree on disk.`
: `Its worktree still has ${formatInTheWay(blockers)}. The kill will be refused unless it is forced.`
: force
? `${reason ?? ""} Forcing the kill removes the session and leaves the worktree on disk.`
: "The session and everything running in it go away. Its worktree stays on disk.";
return createPortal(
<div
role="dialog"
aria-modal="true"
aria-label={`Kill ${name}`}
className="kill-dialog fixed inset-0 z-modal flex items-center justify-center bg-bg/70"
onClick={(e) => {
if (e.target === e.currentTarget) onCancel();
}}
onKeyDown={(e) => {
if (e.key === "Escape") onCancel();
}}
>
<div className="kill-dialog-panel min-w-[280px] max-w-[420px] rounded-r-2 border border-border-strong bg-surface p-sp-4 shadow-2">
<h2 className="kill-dialog-title m-0 text-fs-15 font-semibold text-ink">Kill &quot;{name}&quot;?</h2>
<p className="kill-dialog-body mt-sp-2 text-fs-13 text-ink">{body}</p>
{error && <p className="kill-dialog-error mt-sp-2 text-fs-12 text-danger">{error}</p>}
<div className="kill-dialog-actions mt-sp-3 flex justify-end gap-sp-2">
<button
type="button"
className="kill-dialog-cancel rounded-r-1 border border-border px-sp-3 py-sp-1 text-fs-12 text-ink"
onClick={onCancel}
>
Cancel
</button>
<button
ref={confirmRef}
type="button"
disabled={pending}
className={`rounded-r-1 bg-danger px-sp-3 py-sp-1 text-fs-12 text-accent-ink disabled:opacity-60 ${
force ? "kill-dialog-force" : "kill-dialog-kill"
}`}
onClick={handleConfirm}
>
{force ? "Force kill" : "Kill"}
</button>
</div>
</div>
</div>,
document.body,
);
}
+2
View File
@@ -0,0 +1,2 @@
export { KillDialog, type KillDialogProps } from "./KillDialog.js";
export { HelpOverlay } from "./HelpOverlay.js";
@@ -0,0 +1,79 @@
// @vitest-environment happy-dom
import { cleanup, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useViewport } from "./useViewport.js";
function Probe() {
useViewport();
return null;
}
class FakeVisualViewport {
height = 800;
offsetTop = 0;
private listeners: Record<string, Array<() => void>> = { resize: [], scroll: [] };
addEventListener(type: string, cb: () => void): void {
this.listeners[type].push(cb);
}
removeEventListener(type: string, cb: () => void): void {
this.listeners[type] = this.listeners[type].filter((l) => l !== cb);
}
fire(type: "resize" | "scroll"): void {
for (const l of this.listeners[type]) l();
}
}
let vv: FakeVisualViewport;
beforeEach(() => {
vv = new FakeVisualViewport();
vi.stubGlobal("visualViewport", vv);
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
cb(0);
return 0;
});
vi.stubGlobal("cancelAnimationFrame", () => {});
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
document.documentElement.className = "";
document.documentElement.removeAttribute("style");
});
describe("useViewport", () => {
it("mirrors the visual viewport's height and offset onto CSS custom properties", () => {
vv.height = 640;
vv.offsetTop = 12;
render(<Probe />);
const root = document.documentElement.style;
expect(root.getPropertyValue("--app-vh")).toBe("640px");
expect(root.getPropertyValue("--app-top")).toBe("12px");
});
it("no-ops when visualViewport is unavailable", () => {
vi.stubGlobal("visualViewport", undefined);
expect(() => render(<Probe />)).not.toThrow();
expect(document.documentElement.style.getPropertyValue("--app-vh")).toBe("");
});
it("marks kb-open once an editable shrinks the viewport well past the baseline", () => {
render(<Probe />);
const input = document.createElement("input");
document.body.appendChild(input);
input.focus();
vv.height = 800 - 200;
vv.fire("resize");
expect(document.documentElement.classList.contains("kb-open")).toBe(true);
input.blur();
vv.height = 800;
vv.fire("resize");
expect(document.documentElement.classList.contains("kb-open")).toBe(false);
input.remove();
});
});
+51
View File
@@ -0,0 +1,51 @@
// React port of ../../viewport.ts. Binds the app shell's height and position
// to the *visual* viewport rather than the layout one, so a pinned bottom bar
// (the attach view's special-key bar, NAP-342) lands above an open on-screen
// keyboard instead of behind it. See viewport.ts for the full mechanism.
import { useEffect } from "react";
const KB_OPEN_THRESHOLD = 150;
export function useViewport(): void {
useEffect(() => {
const vv = window.visualViewport;
if (!vv) return;
const root = document.documentElement;
let raf = 0;
let baseline = vv.height;
const isEditing = () => {
const a = document.activeElement as HTMLElement | null;
if (!a) return false;
return a.tagName === "TEXTAREA" || a.tagName === "INPUT" || a.isContentEditable;
};
const sync = () => {
raf = 0;
root.style.setProperty("--app-vh", `${Math.round(vv.height)}px`);
root.style.setProperty("--app-top", `${Math.round(vv.offsetTop)}px`);
// Nothing focused ⇒ keyboard can't be open ⇒ this height is the baseline.
const editing = isEditing();
if (!editing) baseline = vv.height;
const kbOpen = editing && baseline - vv.height > KB_OPEN_THRESHOLD;
root.classList.toggle("kb-open", kbOpen);
};
const schedule = () => {
if (raf) return;
raf = requestAnimationFrame(sync);
};
vv.addEventListener("resize", schedule);
vv.addEventListener("scroll", schedule);
sync();
return () => {
vv.removeEventListener("resize", schedule);
vv.removeEventListener("scroll", schedule);
if (raf) cancelAnimationFrame(raf);
};
}, []);
}
+154
View File
@@ -0,0 +1,154 @@
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "../../catalog.js";
import { TOGGLE_DRAWER_EVENT } from "../../drawer.js";
import { Drawer } from "./Drawer.js";
/** Force the mobile media query on, whatever happy-dom's viewport says
* (mirrors ../layout/MultiView's own `forceMobile` in multiview.test.ts). */
function forceMobile(matches: boolean): void {
vi.spyOn(window, "matchMedia").mockImplementation(
(query: string) =>
({
matches: query.includes("859") && matches,
media: query,
onchange: null,
addEventListener() {},
removeEventListener() {},
addListener() {},
removeListener() {},
dispatchEvent: () => false,
}) as unknown as MediaQueryList,
);
}
function shell(): HTMLElement {
return document.querySelector(".app-shell") as HTMLElement;
}
function touch(type: "touchstart" | "touchend", x: number, y: number): void {
const touches = [{ clientX: x, clientY: y }];
const event = new Event(type, { bubbles: true }) as unknown as {
touches: typeof touches;
changedTouches: typeof touches;
};
event.touches = touches;
event.changedTouches = touches;
act(() => {
document.dispatchEvent(event as unknown as Event);
});
}
beforeEach(() => forceMobile(true));
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
function openViaToggle(): void {
act(() => {
document.dispatchEvent(new CustomEvent(TOGGLE_DRAWER_EVENT));
});
}
describe("Drawer (#35)", () => {
it("toggles open on the toggle event, off on desktop by class alone", () => {
render(
<Drawer sidebar={<div className="tree-leaf">row</div>}>
<div>content</div>
</Drawer>,
);
expect(shell().classList.contains("drawer-open")).toBe(false);
openViaToggle();
expect(shell().classList.contains("drawer-open")).toBe(true);
});
it("closes when a session row is tapped", () => {
render(
<Drawer sidebar={<div className="tree-leaf">row</div>}>
<div>content</div>
</Drawer>,
);
openViaToggle();
fireEvent.click(document.querySelector(".tree-leaf") as HTMLElement);
expect(shell().classList.contains("drawer-open")).toBe(false);
});
it("closes when a spawn is requested", () => {
render(
<Drawer sidebar={<div className="tree-leaf">row</div>}>
<div>content</div>
</Drawer>,
);
openViaToggle();
act(() => {
document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT));
});
expect(shell().classList.contains("drawer-open")).toBe(false);
});
it("closes when spawn similar is requested", () => {
render(
<Drawer sidebar={<div className="tree-leaf">row</div>}>
<div>content</div>
</Drawer>,
);
openViaToggle();
act(() => {
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: {} }));
});
expect(shell().classList.contains("drawer-open")).toBe(false);
});
it("closes on a backdrop click", () => {
render(
<Drawer sidebar={<div className="tree-leaf">row</div>}>
<div>content</div>
</Drawer>,
);
openViaToggle();
fireEvent.click(document.querySelector(".mv-backdrop") as HTMLElement);
expect(shell().classList.contains("drawer-open")).toBe(false);
});
it("opens on a rightward swipe and closes on a leftward one, only on mobile", () => {
render(
<Drawer sidebar={<div className="tree-leaf">row</div>}>
<div>content</div>
</Drawer>,
);
touch("touchstart", 0, 100);
touch("touchend", 100, 100);
expect(shell().classList.contains("drawer-open")).toBe(true);
touch("touchstart", 100, 100);
touch("touchend", 0, 100);
expect(shell().classList.contains("drawer-open")).toBe(false);
});
it("ignores swipes on desktop", () => {
forceMobile(false);
render(
<Drawer sidebar={<div className="tree-leaf">row</div>}>
<div>content</div>
</Drawer>,
);
touch("touchstart", 0, 100);
touch("touchend", 100, 100);
expect(shell().classList.contains("drawer-open")).toBe(false);
});
it("ignores a mostly-vertical or too-short gesture", () => {
render(
<Drawer sidebar={<div className="tree-leaf">row</div>}>
<div>content</div>
</Drawer>,
);
touch("touchstart", 0, 0);
touch("touchend", 10, 100);
expect(shell().classList.contains("drawer-open")).toBe(false);
});
});
+93
View File
@@ -0,0 +1,93 @@
// React port of ../../drawer.ts (#35): the mobile off-canvas sidebar. On
// desktop the `drawer-open` class is inert — CSS gates the drawer styling to
// narrow viewports (styles.css, `.app-shell`).
//
// Anything that puts a new view in the content area must close the drawer, or
// it renders behind the sidebar and the tap reads as a dead button — this
// listens for the same TOGGLE_DRAWER_EVENT/OPEN_SPAWN_EVENT/SPAWN_SIMILAR_EVENT
// the rest of the shell already raises, so it needs no wiring from callers.
import type { ReactNode } from "react";
import { useEffect, useRef, useState } from "react";
import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "../../catalog.js";
import { TOGGLE_DRAWER_EVENT } from "../../drawer.js";
export interface DrawerProps {
sidebar: ReactNode;
children: ReactNode;
className?: string;
}
export function Drawer({ sidebar, children, className }: DrawerProps) {
const [open, setOpen] = useState(false);
const sidebarRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const onToggle = () => setOpen((o) => !o);
const onClose = () => setOpen(false);
document.addEventListener(TOGGLE_DRAWER_EVENT, onToggle);
document.addEventListener(OPEN_SPAWN_EVENT, onClose);
document.addEventListener(SPAWN_SIMILAR_EVENT, onClose);
return () => {
document.removeEventListener(TOGGLE_DRAWER_EVENT, onToggle);
document.removeEventListener(OPEN_SPAWN_EVENT, onClose);
document.removeEventListener(SPAWN_SIMILAR_EVENT, onClose);
};
}, []);
// Horizontal swipes drive the drawer (right opens, left closes) rather than
// the browser's back/forward navigation. Only a clearly horizontal gesture
// past a threshold counts, so vertical terminal scrolling (which owns its
// own touch handler) is untouched.
useEffect(() => {
const mobileMq = window.matchMedia("(max-width: 859px)");
let sx = 0;
let sy = 0;
let tracking = false;
const onTouchStart = (e: TouchEvent) => {
tracking = mobileMq.matches && e.touches.length === 1;
if (tracking) {
sx = e.touches[0].clientX;
sy = e.touches[0].clientY;
}
};
const onTouchEnd = (e: TouchEvent) => {
if (!tracking) return;
tracking = false;
const t = e.changedTouches[0];
const dx = t.clientX - sx;
const dy = t.clientY - sy;
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
setOpen((o) => {
if (dx > 0 && !o) return true;
if (dx < 0 && o) return false;
return o;
});
};
document.addEventListener("touchstart", onTouchStart, { passive: true });
document.addEventListener("touchend", onTouchEnd, { passive: true });
return () => {
document.removeEventListener("touchstart", onTouchStart);
document.removeEventListener("touchend", onTouchEnd);
};
}, []);
return (
<div className={`app-shell flex h-full min-h-0 ${open ? "drawer-open" : ""} ${className ?? ""}`}>
<div
ref={sidebarRef}
className="sidebar flex h-full min-h-0 flex-col bg-surface"
onClick={(e) => {
if ((e.target as HTMLElement).closest(".tree-leaf")) setOpen(false);
}}
>
{sidebar}
</div>
<section className="content min-h-0 flex-1">{children}</section>
<div className="mv-backdrop" onClick={() => setOpen(false)} />
</div>
);
}
@@ -0,0 +1,152 @@
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { leaves } from "../../layout.js";
import { createLayoutStore } from "../../stores/layoutStore.js";
import type { Session } from "../../types.js";
import { MultiView } from "./MultiView.js";
vi.mock("../terminal/TerminalPane.js", () => ({
TerminalPane: ({ sessionId }: { sessionId: string }) => <div data-testid={`terminal-${sessionId}`} />,
}));
vi.mock("../structured/StructuredPane.js", () => ({
StructuredPane: ({ sessionId }: { sessionId: string }) => <div data-testid={`structured-${sessionId}`} />,
}));
afterEach(cleanup);
function store() {
return createLayoutStore({
load: () => null,
save: vi.fn(),
scheduler: { setTimer: () => 1, clearTimer: () => {} },
});
}
function session(id: string, over: Partial<Session> = {}): Session {
return {
id,
cwd: "/home/n",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-01-01T00:00:00Z",
...over,
} as Session;
}
describe("MultiView", () => {
it("shows the empty state when the active tab has no panes", () => {
const s = store();
render(<MultiView store={s} />);
expect(screen.getByText(/No session open/)).toBeTruthy();
});
it("renders a pane per leaf, dispatching to the terminal or structured body", () => {
const s = store();
s.getState().openSession("s1");
s.getState().openSessionBeside("s2");
const sessions = new Map([
["s1", session("s1")],
["s2", session("s2", { view: "Structured" })],
]);
render(<MultiView store={s} sessions={sessions} />);
expect(screen.getByTestId("terminal-s1")).toBeTruthy();
expect(screen.getByTestId("structured-s2")).toBeTruthy();
});
it("split right adds a second pane to the tree", () => {
const s = store();
s.getState().openSession("s1");
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
fireEvent.click(screen.getByTitle("Split right"));
expect(screen.getAllByText("Empty pane")).toHaveLength(1);
});
it("close pane removes it", () => {
const s = store();
s.getState().openSession("s1");
const leafId = leaves(s.getState().layout.tabs[0].root)[0].id;
s.getState().splitFocused(leafId, "row"); // adds an empty leaf beside s1
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
// splitFocused(after: true) puts the fresh empty leaf second — close it.
fireEvent.click(screen.getAllByTitle("Close pane")[1]);
expect(screen.queryByText("Empty pane")).toBeNull();
expect(screen.getByTestId("terminal-s1")).toBeTruthy();
});
it("prunes a leaf whose session no longer exists, once the live set is known", () => {
const s = store();
s.getState().openSession("s1");
s.getState().openSessionBeside("dead");
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
expect(screen.getByTestId("terminal-s1")).toBeTruthy();
expect(screen.queryByTestId("terminal-dead")).toBeNull();
});
it("does not touch the layout while the live set is still unknown", () => {
const s = store();
s.getState().openSession("s1");
render(<MultiView store={s} />);
expect(screen.getByTestId("terminal-s1")).toBeTruthy();
});
describe("preview mode (docs/PREVIEW.md)", () => {
it("shows a session no pane holds in a throwaway tab, marked as a preview", () => {
const s = store();
s.getState().openSession("s1");
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
act(() => s.getState().previewSession("s9"));
// s1's own tab is still in the strip — the preview never touched it.
expect(screen.getByText("Tab 1 · 1")).toBeTruthy();
const pane = document.querySelector('[data-session-id="s9"]');
expect(pane).toBeTruthy();
expect(pane!.querySelector(".bg-preview-tint")).toBeTruthy();
expect(screen.getByText("preview")).toBeTruthy();
});
it("puts the layout back byte for byte once the preview ends", () => {
const s = store();
s.getState().openSession("s1");
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
const before = JSON.stringify(s.getState().layout);
act(() => s.getState().previewSession("s9"));
act(() => s.getState().previewSession(null));
expect(JSON.stringify(s.getState().layout)).toBe(before);
expect(screen.queryByText("preview")).toBeNull();
});
it("does not grab the keyboard while previewing", () => {
const s = store();
s.getState().openSession("s1");
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
act(() => s.getState().previewSession("s9"));
const pane = document.querySelector('[data-session-id="s9"]') as HTMLElement;
expect(pane.querySelector('[data-testid="terminal-s9"]')).toBeTruthy();
// TerminalPane's `focus` prop is verified by TerminalPane's own tests;
// here the preview must never end up as the active-tab's accent pane.
expect(pane.className).not.toContain("border-accent");
});
it("clicking into a previewed pane commits it: no more preview, nothing moves a second time", () => {
const s = store();
s.getState().openSession("s1");
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
act(() => s.getState().previewSession("s9"));
const pane = document.querySelector('[data-session-id="s9"]') as HTMLElement;
fireEvent.pointerDown(pane);
expect(s.getState().preview).toBeNull();
expect(screen.queryByText("preview")).toBeNull();
expect(leaves(s.getState().layout.tabs[1].root).map((l) => l.sessionId)).toEqual(["s9"]);
});
});
});
+237
View File
@@ -0,0 +1,237 @@
// React port of multiview.ts's tab + split tree (issue #249) plus its preview
// mode (issue #251, docs/PREVIEW.md). Sidebar drag-to-dock and the mobile
// single-pane collapse stay with whichever issue wires this into the shell
// (#253) — this renders the tree itself: tabs, nested splits, pane chrome.
import type { ReactNode } from "react";
import { useEffect, useRef } from "react";
import { useStore } from "zustand";
import { activeTab, type Dir, type Node } from "../../layout.js";
import { usePreview } from "../../hooks/usePreview.js";
import type { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js";
import type { Session, Uuid } from "../../types.js";
import { PaneContainer } from "./PaneContainer.js";
import { PaneTitle } from "./PaneTitle.js";
import { TabBar } from "./TabBar.js";
export interface MultiViewProps {
store: LayoutStoreApi;
/** Live sessions, keyed by id — drives pane titles and (once known) boot
* reconciliation. Passing `undefined` defers reconciliation. */
sessions?: ReadonlyMap<Uuid, Session>;
onDisconnect?: (sessionId: Uuid) => void;
emptyState?: ReactNode;
}
export function MultiView({ store, sessions, onDisconnect, emptyState }: MultiViewProps) {
const layout = useStore(store, (s: LayoutStore) => s.layout);
const tab = activeTab(layout);
const { previewedSessionId, commit: commitPreview } = usePreview({ store });
const isPreviewing = previewedSessionId !== null;
const reconciledRef = useRef(false);
useEffect(() => {
if (reconciledRef.current || !sessions) return;
reconciledRef.current = true;
store.getState().reconcile(new Set(sessions.keys()));
}, [sessions, store]);
return (
<div className="mv flex h-full min-h-0 flex-col">
<TabBar store={store} />
<div className="mv-canvas relative min-h-0 flex-1">
{tab.root ? (
<SplitNode
node={tab.root}
tab={tab}
store={store}
sessions={sessions}
onDisconnect={onDisconnect}
previewedSessionId={previewedSessionId}
commitPreview={commitPreview}
/>
) : (
(emptyState ?? (
<div className="mv-empty flex h-full items-center justify-center p-sp-4 text-fs-13 text-faint">
No session open — pick one from the sidebar, or spawn a new one.
</div>
))
)}
{isPreviewing && (
<>
<div className="mv-preview-ring pointer-events-none absolute inset-0 rounded-r-2 border border-dashed border-preview-ring" />
<div className="mv-preview-chip pointer-events-none absolute right-sp-2 top-sp-2 z-sticky rounded-r-pill bg-preview px-sp-2 py-[2px] text-fs-11 text-preview-ink">
preview
</div>
</>
)}
</div>
</div>
);
}
interface TreeProps {
tab: { id: string; focusedLeafId: string | null };
store: LayoutStoreApi;
sessions?: ReadonlyMap<Uuid, Session>;
onDisconnect?: (sessionId: Uuid) => void;
previewedSessionId: Uuid | null;
commitPreview: (sessionId: Uuid) => void;
}
function SplitNode({ node, ...rest }: TreeProps & { node: Node }) {
if (node.kind === "leaf") {
return <Pane leaf={node} isFocused={rest.tab.focusedLeafId === node.id} {...rest} />;
}
return <SplitContainer node={node} {...rest} />;
}
function SplitContainer({ node, ...rest }: TreeProps & { node: Extract<Node, { kind: "split" }> }) {
const slotA = useRef<HTMLDivElement | null>(null);
const slotB = useRef<HTMLDivElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const onDividerPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault();
const divider = e.currentTarget;
const container = containerRef.current;
if (!container) return;
divider.setPointerCapture(e.pointerId);
const rect = container.getBoundingClientRect();
const move = (ev: PointerEvent) => {
const frac =
node.dir === "row" ? (ev.clientX - rect.left) / rect.width : (ev.clientY - rect.top) / rect.height;
node.ratio = Math.max(0.1, Math.min(0.9, frac));
if (slotA.current) slotA.current.style.flexGrow = String(node.ratio);
if (slotB.current) slotB.current.style.flexGrow = String(1 - node.ratio);
};
const up = (ev: PointerEvent) => {
try {
divider.releasePointerCapture(ev.pointerId);
} catch {
// already released
}
divider.removeEventListener("pointermove", move);
divider.removeEventListener("pointerup", up);
rest.store.getState().commitLayout();
};
divider.addEventListener("pointermove", move);
divider.addEventListener("pointerup", up);
};
return (
<div
ref={containerRef}
className={`mv-split flex h-full min-h-0 w-full ${node.dir === "row" ? "flex-row" : "flex-col"}`}
>
<div ref={slotA} className="mv-slot min-h-0 min-w-0" style={{ flex: `${node.ratio} 1 0` }}>
<SplitNode node={node.a} {...rest} />
</div>
<div
className={
"mv-divider flex-none bg-border-strong hover:bg-accent " +
(node.dir === "row" ? "w-[3px] cursor-col-resize" : "h-[3px] cursor-row-resize")
}
onPointerDown={onDividerPointerDown}
/>
<div ref={slotB} className="mv-slot min-h-0 min-w-0" style={{ flex: `${1 - node.ratio} 1 0` }}>
<SplitNode node={node.b} {...rest} />
</div>
</div>
);
}
function Pane({
leaf,
isFocused,
store,
sessions,
onDisconnect,
previewedSessionId,
commitPreview,
}: TreeProps & { leaf: Extract<Node, { kind: "leaf" }>; isFocused: boolean }) {
const session = leaf.sessionId ? sessions?.get(leaf.sessionId) : undefined;
const isPreview = leaf.sessionId !== null && leaf.sessionId === previewedSessionId;
const splitFocused = (dir: Dir) => store.getState().splitFocused(leaf.id, dir);
return (
<div
className={
"mv-pane flex h-full min-h-0 min-w-0 flex-col border " +
(isFocused && !isPreview ? "border-accent" : "border-border-strong")
}
data-leaf-id={leaf.id}
data-session-id={leaf.sessionId ?? undefined}
onPointerDownCapture={() => {
// Clicking into a previewed pane is the operator choosing it
// (docs/PREVIEW.md).
if (isPreview) commitPreview(leaf.sessionId!);
store.getState().focusLeaf(leaf.id);
}}
>
<div
className={
"mv-pane-header flex items-center justify-between gap-sp-2 border-b border-border-strong px-sp-2 py-sp-1 text-fs-12 " +
(isPreview ? "bg-preview-tint text-preview" : "bg-surface")
}
>
{leaf.sessionId ? (
<PaneTitle sessionId={leaf.sessionId} session={session} />
) : (
<span className="text-faint">Empty pane</span>
)}
<div className="mv-pane-tools flex items-center gap-sp-1">
{leaf.sessionId && onDisconnect && (
<button
type="button"
className="mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
title="Disconnect"
onClick={() => onDisconnect(leaf.sessionId!)}
>
⏻
</button>
)}
<button
type="button"
className="mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
title="Split right"
onClick={() => splitFocused("row")}
>
▥
</button>
<button
type="button"
className="mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
title="Split down"
onClick={() => splitFocused("col")}
>
▤
</button>
<button
type="button"
className="mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
title="Close pane"
onClick={() => store.getState().closePane(leaf.id)}
>
✕
</button>
</div>
</div>
<div className="mv-pane-host min-h-0 flex-1">
{leaf.sessionId ? (
<PaneContainer
sessionId={leaf.sessionId}
session={session}
focus={isFocused && previewedSessionId === null}
onClosed={() => store.getState().closeSession(leaf.sessionId!)}
/>
) : (
<div className="mv-pane-empty flex h-full items-center justify-center p-sp-4 text-fs-13 text-faint">
Empty pane — pick a session from the sidebar, or spawn one.
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,54 @@
// @vitest-environment happy-dom
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Session } from "../../types.js";
import { PaneContainer } from "./PaneContainer.js";
vi.mock("../terminal/TerminalPane.js", () => ({
TerminalPane: ({ sessionId, focus }: { sessionId: string; focus?: boolean }) => (
<div data-testid="terminal-pane" data-session-id={sessionId} data-focus={String(focus)} />
),
}));
vi.mock("../structured/StructuredPane.js", () => ({
StructuredPane: ({ sessionId, focus }: { sessionId: string; focus?: boolean }) => (
<div data-testid="structured-pane" data-session-id={sessionId} data-focus={String(focus)} />
),
}));
afterEach(cleanup);
function session(over: Partial<Session> = {}): Session {
return {
id: "s1",
cwd: "/home/n",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-01-01T00:00:00Z",
...over,
} as Session;
}
describe("PaneContainer", () => {
it("renders TerminalPane when the session has no view (defaults to Terminal)", () => {
render(<PaneContainer sessionId="s1" session={session()} />);
expect(screen.getByTestId("terminal-pane")).toBeTruthy();
expect(screen.queryByTestId("structured-pane")).toBeNull();
});
it("renders TerminalPane when the session isn't known yet", () => {
render(<PaneContainer sessionId="s1" />);
expect(screen.getByTestId("terminal-pane")).toBeTruthy();
});
it("renders StructuredPane for a Structured session", () => {
render(<PaneContainer sessionId="s1" session={session({ view: "Structured" })} focus={false} />);
const pane = screen.getByTestId("structured-pane");
expect(pane.dataset.sessionId).toBe("s1");
expect(pane.dataset.focus).toBe("false");
expect(screen.queryByTestId("terminal-pane")).toBeNull();
});
});
@@ -0,0 +1,49 @@
// Dispatches a leaf's session to the right body — TerminalPane for a PTY
// session, StructuredPane for an ACP one (session.view, #133/#140/#249).
import { forwardRef, useImperativeHandle, useRef } from "react";
import type { Session, Uuid } from "../../types.js";
import { StructuredPane, type StructuredPaneHandle } from "../structured/StructuredPane.js";
import { TerminalPane, type TerminalPaneHandle } from "../terminal/TerminalPane.js";
export interface PaneContainerHandle {
focusTerminal(): void;
/** No-op for a structured pane — it has no PTY (pane.ts's PaneConnection contract). */
sendInput(data: string): void;
}
export interface PaneContainerProps {
sessionId: Uuid;
session?: Session;
/** Grabs the keyboard once connected. A sidebar preview passes `false`. */
focus?: boolean;
onClosed?: () => void;
className?: string;
}
export const PaneContainer = forwardRef<PaneContainerHandle, PaneContainerProps>(function PaneContainer(
{ sessionId, session, focus, onClosed, className },
ref,
) {
const terminalRef = useRef<TerminalPaneHandle>(null);
const structuredRef = useRef<StructuredPaneHandle>(null);
useImperativeHandle(
ref,
() => ({
focusTerminal: () => {
terminalRef.current?.focusTerminal();
structuredRef.current?.focusComposer();
},
sendInput: (data: string) => terminalRef.current?.sendInput(data),
}),
[],
);
return session?.view === "Structured" ? (
<StructuredPane ref={structuredRef} sessionId={sessionId} focus={focus} onClosed={onClosed} className={className} />
) : (
<TerminalPane ref={terminalRef} sessionId={sessionId} focus={focus} onClosed={onClosed} className={className} />
);
});
@@ -0,0 +1,48 @@
// @vitest-environment happy-dom
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import type { Session } from "../../types.js";
import { PaneTitle } from "./PaneTitle.js";
afterEach(cleanup);
function session(over: Partial<Session> = {}): Session {
return {
id: "s1",
cwd: "/home/n",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-01-01T00:00:00Z",
...over,
} as Session;
}
describe("PaneTitle", () => {
it("falls back to a short session id when the session isn't known yet", () => {
render(<PaneTitle sessionId="abcdef12-3456-7890-abcd-ef1234567890" />);
expect(screen.getByText(/abcdef12/)).toBeTruthy();
});
it("shows the name alone when the command matches it", () => {
render(<PaneTitle sessionId="s1" session={session({ command: "claude", metadata: { name: "claude" } })} />);
expect(screen.getByText("claude")).toBeTruthy();
expect(document.querySelector(".at-cmd")).toBeNull();
});
it("shows the command alongside a custom name", () => {
render(
<PaneTitle sessionId="s1" session={session({ command: "hx src/main.rs", metadata: { name: "editor" } })} />,
);
expect(screen.getByText("editor")).toBeTruthy();
expect(document.querySelector(".at-cmd")).not.toBeNull();
});
it("never injects markup from a session name", () => {
render(<PaneTitle sessionId="s1" session={session({ metadata: { name: "<img onerror=x>" } })} />);
expect(document.querySelector("img")).toBeNull();
expect(screen.getByText("<img onerror=x>")).toBeTruthy();
});
});
+39
View File
@@ -0,0 +1,39 @@
// React port of paneTitle.ts: a pane header's session identity — the name,
// then the command as its tool mark when recognised, same as the sidebar
// card's own CommandFragment (SessionCard.tsx). JSX escapes text nodes, so
// unlike the vanilla string-builder this needs no manual HTML escaping.
import { splitCommand, toolIconSvg } from "../../toolIcon.js";
import { sessionName, type Session, type Uuid } from "../../types.js";
export interface PaneTitleProps {
sessionId: Uuid;
session?: Session;
className?: string;
}
export function PaneTitle({ sessionId, session, className }: PaneTitleProps) {
if (!session) {
return (
<span className={className ?? "at-title"} title={sessionId}>
session <span className="sid">{sessionId.slice(0, 8)}</span>
</span>
);
}
const name = sessionName(session);
const showCommand = Boolean(session.command) && session.command !== name;
const { tool, program } = splitCommand(session.command);
const icon = tool ? toolIconSvg(tool) : null;
return (
<span className={className ?? "at-title"} title={`${name} · ${session.command} (${sessionId})`}>
<span className="at-name">{name}</span>
{showCommand && (
<span className="at-cmd">
{icon ? <span className="contents" dangerouslySetInnerHTML={{ __html: icon }} /> : <span>{program}</span>}
</span>
)}
</span>
);
}
+88
View File
@@ -0,0 +1,88 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createLayoutStore } from "../../stores/layoutStore.js";
import { TabBar } from "./TabBar.js";
afterEach(cleanup);
function store() {
return createLayoutStore({
load: () => null,
save: vi.fn(),
scheduler: { setTimer: () => 1, clearTimer: () => {} },
});
}
function dataTransfer() {
const data = new Map<string, string>();
return {
setData: (type: string, value: string) => data.set(type, value),
getData: (type: string) => data.get(type) ?? "",
effectAllowed: "",
};
}
describe("TabBar", () => {
it("renders one tab per layout tab, marking the active one", () => {
const s = store();
s.getState().addTab();
render(<TabBar store={s} />);
const tabs = screen.getAllByRole("tab");
expect(tabs).toHaveLength(2);
expect(tabs[1].getAttribute("aria-selected")).toBe("true");
expect(tabs[0].getAttribute("aria-selected")).toBe("false");
});
it("shows the open-session count next to a tab's title", () => {
const s = store();
s.getState().openSession("s1");
render(<TabBar store={s} />);
expect(screen.getByText(/· 1/)).toBeTruthy();
});
it("clicking a tab selects it", () => {
const s = store();
s.getState().addTab();
const [first] = s.getState().layout.tabs;
render(<TabBar store={s} />);
fireEvent.click(screen.getAllByRole("tab")[0]);
expect(s.getState().layout.activeTabId).toBe(first.id);
});
it("the + button adds a tab", () => {
const s = store();
render(<TabBar store={s} />);
fireEvent.click(screen.getByTitle("New tab"));
expect(s.getState().layout.tabs).toHaveLength(2);
});
it("hides the close control on the last remaining tab", () => {
const s = store();
render(<TabBar store={s} />);
expect(screen.queryByTitle("Close tab")).toBeNull();
});
it("closing a tab removes it", () => {
const s = store();
s.getState().addTab();
render(<TabBar store={s} />);
fireEvent.click(screen.getAllByTitle("Close tab")[0]);
expect(s.getState().layout.tabs).toHaveLength(1);
});
it("dragging a tab onto another reorders them", () => {
const s = store();
s.getState().addTab();
s.getState().addTab();
const [a, b, c] = s.getState().layout.tabs;
render(<TabBar store={s} />);
const tabs = screen.getAllByRole("tab");
const dt = dataTransfer();
fireEvent.dragStart(tabs[0], { dataTransfer: dt });
fireEvent.dragOver(tabs[2], { dataTransfer: dt });
fireEvent.drop(tabs[2], { dataTransfer: dt });
expect(s.getState().layout.tabs.map((t) => t.id)).toEqual([b.id, c.id, a.id]);
});
});
+102
View File
@@ -0,0 +1,102 @@
// The tab strip above the canvas (issue #249): click to switch, drag to
// reorder, a close ✕ per tab (hidden on the last one), a trailing + to add.
import { useState } from "react";
import { useStore } from "zustand";
import { leaves } from "../../layout.js";
import type { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js";
export interface TabBarProps {
store: LayoutStoreApi;
}
export function TabBar({ store }: TabBarProps) {
const layout = useStore(store, (s: LayoutStore) => s.layout);
const previewTempTabId = useStore(store, (s: LayoutStore) => s.preview?.tempTabId ?? null);
const [draggingId, setDraggingId] = useState<string | null>(null);
const [overId, setOverId] = useState<string | null>(null);
return (
<div className="mv-tabs flex items-center gap-sp-1 overflow-x-auto border-b border-border-strong bg-surface px-sp-2 text-fs-12" role="tablist">
{layout.tabs.map((tab) => {
const count = leaves(tab.root).filter((l) => l.sessionId).length;
const isActive = tab.id === layout.activeTabId;
const isDropTarget = overId === tab.id && draggingId !== null && draggingId !== tab.id;
// A preview's throwaway tab is not a place to go — it goes on its
// own the moment the preview ends (docs/PREVIEW.md).
const isPreviewTab = tab.id === previewTempTabId;
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={isActive}
draggable
data-tab-id={tab.id}
className={
"mv-tab flex items-center gap-sp-1 rounded-t-r-1 px-sp-2 py-sp-1 " +
(isPreviewTab
? "border border-dashed border-preview-ring italic text-preview"
: isActive
? "bg-surface-2 text-ink"
: "text-faint hover:text-ink") +
(isDropTarget ? " outline outline-2 outline-accent" : "")
}
onDragStart={(e) => {
setDraggingId(tab.id);
e.dataTransfer.setData("text/plain", tab.id);
e.dataTransfer.effectAllowed = "move";
}}
onDragOver={(e) => {
if (!draggingId || draggingId === tab.id) return;
e.preventDefault();
setOverId(tab.id);
}}
onDragLeave={() => setOverId((current) => (current === tab.id ? null : current))}
onDrop={(e) => {
e.preventDefault();
const fromId = e.dataTransfer.getData("text/plain") || draggingId;
setDraggingId(null);
setOverId(null);
if (fromId && fromId !== tab.id) store.getState().reorderTab(fromId, tab.id);
}}
onDragEnd={() => {
setDraggingId(null);
setOverId(null);
}}
onClick={() => {
if (isPreviewTab) return;
store.getState().selectTab(tab.id);
}}
>
<span>
{tab.title}
{count ? ` · ${count}` : ""}
</span>
{layout.tabs.length > 1 && !isPreviewTab && (
<span
className="mv-tab-close text-faint hover:text-ink"
title="Close tab"
onClick={(e) => {
e.stopPropagation();
store.getState().closeTab(tab.id);
}}
>
✕
</span>
)}
</button>
);
})}
<button
type="button"
className="mv-tab-add px-sp-2 py-sp-1 text-muted hover:text-ink"
title="New tab"
onClick={() => store.getState().addTab()}
>
+
</button>
</div>
);
}
+2 -2
View File
@@ -6,7 +6,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useStore } from "zustand";
import { ApiRequestError, killSession, renameSession } from "../../api.js";
import type { CatalogStoreApi } from "../../stores/catalogStore.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";
@@ -349,7 +349,7 @@ function useSessions(props: SidebarProps): Session[] {
// `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) => (s as { sessions: Map<string, Session> }).sessions,
(s: CatalogStore) => s.sessions,
);
return useMemo(
() => (props.store ? [...fromStore.values()] : (props.sessions ?? [])),
@@ -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]);
}
@@ -0,0 +1,84 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ApprovalView } from "./ApprovalView.js";
afterEach(cleanup);
const ALL_OPTIONS = [
{ optionId: "a1", name: "Allow once", kind: "allow_once" as const },
{ optionId: "a2", name: "Allow always", kind: "allow_always" as const },
{ optionId: "r1", name: "Reject", kind: "reject_once" as const },
];
function buttons(): string[] {
return [...document.querySelectorAll(".st-approval-acts button")].map((b) => b.textContent ?? "");
}
describe("ApprovalView", () => {
it("asks the operator with the three decisions the daemon exposes", () => {
render(
<ApprovalView
request={{ toolCall: { title: "Write(a.ts)", locations: [{ path: "/repo/a.ts" }] }, options: ALL_OPTIONS }}
pending
onDecide={() => Promise.resolve()}
/>,
);
expect(buttons()).toEqual(["Allow once", "Allow for session", "Deny"]);
expect(screen.getByText("Write(a.ts)")).toBeTruthy();
expect(screen.getByText("/repo/a.ts")).toBeTruthy();
});
it("submits the decision the operator picked", async () => {
const onDecide = vi.fn().mockResolvedValue(undefined);
render(<ApprovalView request={{ options: ALL_OPTIONS }} pending onDecide={onDecide} />);
fireEvent.click(screen.getByText("Deny"));
await waitFor(() => expect(onDecide).toHaveBeenCalledWith("deny"));
});
it("disables every button once one is pressed, so a decision is sent once", () => {
render(<ApprovalView request={{ options: ALL_OPTIONS }} pending onDecide={() => new Promise(() => {})} />);
fireEvent.click(screen.getByText("Allow once"));
const all = [...document.querySelectorAll<HTMLButtonElement>(".st-approval-acts button")];
expect(all.every((b) => b.disabled)).toBe(true);
});
it("restores the controls when the decision failed and this approval is still pending", async () => {
const onDecide = vi.fn().mockRejectedValue(new Error("nope"));
render(<ApprovalView request={{ options: ALL_OPTIONS }} pending onDecide={onDecide} />);
fireEvent.click(screen.getByText("Allow once"));
await waitFor(() => {
const all = [...document.querySelectorAll<HTMLButtonElement>(".st-approval-acts button")];
expect(all.every((b) => !b.disabled)).toBe(true);
});
});
it("offers no button for a decision the request cannot back", () => {
render(
<ApprovalView
request={{ options: [{ optionId: "a1", name: "Allow once", kind: "allow_once" }] }}
pending
onDecide={() => Promise.resolve()}
/>,
);
expect(buttons()).toEqual(["Allow once"]);
});
it("says so when the request offers no decision at all", () => {
render(<ApprovalView request={{ options: [] }} pending onDecide={() => Promise.resolve()} />);
expect(screen.getByText("no decision offered")).toBeTruthy();
});
it("renders a resolved approval as a one-liner, with no controls", () => {
render(
<ApprovalView
request={{ toolCall: { title: "Write(a.ts)" }, options: ALL_OPTIONS }}
pending={false}
onDecide={() => Promise.resolve()}
/>,
);
expect(screen.getByText("Write(a.ts) — approved")).toBeTruthy();
expect(document.querySelector(".st-approval-acts")).toBeNull();
});
});
@@ -0,0 +1,80 @@
// An `Approval` row. The daemon exposes exactly three decisions
// (docs/STRUCTURED-VIEW.md) and mints the nonce; this only solicits one of
// them. A decision the request offers no matching ACP option for is not shown
// — its button would "allow" something that silently resolves `Cancelled`.
import { useState } from "react";
import type { AcpPermissionOption, AcpToolCallLike, StructuredApprovalDecision } from "../../types.js";
import { offeredDecisions, toolKindLabel } from "./model.js";
const SPECS: [StructuredApprovalDecision, string, string][] = [
["allow_once", "Allow once", "primary"],
["allow_for_session", "Allow for session", ""],
["deny", "Deny", "deny"],
];
export interface ApprovalViewProps {
/** The `RequestPermissionRequest` the row carries — serialized without its
* nonce, which never leaves the daemon's live wire. */
request: { toolCall?: AcpToolCallLike; options?: AcpPermissionOption[] };
/** Whether this is the one live approval. A restored/answered row is not. */
pending: boolean;
/** Resolves once the decision reached the daemon; rejects only while this
* approval is still the pending one, so the buttons can come back. */
onDecide: (decision: StructuredApprovalDecision) => Promise<void>;
}
export function ApprovalView({ request, pending, onDecide }: ApprovalViewProps) {
const [busy, setBusy] = useState(false);
const toolCall = request.toolCall ?? {};
const what = toolCall.title ?? toolKindLabel(toolCall.kind);
// Resolved approvals render as a compact one-liner inside a tool group (or
// are dropped entirely when standalone — see model.ts's transcriptItems).
if (!pending) {
return <div className="st-row st-row-approval st-approval-compact">{`${what} — approved`}</div>;
}
const offered = offeredDecisions(request.options ?? []);
const path = toolCall.locations?.[0]?.path;
const decide = (decision: StructuredApprovalDecision) => {
setBusy(true);
onDecide(decision).catch(() => {
// Rejects only while this approval is still pending — restore the
// controls so the operator can retry. The error itself is surfaced by
// the pane's banner.
setBusy(false);
});
};
return (
<div className="st-row st-row-approval">
<div className="st-approval">
<div className="st-approval-title">
<span aria-hidden="true">⚠</span> permission requested
</div>
<div className="st-approval-what">{what}</div>
{path && <div className="st-approval-path">{path}</div>}
{offered.length > 0 ? (
<div className="st-approval-acts">
{SPECS.filter(([decision]) => offered.includes(decision)).map(([decision, label, cls]) => (
<button
key={decision}
type="button"
className={cls ? `act ${cls}` : "act"}
disabled={busy}
onClick={() => decide(decision)}
>
{label}
</button>
))}
</div>
) : (
<div className="st-approval-resolved">no decision offered</div>
)}
</div>
</div>
);
}
+391
View File
@@ -0,0 +1,391 @@
// The composer: draft, attachments, `/` command autocomplete off the live
// `available_commands_update` notices, the session-settings menu, and the
// send/cancel pair. Sending is the pane's job — this only hands it a draft and
// the files that go with it.
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type ChangeEvent,
type ClipboardEvent,
type DragEvent,
type FormEvent,
type KeyboardEvent,
} from "react";
import type { AcpSessionConfigChoice, AcpSessionConfigOption } from "../../types.js";
import {
applyCommand,
commandTokenAt,
filterCommands,
orderConfigOptions,
type AvailableCommand,
} from "./model.js";
/** Per-message ceilings the daemon enforces on uploads
* (docs/STRUCTURED-VIEW.md, "Prompt and cancel"). */
export const MAX_ATTACHMENTS = 10;
export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
/** Pasted text at or above either bound becomes a `.txt` attachment instead of
* going inline. */
const PASTE_TO_FILE_CHARS = 4_000;
const PASTE_TO_FILE_LINES = 40;
export interface ComposerProps {
/** Files chosen but not yet sent. Owned by the pane so a failed send can put
* them back. */
attachments: File[];
onAttachmentsChange(files: File[]): void;
commands: AvailableCommand[];
configOptions: AcpSessionConfigOption[];
/** A turn is in flight — the cancel control replaces nothing, it appears
* beside send (a queued message is still allowed). */
turnActive: boolean;
/** An upload/POST is in flight: the attachment controls are frozen. */
uploading: boolean;
configBusy: boolean;
/** Restored into the box when a send fails. */
draft: string;
onDraftChange(draft: string): void;
onSubmit(text: string, attachments: File[]): void;
onCancel(): void;
onConfigChange(option: AcpSessionConfigOption, value: string | boolean): void;
onError(message: string): void;
menuId: string;
}
function configChoices(option: AcpSessionConfigOption): AcpSessionConfigChoice[] {
return (option.options ?? []).flatMap((choice) => ("options" in choice ? choice.options : [choice]));
}
function currentConfigLabel(option: AcpSessionConfigOption): string {
if (typeof option.currentValue === "boolean") return option.currentValue ? "On" : "Off";
return configChoices(option).find((choice) => choice.value === option.currentValue)?.name
?? String(option.currentValue);
}
export function Composer({
attachments,
onAttachmentsChange,
commands,
configOptions,
turnActive,
uploading,
configBusy,
draft,
onDraftChange,
onSubmit,
onCancel,
onConfigChange,
onError,
menuId,
}: ComposerProps) {
const inputRef = useRef<HTMLTextAreaElement | null>(null);
const fileRef = useRef<HTMLInputElement | null>(null);
const configRef = useRef<HTMLDivElement | null>(null);
const [caret, setCaret] = useState(0);
const [activeCommand, setActiveCommand] = useState(0);
const [configOpen, setConfigOpen] = useState(false);
/** Escape closes the menu until the draft or caret moves again. */
const [menuDismissed, setMenuDismissed] = useState(false);
const pasteCount = useRef(0);
// Grow with the draft, up to the CSS max-height; past that it scrolls.
useLayoutEffect(() => {
const input = inputRef.current;
if (!input) return;
input.style.height = "auto";
input.style.overflowY = "hidden";
input.style.height = `${Math.min(input.scrollHeight, 160)}px`;
if (input.scrollHeight > 160) input.style.overflowY = "auto";
}, [draft]);
useEffect(() => {
if (!configOpen) return;
const onPointerDown = (event: PointerEvent) => {
if (!configRef.current?.contains(event.target as Node)) setConfigOpen(false);
};
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [configOpen]);
const token = commandTokenAt(draft, caret, caret);
const matches = token && commands.length > 0 ? filterCommands(commands, token.query) : [];
const menuOpen = matches.length > 0 && !menuDismissed;
const activeIndex = menuOpen ? Math.min(Math.max(activeCommand, 0), matches.length - 1) : -1;
const addFiles = useCallback(
(files: File[]) => {
if (uploading) return;
const next = [...attachments];
for (const file of files) {
if (file.size > MAX_ATTACHMENT_BYTES) {
onError(`${file.name} exceeds 20 MiB. Choose a smaller file.`);
} else if (next.length >= MAX_ATTACHMENTS) {
onError(`Attach up to ${MAX_ATTACHMENTS} files per message.`);
} else {
next.push(file);
}
}
onAttachmentsChange(next);
},
[attachments, onAttachmentsChange, onError, uploading],
);
const syncCaret = () => {
setMenuDismissed(false);
setCaret(inputRef.current?.selectionStart ?? 0);
};
const chooseCommand = (command: AvailableCommand) => {
const input = inputRef.current;
if (!input) return;
const current = commandTokenAt(draft, input.selectionStart, input.selectionEnd);
if (!current) return;
const applied = applyCommand(draft, current, command.name);
onDraftChange(applied.value);
setActiveCommand(0);
// The caret has to move after React has written the new value.
requestAnimationFrame(() => {
input.focus();
input.setSelectionRange(applied.cursor, applied.cursor);
setCaret(applied.cursor);
});
};
const submit = () => {
if ((!draft.trim() && attachments.length === 0) || uploading) return;
onSubmit(draft, attachments);
};
const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (menuOpen) {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
const direction = event.key === "ArrowDown" ? 1 : -1;
setActiveCommand((index) => (index + direction + matches.length) % matches.length);
return;
}
if ((event.key === "Enter" && !event.shiftKey) || event.key === "Tab") {
event.preventDefault();
chooseCommand(matches[activeIndex]);
return;
}
if (event.key === "Escape") {
event.preventDefault();
setMenuDismissed(true);
return;
}
}
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
submit();
}
};
const onPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
const clipboard = event.clipboardData;
if (!clipboard || uploading) return;
if (clipboard.files.length) {
event.preventDefault();
addFiles(Array.from(clipboard.files));
return;
}
const text = clipboard.getData("text/plain");
if (text.length < PASTE_TO_FILE_CHARS && text.split("\n").length < PASTE_TO_FILE_LINES) return;
// No slot for it, or too big to upload: leave it inline rather than
// dropping it on the floor.
if (attachments.length >= MAX_ATTACHMENTS || new Blob([text]).size > MAX_ATTACHMENT_BYTES) return;
event.preventDefault();
addFiles([new File([text], `pasted-${++pasteCount.current}.txt`, { type: "text/plain" })]);
};
const ordered = orderConfigOptions(configOptions);
const summary = ordered.map((option) => `${option.name}: ${currentConfigLabel(option)}`).join("\n");
return (
<form
className="structured-composer"
onSubmit={(event: FormEvent) => {
event.preventDefault();
submit();
}}
onDragOver={(event: DragEvent) => {
if (event.dataTransfer?.types.includes("Files")) event.preventDefault();
}}
onDrop={(event: DragEvent) => {
if (!event.dataTransfer?.files.length) return;
event.preventDefault();
addFiles(Array.from(event.dataTransfer.files));
}}
>
<button
type="button"
className="structured-attach"
aria-label="Attach files"
title="Attach files (up to 20 MiB each)"
disabled={uploading}
onClick={() => fileRef.current?.click()}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<path d="m8 12 7-7a3 3 0 0 1 4 4L9 19a5 5 0 0 1-7-7L12 2M5 14l9-9" />
</svg>
</button>
<input
ref={fileRef}
className="structured-file-input"
type="file"
multiple
hidden
disabled={uploading}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
addFiles(Array.from(event.target.files ?? []));
event.target.value = "";
}}
/>
<div className="structured-input-wrap">
<div className="structured-attachments" aria-label="Attachments" aria-live="polite" hidden={attachments.length === 0}>
{attachments.map((file, index) => (
<span key={`${file.name}-${index}`} className="structured-attachment">
<span title={`${file.name} (${file.size.toLocaleString()} bytes)`}>{file.name}</span>
<button
type="button"
aria-label={`Remove ${file.name}`}
disabled={uploading}
onClick={() => onAttachmentsChange(attachments.filter((_, i) => i !== index))}
>
×
</button>
</span>
))}
</div>
<div id={menuId} className="structured-command-menu" role="listbox" hidden={!menuOpen}>
{matches.map((command, index) => (
<div
key={command.name}
id={`${menuId}-option-${index}`}
className="structured-command-option"
role="option"
aria-selected={index === activeIndex}
onPointerDown={(event) => event.preventDefault()}
onClick={() => chooseCommand(command)}
>
<div className="structured-command-title">
<span className="structured-command-name">{`/${command.name}`}</span>
<span className="structured-command-hint">{command.input?.hint ?? ""}</span>
</div>
<div className="structured-command-description">
{command.description.replace(/\s+/g, " ").trim()}
</div>
</div>
))}
</div>
<textarea
ref={inputRef}
className="structured-input"
rows={1}
placeholder="Reply, or / for commands…"
autoComplete="off"
aria-autocomplete="list"
aria-expanded={menuOpen}
aria-controls={menuId}
aria-activedescendant={activeIndex >= 0 ? `${menuId}-option-${activeIndex}` : undefined}
value={draft}
onChange={(event) => {
onDraftChange(event.target.value);
setActiveCommand(0);
setMenuDismissed(false);
setCaret(event.target.selectionStart ?? event.target.value.length);
}}
onClick={syncCaret}
onSelect={syncCaret}
onKeyUp={(event) => {
if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) syncCaret();
}}
onKeyDown={onKeyDown}
onPaste={onPaste}
/>
</div>
<div className="structured-config" ref={configRef} hidden={ordered.length === 0}>
<button
type="button"
className="structured-config-trigger"
aria-haspopup="true"
aria-expanded={configOpen}
aria-label={`Session settings. ${summary.replaceAll("\n", ". ")}`}
title={summary}
disabled={configBusy}
onClick={() => setConfigOpen((open) => !open)}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 7h10M18 7h2M4 17h2M10 17h10M14 4v6M6 14v6" />
</svg>
</button>
<div
className="structured-config-menu"
role="group"
aria-label="Session settings"
hidden={!configOpen}
onKeyDown={(event) => {
if (event.key !== "Escape") return;
setConfigOpen(false);
configRef.current?.querySelector<HTMLButtonElement>(".structured-config-trigger")?.focus();
}}
>
{ordered.map((option) => (
<label key={option.id} className="structured-config-row" title={option.description ?? undefined}>
<span className="structured-config-name">{option.name}</span>
{option.type === "select" ? (
<select
className="structured-config-select"
disabled={configBusy}
value={String(option.currentValue)}
onChange={(event) => onConfigChange(option, event.target.value)}
>
{configChoices(option).map((choice) => (
<option key={choice.value} value={choice.value} title={choice.description ?? ""}>
{choice.name}
</option>
))}
</select>
) : option.type === "boolean" ? (
<input
type="checkbox"
className="structured-config-toggle"
disabled={configBusy}
checked={option.currentValue === true}
onChange={(event) => onConfigChange(option, event.target.checked)}
/>
) : null}
</label>
))}
</div>
</div>
<button type="submit" className="structured-send">
<span>Send</span>
<svg viewBox="0 0 20 20" aria-hidden="true">
<path d="m7 5 5 5-5 5M12 10H3" />
</svg>
</button>
{turnActive && (
<button
type="button"
className="structured-cancel"
aria-label="Cancel turn"
title="Cancel turn"
onClick={onCancel}
>
<svg className="structured-cancel-spinner" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" strokeWidth="2" strokeDasharray="50 14" strokeLinecap="round" />
</svg>
<svg className="structured-cancel-stop" viewBox="0 0 24 24" aria-hidden="true">
<rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor" />
</svg>
</button>
)}
</form>
);
}
+101
View File
@@ -0,0 +1,101 @@
// docs/STRUCTURED-VIEW.md: the web client's user, agent, thought, tool-detail
// and notice text blocks copy their complete source text when clicked or
// keyboard-activated. The visible node is whatever it wraps; this only adds
// the affordance and the "copied"/"error" state the CSS reads.
import { useCallback, useEffect, useRef, useState, type ElementType, type ReactNode } from "react";
async function writeClipboard(text: string): Promise<void> {
try {
await navigator.clipboard.writeText(text);
return;
} catch {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
const copied = document.execCommand?.("copy") ?? false;
textarea.remove();
if (!copied) throw new Error("clipboard unavailable");
}
}
export interface CopyableProps {
/** The complete source text — not whatever the DOM happens to show. */
text: string;
/** Names the thing in the "Copy …" label ("message", "diff", "path", …). */
label: string;
as?: ElementType;
className?: string;
title?: string;
children?: ReactNode;
/** Extra attributes the row builders set (`data-row-id`, `lang`, …). */
[key: string]: unknown;
}
/** Wrapping a node in a copy affordance. Empty `text` renders the node plain —
* a control that copies nothing is worse than no control. */
export function Copyable({
text,
label,
as: Tag = "div",
className,
children,
...rest
}: CopyableProps) {
const [copyState, setCopyState] = useState<"copied" | "error" | null>(null);
const timer = useRef<number | null>(null);
useEffect(
() => () => {
if (timer.current !== null) window.clearTimeout(timer.current);
},
[],
);
const copy = useCallback(() => {
void writeClipboard(text).then(
() => {
setCopyState("copied");
if (timer.current !== null) window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setCopyState(null), 1200);
},
() => setCopyState("error"),
);
}, [text]);
if (!text) {
return (
<Tag className={className} {...rest}>
{children}
</Tag>
);
}
const ariaLabel = copyState === "copied" ? `${label} copied` : `Copy ${label}`;
return (
<Tag
className={className ? `${className} st-copyable` : "st-copyable"}
tabIndex={0}
role="button"
aria-label={ariaLabel}
title={`Copy ${label}`}
data-copy-state={copyState ?? undefined}
onClick={(event: React.MouseEvent) => {
// A link inside the block is a link, not a copy target.
if ((event.target as Element | null)?.closest("a")) return;
copy();
}}
onKeyDown={(event: React.KeyboardEvent) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
copy();
}}
{...rest}
>
{children}
</Tag>
);
}
@@ -0,0 +1,50 @@
// @vitest-environment happy-dom
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { DiffView } from "./DiffView.js";
import { toolDiffMeta } from "./model.js";
afterEach(cleanup);
function lines(kind: string): string[] {
return [...document.querySelectorAll(`.st-ln-${kind}`)].map((el) => el.textContent ?? "");
}
describe("DiffView", () => {
it("marks insertions and deletions with their column prefixes", () => {
render(<DiffView diff={{ path: "src/a.ts", oldText: "one\ntwo\n", newText: "one\nTWO\nthree\n" }} />);
expect(lines("del")).toEqual(["- two"]);
expect(lines("add")).toEqual(["+ TWO", "+ three"]);
expect(lines("ctx")).toEqual([" one"]);
expect(screen.getByText("src/a.ts")).toBeTruthy();
});
it("carries the +added −removed counts the tool head shows", () => {
render(<DiffView diff={{ path: "a.ts", oldText: "one\ntwo\n", newText: "one\nTWO\nthree\n" }} />);
const body = document.querySelector<HTMLElement>(".st-diff")!;
expect(body.dataset.added).toBe("2");
expect(body.dataset.removed).toBe("1");
expect(toolDiffMeta([{ type: "diff", path: "a.ts", oldText: "one\ntwo\n", newText: "one\nTWO\nthree\n" }]))
.toBe("+2 −1");
});
it("renders every line of a new file as added", () => {
render(<DiffView diff={{ path: "new.ts", newText: "alpha\nbeta\n" }} />);
expect(lines("add")).toEqual(["+ alpha", "+ beta"]);
expect(lines("del")).toEqual([]);
});
it("collapses a long unchanged run to a hunk summary", () => {
const filler = Array.from({ length: 20 }, (_, i) => `line ${i}`).join("\n");
render(<DiffView diff={{ path: "a.ts", oldText: `head\n${filler}\n`, newText: `HEAD\n${filler}\n` }} />);
expect(lines("hunk")).toEqual(["⋯ 17 unchanged lines ⋯"]);
});
it("copies the whole diff, prefixes included", () => {
render(<DiffView diff={{ path: "a.ts", oldText: "one\n", newText: "two\n" }} />);
const body = document.querySelector<HTMLElement>(".st-diff")!;
expect(body.getAttribute("aria-label")).toBe("Copy diff");
expect(body.textContent).toBe("- one+ two");
});
});
@@ -0,0 +1,56 @@
// A `Diff` content block, rendered as coloured hunks. ACP hands over whole
// `oldText`/`newText`, so the lines and their `+added −removed` counts are
// computed here (structuredDiff.ts) rather than parsed out of a patch.
import { useMemo } from "react";
import { computeDiffLines, diffStats, type DiffLine } from "../../structuredDiff.js";
import type { AcpDiff } from "../../types.js";
import { Copyable } from "./Copyable.js";
/** Column prefix by line kind. A `hunk` summary and an `eof` marker are their
* own complete text, so neither takes one. */
const PREFIX: Record<DiffLine["kind"], string> = {
add: "+ ",
del: "- ",
ctx: " ",
hunk: "",
eof: "",
};
export function diffLineText(line: DiffLine): string {
return PREFIX[line.kind] + line.text;
}
export interface DiffViewProps {
diff: AcpDiff;
}
export function DiffView({ diff }: DiffViewProps) {
const lines = useMemo(() => computeDiffLines(diff.oldText, diff.newText), [diff.oldText, diff.newText]);
const stats = useMemo(() => diffStats(lines), [lines]);
const plain = useMemo(() => lines.map(diffLineText).join("\n"), [lines]);
return (
<div className="st-diff-block">
<Copyable className="st-diff-path" text={diff.path} label="path" title={diff.path}>
{diff.path}
</Copyable>
<Copyable
className="st-diff"
text={plain}
label="diff"
data-added={stats.added}
data-removed={stats.removed}
>
<pre>
{lines.map((line, index) => (
<span key={index} className={`st-ln st-ln-${line.kind}`}>
{diffLineText(line)}
</span>
))}
</pre>
</Copyable>
</div>
);
}
@@ -0,0 +1,403 @@
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../api.js", async () => {
const actual = await vi.importActual<typeof import("../../api.js")>("../../api.js");
return {
...actual,
sendStructuredPrompt: vi.fn().mockResolvedValue(undefined),
uploadStructuredFile: vi.fn().mockResolvedValue({ path: "/uploads/pasted.txt" }),
cancelStructuredTurn: vi.fn().mockResolvedValue(undefined),
resolveStructuredApproval: vi.fn().mockResolvedValue(undefined),
setStructuredConfig: vi.fn().mockResolvedValue(undefined),
};
});
import {
cancelStructuredTurn,
resolveStructuredApproval,
sendStructuredPrompt,
uploadStructuredFile,
} from "../../api.js";
import { INITIAL_RECONNECT_MS } from "../../reconnect.js";
import type { ReducedState, StructuredEvent, TranscriptRow } from "../../types.js";
import { StructuredPane } from "./StructuredPane.js";
/** Just enough of a WebSocket for the pane to drive (mirrors structured.test.ts's
* own fake, which the pre-React connection used). */
class FakeSocket {
onopen: (() => void) | null = null;
onmessage: ((ev: { data: string }) => void) | null = null;
onerror: (() => void) | null = null;
onclose: ((ev: { code: number; reason: string }) => void) | null = null;
closed = false;
open(): void {
act(() => this.onopen?.());
}
emit(event: StructuredEvent): void {
act(() => this.onmessage?.({ data: JSON.stringify(event) }));
}
drop(code = 1006, reason = ""): void {
act(() => this.onclose?.({ code, reason }));
}
close(): void {
this.closed = true;
}
}
const idle: ReducedState = { turn: { responding: false, thinking: false, active_tool_calls: [] } };
const busy: ReducedState = { turn: { responding: true, thinking: false, active_tool_calls: [] } };
function row(id: string, kind: TranscriptRow["kind"], data: unknown = {}): TranscriptRow {
return { id, kind, data };
}
function chunk(id: string, kind: "user" | "agent_text" | "agent_thought", text: string): TranscriptRow {
return row(id, kind, { content: { type: "text", text } });
}
const APPROVAL_REQUEST = {
toolCall: { title: "Write(secrets.txt)", kind: "edit", locations: [{ path: "/repo/secrets.txt" }] },
options: [
{ optionId: "a1", name: "Allow once", kind: "allow_once" },
{ optionId: "a2", name: "Allow always", kind: "allow_always" },
{ optionId: "r1", name: "Reject", kind: "reject_once" },
],
};
function mount(): { socket: FakeSocket; sockets: FakeSocket[] } {
const sockets: FakeSocket[] = [];
render(
<StructuredPane
sessionId="s1"
socketFactory={() => {
const socket = new FakeSocket();
sockets.push(socket);
return socket as unknown as WebSocket;
}}
/>,
);
sockets[0].open();
return { socket: sockets[0], sockets };
}
function snapshot(socket: FakeSocket, rows: TranscriptRow[], state: ReducedState = idle): void {
socket.emit({ type: "transcript_snapshot", snapshot: { cursor: rows.length, rows } });
socket.emit({ type: "reduced_state", state });
}
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
describe("the eight row kinds", () => {
it("renders user, agent, thought, tool, diff, approval and notice rows; never a plan", () => {
const { socket } = mount();
snapshot(
socket,
[
chunk("user:1", "user", "please edit it"),
chunk("agent:1", "agent_text", "**bold** answer"),
chunk("thought:1", "agent_thought", "considering"),
row("tool:1", "tool_call", { title: "Read(src/main.ts)", kind: "read", status: "completed" }),
row("tool:2", "tool_diff", {
title: "Edit(a.ts)",
kind: "edit",
status: "completed",
content: [{ type: "diff", path: "a.ts", oldText: "one\n", newText: "one\ntwo\n" }],
}),
row("approval:1", "approval", APPROVAL_REQUEST),
row("plan", "plan", { entries: [{ content: "step", priority: "high", status: "pending" }] }),
row("notice:1", "notice", { sessionUpdate: "mystery_update" }),
],
{ ...idle, pending_approval: { nonce: "nonce-1", request: APPROVAL_REQUEST as never } },
);
expect(screen.getByText("please edit it")).toBeTruthy();
expect(document.querySelector(".st-row-agent strong")?.textContent).toBe("bold");
expect(document.querySelector(".st-row-thought")?.textContent).toContain("considering");
expect(screen.getByText("src/main.ts")).toBeTruthy();
// The diff row's head carries the line counts, not a fabricated duration.
expect(document.querySelector('[data-row-id="tool:2"] .st-tool-meta')?.textContent).toBe("+1 −0");
expect(screen.getByText("permission requested")).toBeTruthy();
expect(screen.getByText("[notice]")).toBeTruthy();
expect(document.querySelector('[data-row-id="plan"]')).toBeNull();
});
it("omits the state-only notices it consumes for `/` autocomplete", () => {
const { socket } = mount();
snapshot(socket, [
row("notice:1", "notice", {
sessionUpdate: "available_commands_update",
availableCommands: [{ name: "compact", description: "Compact the conversation" }],
}),
row("notice:2", "notice", { sessionUpdate: "session_info_update" }),
]);
expect(screen.queryByText("[notice]")).toBeNull();
// …and offers them in the composer instead.
const input = document.querySelector<HTMLTextAreaElement>(".structured-input")!;
fireEvent.change(input, { target: { value: "/comp" } });
expect(screen.getByText("/compact")).toBeTruthy();
});
it("renders a completed turn's footer inline, and retires it once the next turn starts", () => {
const { socket } = mount();
const footer = row("turn_meta:1", "notice", {
maestroTurnMeta: { elapsedMs: 65_000, outcome: "completed", model: "sonnet", contextDelta: 1200 },
});
snapshot(socket, [chunk("user:1", "user", "go"), footer]);
expect(screen.getByText("1m 5s · +1.2k context tokens · sonnet")).toBeTruthy();
socket.emit({
type: "projection_update",
transcript: { type: "append", row: chunk("user:2", "user", "again") },
state: idle,
});
expect(screen.queryByText(/1m 5s/)).toBeNull();
});
});
describe("tool rows and activity groups", () => {
it("collapses two or more tools in a turn into one group, opened on click", () => {
const { socket } = mount();
snapshot(socket, [
chunk("user:1", "user", "go"),
row("tool:1", "tool_call", { title: "Read(a.ts)", kind: "read", status: "completed" }),
chunk("agent:1", "agent_text", "still working"),
row("tool:2", "tool_call", { title: "Read(b.ts)", kind: "read", status: "completed" }),
chunk("agent:2", "agent_text", "the answer"),
]);
const head = screen.getByText("2 tool calls").closest("button")!;
expect(head.getAttribute("aria-expanded")).toBe("false");
// The final answer stays outside the group; the intermediate text is in it.
expect(document.querySelector(".st-tool-group")!.contains(screen.getByText("the answer"))).toBe(false);
expect(document.querySelector(".st-tool-group")!.contains(screen.getByText("still working"))).toBe(true);
fireEvent.click(head);
expect(head.getAttribute("aria-expanded")).toBe("true");
});
it("discloses a tool's output only once its head is opened", () => {
const { socket } = mount();
snapshot(socket, [
row("tool:1", "tool_call", {
title: "Execute(ls)",
kind: "execute",
status: "completed",
content: [{ type: "content", content: { type: "text", text: "a.ts\nb.ts" } }],
rawInput: { command: "ls" },
rawOutput: { exit_code: 0 },
}),
]);
expect(document.querySelector(".st-tool-text")).toBeNull();
fireEvent.click(document.querySelector<HTMLButtonElement>(".st-tool-head")!);
expect(document.querySelector(".st-tool-text")?.textContent).toBe("a.ts\nb.ts");
expect(screen.getByText("Exit code")).toBeTruthy();
expect(document.querySelector(".st-tool-exit-ok")?.textContent).toBe("0");
});
it("patches a tool row in place rather than appending a second one", () => {
const { socket } = mount();
snapshot(socket, [row("tool:1", "tool_call", { title: "Read(a.ts)", kind: "read", status: "in_progress" })]);
expect(document.querySelector(".st-tool-meta")?.textContent).toBe("running");
socket.emit({
type: "projection_update",
transcript: {
type: "patch",
row: row("tool:1", "tool_call", { title: "Read(a.ts)", kind: "read", status: "completed" }),
},
state: idle,
});
expect(document.querySelectorAll('[data-row-id="tool:1"]')).toHaveLength(1);
expect(document.querySelector(".st-tool-meta")).toBeNull();
});
});
describe("approvals", () => {
it("answers with the live nonce, offering only the decisions the request backs", async () => {
const { socket } = mount();
snapshot(socket, [row("approval:1", "approval", APPROVAL_REQUEST)], {
...idle,
pending_approval: { nonce: "nonce-1", request: APPROVAL_REQUEST as never },
});
expect(screen.getByText("/repo/secrets.txt")).toBeTruthy();
fireEvent.click(screen.getByText("Allow for session"));
await waitFor(() =>
expect(resolveStructuredApproval).toHaveBeenCalledWith("s1", "nonce-1", "allow_for_session"),
);
});
it("hides a decision the request offers no ACP option for", () => {
const { socket } = mount();
const request = { toolCall: { title: "Read(a.ts)" }, options: [{ optionId: "r", name: "No", kind: "reject_once" }] };
snapshot(socket, [row("approval:1", "approval", request)], {
...idle,
pending_approval: { nonce: "nonce-2", request: request as never },
});
expect(screen.getByText("Deny")).toBeTruthy();
expect(screen.queryByText("Allow once")).toBeNull();
});
it("treats only the newest approval as pending, and hides resolved standalone ones", () => {
const { socket } = mount();
snapshot(
socket,
[row("approval:1", "approval", APPROVAL_REQUEST), row("approval:2", "approval", APPROVAL_REQUEST)],
{ ...idle, pending_approval: { nonce: "nonce-3", request: APPROVAL_REQUEST as never } },
);
expect(screen.getAllByText("permission requested")).toHaveLength(1);
expect(screen.queryByText(/approved/)).toBeNull();
});
it("drops the controls once the approval is no longer pending", () => {
const { socket } = mount();
snapshot(socket, [row("approval:1", "approval", APPROVAL_REQUEST)], {
...idle,
pending_approval: { nonce: "nonce-4", request: APPROVAL_REQUEST as never },
});
expect(screen.getByText("Allow once")).toBeTruthy();
socket.emit({ type: "reduced_state", state: idle });
expect(screen.queryByText("Allow once")).toBeNull();
});
});
describe("prompt and cancel", () => {
it("sends a draft, clearing the composer", async () => {
const { socket } = mount();
snapshot(socket, []);
const input = document.querySelector<HTMLTextAreaElement>(".structured-input")!;
fireEvent.change(input, { target: { value: "do the thing" } });
fireEvent.submit(document.querySelector("form.structured-composer")!);
await waitFor(() => expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "do the thing"));
expect(input.value).toBe("");
});
it("uploads attachments and names them in the prompt", async () => {
const { socket } = mount();
snapshot(socket, []);
const file = new File(["hello"], "notes.txt", { type: "text/plain" });
fireEvent.change(document.querySelector<HTMLInputElement>(".structured-file-input")!, {
target: { files: [file] },
});
expect(screen.getByText("notes.txt")).toBeTruthy();
fireEvent.change(document.querySelector<HTMLTextAreaElement>(".structured-input")!, {
target: { value: "read this" },
});
fireEvent.submit(document.querySelector("form.structured-composer")!);
await waitFor(() => expect(uploadStructuredFile).toHaveBeenCalled());
await waitFor(() =>
expect(sendStructuredPrompt).toHaveBeenCalledWith(
"s1",
'read this\n\nAttached file "notes.txt": "/uploads/pasted.txt"',
),
);
});
it("queues a second message while a turn is in flight, and sends it when the turn ends", async () => {
const { socket } = mount();
snapshot(socket, [], busy);
fireEvent.change(document.querySelector<HTMLTextAreaElement>(".structured-input")!, {
target: { value: "next up" },
});
fireEvent.submit(document.querySelector("form.structured-composer")!);
expect(sendStructuredPrompt).not.toHaveBeenCalled();
expect(document.querySelector(".st-row-queued")?.textContent).toBe("next up");
socket.emit({ type: "reduced_state", state: idle });
await waitFor(() => expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "next up"));
});
it("hands a failed send's draft back to the operator", async () => {
vi.mocked(sendStructuredPrompt).mockRejectedValueOnce(new Error("boom"));
const { socket } = mount();
snapshot(socket, []);
const input = document.querySelector<HTMLTextAreaElement>(".structured-input")!;
fireEvent.change(input, { target: { value: "keep me" } });
fireEvent.submit(document.querySelector("form.structured-composer")!);
await waitFor(() => expect(document.querySelector(".structured-error")?.textContent).toContain("boom"));
expect(input.value).toBe("keep me");
});
it("shows the cancel control only while a turn is live", () => {
const { socket } = mount();
snapshot(socket, []);
expect(document.querySelector(".structured-cancel")).toBeNull();
socket.emit({ type: "reduced_state", state: busy });
fireEvent.click(document.querySelector<HTMLButtonElement>(".structured-cancel")!);
expect(cancelStructuredTurn).toHaveBeenCalledWith("s1");
});
it("reports live turn activity above the composer", () => {
const { socket } = mount();
snapshot(socket, [], {
turn: {
responding: true,
thinking: false,
active_tool_calls: ["t1", "t2"],
started_at: new Date(Date.now() - 5_000).toISOString(),
},
usage: { used: 12_000, size: 200_000 },
});
const activity = document.querySelector<HTMLElement>(".structured-activity")!;
expect(activity.hidden).toBe(false);
expect(activity.textContent).toContain("Running 2 tools");
expect(activity.textContent).toContain("12k context / 200k context");
});
});
describe("the socket", () => {
it("replaces the transcript wholesale on every snapshot, never merging", async () => {
const { socket, sockets } = mount();
snapshot(socket, [chunk("user:1", "user", "first")]);
socket.drop(1006);
// The retry is on reconnect.ts's backoff schedule, not immediate.
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, INITIAL_RECONNECT_MS + 20));
});
const replacement = sockets[1];
expect(replacement).toBeDefined();
replacement.open();
snapshot(replacement, [chunk("user:2", "user", "second")]);
expect(screen.queryByText("first")).toBeNull();
expect(screen.getByText("second")).toBeTruthy();
});
it("reports a daemon rejection instead of retrying it", () => {
const status = vi.fn();
const sockets: FakeSocket[] = [];
render(
<StructuredPane
sessionId="s1"
onStatusChange={status}
socketFactory={() => {
const socket = new FakeSocket();
sockets.push(socket);
return socket as unknown as WebSocket;
}}
/>,
);
sockets[0].open();
sockets[0].drop(4004, "not a structured session");
expect(sockets).toHaveLength(1);
expect(status).toHaveBeenCalledWith("error", "not a structured session");
});
});
@@ -0,0 +1,400 @@
// React port of structured.ts (#246): a live structured session — the
// transcript from `WS /api/structured/:id`, the composer that drives
// `.../prompt` and `.../cancel`, and the approval controls that answer
// `.../approvals/:nonce`. docs/STRUCTURED-VIEW.md is the contract.
//
// Like TerminalPane, the pane draws the body only: pane chrome (title,
// disconnect, sessions drawer) belongs to whatever composes it, which reads
// the connection state through `onStatusChange`.
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import {
cancelStructuredTurn,
resolveStructuredApproval,
sendStructuredPrompt,
setStructuredConfig,
uploadStructuredFile,
} from "../../api.js";
import { formatContextTokens, formatElapsed } from "../../structuredRender.js";
import type {
AcpSessionConfigOption,
StructuredApprovalDecision,
TranscriptRow as Row,
Uuid,
} from "../../types.js";
import { Composer } from "./Composer.js";
import {
commandsFromTranscript,
pendingApprovalRowId as pendingApprovalRowIdOf,
transcriptItems,
turnIsActive,
} from "./model.js";
import { ToolGroup } from "./ToolGroup.js";
import { TranscriptRow } from "./TranscriptRow.js";
import { useStructuredSession, type StructuredStatus, type StructuredSocketFactory } from "./useStructuredSession.js";
import "./structured.css";
/** Within this much of the bottom, wheel and touch imprecision should still
* count as following. */
const FOLLOW_BOTTOM_THRESHOLD_PX = 64;
interface QueuedPrompt {
text: string;
attachments: File[];
}
export interface StructuredPaneProps {
sessionId: Uuid;
/** Grabs the composer once mounted. A sidebar preview passes `false`. */
focus?: boolean;
shouldReconnect?: (code: number) => boolean;
onClosed?: () => void;
onStatusChange?: (status: StructuredStatus, detail?: string) => void;
/** Test seam: drive the socket without a daemon. */
socketFactory?: StructuredSocketFactory;
className?: string;
}
export interface StructuredPaneHandle {
/** Put the keyboard in the composer — the structured pane's terminal. */
focusComposer(): void;
}
export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPaneProps>(function StructuredPane(
{ sessionId, focus = true, shouldReconnect, onClosed, onStatusChange, socketFactory, className },
ref,
) {
const session = useStructuredSession(sessionId, { shouldReconnect, onClosed, socketFactory });
const { rows, state, status, detail, socketGeneration } = session;
const scrollRef = useRef<HTMLDivElement | null>(null);
const composerRef = useRef<HTMLDivElement | null>(null);
const followingBottom = useRef(true);
const [expandedRows, setExpandedRows] = useState<ReadonlySet<string>>(new Set());
const [expandedGroups, setExpandedGroups] = useState<ReadonlySet<string>>(new Set());
const [draft, setDraft] = useState("");
const [attachments, setAttachments] = useState<File[]>([]);
const [queue, setQueue] = useState<QueuedPrompt[]>([]);
const [uploading, setUploading] = useState(false);
const [configBusy, setConfigBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [now, setNow] = useState(() => Date.now());
/** Server path → object URL, so a user row can preview an image it uploaded. */
const imageUrls = useRef(new Map<string, string>());
// Submission bookkeeping, ported from structured.ts: a prompt is "sending"
// from the POST until the projection shows the turn it claimed, and an
// acceptance that straddles a reconnect forces one more reconnect so the
// snapshot cannot predate the 204.
const submission = useRef(0);
const accepted = useRef<{ generation: number; socketGeneration: number } | null>(null);
const optimisticStart = useRef<number | null>(null);
const promptPending = useRef(false);
const [awaitingBegin, setAwaitingBegin] = useState<number | null>(null);
useImperativeHandle(
ref,
() => ({
focusComposer: () => composerRef.current?.querySelector<HTMLTextAreaElement>(".structured-input")?.focus(),
}),
[],
);
useEffect(() => {
onStatusChange?.(status, detail ?? undefined);
}, [status, detail, onStatusChange]);
useEffect(() => {
if (!focus) return;
composerRef.current?.querySelector<HTMLTextAreaElement>(".structured-input")?.focus();
}, [focus]);
const urls = imageUrls.current;
useEffect(
() => () => {
for (const url of urls.values()) URL.revokeObjectURL(url);
urls.clear();
},
[urls],
);
const turnActive = turnIsActive(state);
const pendingRowId = useMemo(() => pendingApprovalRowIdOf(rows, state), [rows, state]);
const items = useMemo(
() => transcriptItems(rows, pendingRowId, turnActive),
[rows, pendingRowId, turnActive],
);
const commands = useMemo(() => commandsFromTranscript(rows), [rows]);
const configOptions: AcpSessionConfigOption[] = state.config_options?.configOptions ?? [];
const active = turnActive || awaitingBegin !== null;
// Stop saying "Sending" once the turn the submission was waiting for has
// begun — or, when it was accepted but a replacement socket has
// re-snapshotted since, once that newer snapshot has landed. Without the
// second case a prompt accepted across a reconnect would say "Sending"
// forever.
useEffect(() => {
if (turnActive) {
setAwaitingBegin(null);
} else if (
awaitingBegin !== null &&
accepted.current?.generation === awaitingBegin &&
socketGeneration > accepted.current.socketGeneration
) {
setAwaitingBegin(null);
}
}, [turnActive, awaitingBegin, socketGeneration]);
useEffect(() => {
if (!active) optimisticStart.current = null;
}, [active]);
// Keep the pane pinned to the newest row unless the operator scrolled away.
useLayoutEffect(() => {
const el = scrollRef.current;
if (!el || !followingBottom.current) return;
el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight);
}, [items, active, queue]);
useEffect(() => {
if (!active) return;
const timer = window.setInterval(() => setNow(Date.now()), 1_000);
return () => window.clearInterval(timer);
}, [active]);
const onScroll = () => {
const el = scrollRef.current;
if (!el) return;
followingBottom.current = el.scrollHeight - el.clientHeight - el.scrollTop <= FOLLOW_BOTTOM_THRESHOLD_PX;
};
const decide = useCallback(
async (rowId: string, decision: StructuredApprovalDecision): Promise<void> => {
const nonce = state.pending_approval?.nonce;
// Already resolved elsewhere between the click and here. A stale nonce
// is a no-op, not an error (docs/STRUCTURED-VIEW.md: any nonce answers
// 204), and the row's buttons are already gone — resolve quietly.
if (!nonce || rowId !== pendingRowId) return;
try {
await resolveStructuredApproval(sessionId, nonce, decision);
} catch (e: unknown) {
// Propagate — so the row re-enables its buttons — only while this is
// still the pending approval. If it moved on in flight, reviving the
// buttons would revive a decision that no longer applies.
setError(String(e));
throw e;
}
},
[sessionId, state.pending_approval?.nonce, pendingRowId],
);
const dispatchPrompt = useCallback(
(text: string, files: File[]) => {
const generation = ++submission.current;
const submissionSocket = socketGeneration;
setAwaitingBegin(generation);
optimisticStart.current = Date.now();
accepted.current = null;
promptPending.current = true;
setUploading(files.length > 0);
setDraft("");
setError(null);
const send = async () => {
const references: string[] = [];
for (const file of files) {
const { path } = await uploadStructuredFile(sessionId, file);
if (file.type.startsWith("image/")) imageUrls.current.set(path, URL.createObjectURL(file));
references.push(`Attached file ${JSON.stringify(file.name)}: ${JSON.stringify(path)}`);
}
await sendStructuredPrompt(sessionId, [text, ...references].filter(Boolean).join("\n\n"));
};
void send().then(
() => {
promptPending.current = false;
if (generation !== submission.current) return;
setUploading(false);
setAttachments((current) => (current === files ? [] : current));
accepted.current = { generation, socketGeneration };
// A replacement socket may have captured idle before the POST
// claimed the prompt. Reconnect so its snapshot cannot predate the
// 204.
if (socketGeneration > submissionSocket) session.reconnect();
},
(e: unknown) => {
promptPending.current = false;
if (generation !== submission.current) return;
setAwaitingBegin((current) => (current === generation ? null : current));
optimisticStart.current = null;
setUploading(false);
// The draft and its attachments are the operator's work — a failed
// send hands both back rather than eating them.
setAttachments((current) => (current.length === 0 ? files : current));
setDraft((current) => (current === "" ? text : current));
setError(String(e));
},
);
},
[sessionId, socketGeneration, session],
);
// A queued message goes out as soon as the projected turn is idle — the
// daemon answers a second prompt `409 turn_in_flight`, so the queue is
// client-local (docs/STRUCTURED-VIEW.md).
useEffect(() => {
if (active || promptPending.current || queue.length === 0) return;
const [next, ...rest] = queue;
setQueue(rest);
dispatchPrompt(next.text, next.attachments);
}, [active, queue, dispatchPrompt]);
const submit = (text: string, files: File[]) => {
if (active || queue.length > 0) {
setQueue((current) => [...current, { text, attachments: files }]);
setAttachments([]);
setDraft("");
setError(null);
return;
}
dispatchPrompt(text, files);
};
const cancel = () => {
setError(null);
cancelStructuredTurn(sessionId).catch((e: unknown) => setError(String(e)));
};
const changeConfig = (option: AcpSessionConfigOption, value: string | boolean) => {
setConfigBusy(true);
setError(null);
setStructuredConfig(sessionId, { config_id: option.id, value })
.catch((e: unknown) =>
setError(e instanceof Error ? e.message : "Could not change the session setting"),
)
.finally(() => setConfigBusy(false));
};
const elapsedMs = () => {
const authoritative = state.turn.started_at ? Date.parse(state.turn.started_at) : Number.NaN;
const startedAt = Number.isFinite(authoritative) ? authoritative : optimisticStart.current;
return startedAt === null ? 0 : Math.max(0, now - startedAt);
};
const activityLabel = () => {
const tools = state.turn.active_tool_calls.length;
const label = awaitingBegin !== null
? "Sending"
: state.turn.thinking
? "Thinking"
: tools > 0
? `Running ${tools} ${tools === 1 ? "tool" : "tools"}`
: "Responding";
const parts = [label, formatElapsed(elapsedMs())];
if (typeof state.usage?.used === "number") {
const usage = formatContextTokens(state.usage.used);
parts.push(
typeof state.usage.size === "number" ? `${usage} / ${formatContextTokens(state.usage.size)}` : usage,
);
}
return parts.join(" · ");
};
const setRowExpanded = (id: string, expanded: boolean) =>
setExpandedRows((current) => {
const next = new Set(current);
if (expanded) next.add(id);
else next.delete(id);
return next;
});
const setGroupExpanded = (id: string, expanded: boolean) =>
setExpandedGroups((current) => {
const next = new Set(current);
if (expanded) next.add(id);
else next.delete(id);
return next;
});
const renderRow = (row: Row) => (
<TranscriptRow
key={row.id}
row={row}
pendingApproval={row.id === pendingRowId}
onDecide={(decision) => decide(row.id, decision)}
expanded={expandedRows.has(row.id)}
onExpandedChange={(expanded) => setRowExpanded(row.id, expanded)}
imageUrls={imageUrls.current}
/>
);
return (
<div className={className ? `structured-pane ${className}` : "structured-pane"}>
<div className="structured-body">
<div className="structured-main">
<div className="structured-scroll" ref={scrollRef} onScroll={onScroll}>
<div className="structured-rows">
{items.map((item) =>
item.type === "row" ? (
renderRow(item.row)
) : (
<ToolGroup
key={item.id}
id={item.id}
toolCount={item.group.toolCount}
expanded={expandedGroups.has(item.id)}
onExpandedChange={(expanded) => setGroupExpanded(item.id, expanded)}
>
{item.group.rows.map(renderRow)}
</ToolGroup>
),
)}
{queue.map((queued, index) => (
<div key={`queued-${index}`} className="st-row st-row-queued">
<div className="st-body">
<div className="st-user-text">{queued.text}</div>
</div>
</div>
))}
</div>
<div className="structured-activity" role="status" aria-live="polite" hidden={!active}>
<span className="structured-activity-pulse" aria-hidden="true" />
<span className="structured-activity-text">{active ? activityLabel() : ""}</span>
</div>
</div>
{error !== null && <div className="structured-error">{error}</div>}
<div ref={composerRef}>
<Composer
attachments={attachments}
onAttachmentsChange={setAttachments}
commands={commands}
configOptions={configOptions}
turnActive={active}
uploading={uploading}
configBusy={configBusy}
draft={draft}
onDraftChange={setDraft}
onSubmit={submit}
onCancel={cancel}
onConfigChange={changeConfig}
onError={setError}
menuId={`structured-command-menu-${sessionId}`}
/>
</div>
</div>
</div>
</div>
);
});
@@ -0,0 +1,43 @@
// A turn's collapsed activity block: two or more tool rows after the same
// `User` row, plus the narration absorbed between them. The header counts tool
// rows only (docs/STRUCTURED-VIEW.md); opening it reveals tools and text in
// chronological order.
import type { ReactNode } from "react";
export interface ToolGroupProps {
id: string;
toolCount: number;
expanded: boolean;
onExpandedChange(expanded: boolean): void;
children: ReactNode;
}
export function ToolGroup({ id, toolCount, expanded, onExpandedChange, children }: ToolGroupProps) {
return (
<div className="st-tool-group" data-tool-group-id={id}>
<button
type="button"
className="st-tool-group-head"
aria-expanded={expanded}
onClick={() => onExpandedChange(!expanded)}
>
<span className="st-tool-group-glyph" aria-hidden="true">
›
</span>
<span className="st-tool-group-title">{`${toolCount} tool calls`}</span>
</button>
<div
className={
expanded ? "st-tool-group-body st-disclosure is-expanded" : "st-tool-group-body st-disclosure"
}
aria-hidden={!expanded}
inert={!expanded}
>
<div className="st-disclosure-clip">
<div className="st-disclosure-inner">{children}</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,35 @@
// The glyph column of a tool row's head. Recognition is narrow on purpose: an
// unrecognised or absent `ToolKind` gets the generic bullet, never a blank
// column and never a mark that stands for the wrong kind of work.
import type { AcpToolKind } from "../../types.js";
/** Glyph shown in a tool row's head, by `ToolKind`. */
const KIND_GLYPH: Record<string, string> = {
read: "≡",
edit: "±",
delete: "✕",
move: "→",
search: "⌕",
execute: "$",
think: "…",
fetch: "⇣",
switch_mode: "⇄",
};
export function toolGlyph(kind: string | undefined): string {
return (kind && KIND_GLYPH[kind]) || "•";
}
export interface ToolIconProps {
kind: AcpToolKind | string | undefined;
className?: string;
}
export function ToolIcon({ kind, className = "st-tool-glyph" }: ToolIconProps) {
return (
<span className={className} aria-hidden="true">
{toolGlyph(kind)}
</span>
);
}
@@ -0,0 +1,387 @@
// One transcript row, by kind. docs/STRUCTURED-VIEW.md fixes the eight kinds
// and leaves every rendering decision here: markdown for AgentText, an inline
// italic stream for AgentThought, a tool card for ToolCall/ToolDiff, a diff
// view for ToolDiff's content, an approval card for Approval, and an opaque
// collapsed line for a Notice nobody has taught the client about. `Plan` is
// deliberately not drawn — the web client has no plan rail.
import { useEffect, useRef, type ReactNode } from "react";
import { renderInlineMarkdown, renderMarkdown, formatElapsed } from "../../structuredRender.js";
import type {
AcpContentBlock,
AcpPermissionOption,
AcpToolCallContent,
AcpToolCallLike,
StructuredApprovalDecision,
TranscriptRow as Row,
TurnMeta,
} from "../../types.js";
import { ApprovalView } from "./ApprovalView.js";
import { Copyable } from "./Copyable.js";
import { DiffView } from "./DiffView.js";
import {
contentBlockText,
isStateOnlyNotice,
splitToolTitle,
statusMeta,
toolDiffMeta,
turnMeta,
} from "./model.js";
import { safeMediaSource, toolPayload } from "./toolPayload.js";
import { ToolIcon } from "./ToolIcon.js";
// ---------------------------------------------------------------------------
// content rows: user / agent / thought
// ---------------------------------------------------------------------------
/** Model-authored markdown, sanitized by structuredRender.ts before it enters
* the DOM. Tables get their own scroller and links open out of the app. */
function Prose({ html }: { html: string }) {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const container = ref.current;
if (!container) return;
for (const table of Array.from(container.querySelectorAll("table"))) {
if (table.parentElement?.classList.contains("st-markdown-table-wrap")) continue;
const scroller = document.createElement("div");
scroller.className = "st-markdown-table-wrap";
table.replaceWith(scroller);
scroller.appendChild(table);
}
for (const link of container.querySelectorAll("a")) {
link.target = "_blank";
link.rel = "noopener noreferrer";
}
}, [html]);
return <div ref={ref} className="st-body" dangerouslySetInnerHTML={{ __html: html }} />;
}
export interface ContentRowProps {
row: Row;
variant: "user" | "agent" | "thought";
/** Object URLs for images this prompt uploaded, keyed by the server path the
* row's text names. */
imageUrls?: Map<string, string>;
}
/** `Attached file "name": "path"` references the composer appends on send. */
const ATTACHED_FILE_RE = /Attached file "(?:[^"\\]|\\.)*": "((?:[^"\\]|\\.)*)"/g;
function attachedImages(text: string, imageUrls: Map<string, string>): string[] {
const urls: string[] = [];
ATTACHED_FILE_RE.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = ATTACHED_FILE_RE.exec(text)) !== null) {
let serverPath: string;
try {
serverPath = JSON.parse(`"${match[1]}"`) as string;
} catch {
continue;
}
const url = imageUrls.get(serverPath);
if (url) urls.push(url);
}
return urls;
}
export function ContentRow({ row, variant, imageUrls }: ContentRowProps) {
const data = row.data as { content?: AcpContentBlock } | undefined;
const text = contentBlockText(data?.content);
const displayText = variant === "thought" ? text.trim() : text;
const label = variant === "user" ? "message" : variant === "agent" ? "response" : "thought";
let body: ReactNode;
if (variant === "user") {
const images = imageUrls && imageUrls.size > 0 ? attachedImages(displayText, imageUrls) : [];
// With previews in hand the path references are noise — the image is the
// attachment. Without them the text is all the operator has, so it stays.
const shown =
images.length > 0
? displayText.replace(/\n\nAttached file "[^"]*": "[^"]*"/g, "").trim()
: displayText;
body = (
<div className="st-body">
{shown && <div className="st-user-text">{shown}</div>}
{images.map((src) => (
<img key={src} className="st-user-image" src={src} alt="Uploaded image" loading="lazy" />
))}
</div>
);
} else if (variant === "agent") {
body = <Prose html={renderMarkdown(displayText)} />;
} else {
body = <div className="st-body" dangerouslySetInnerHTML={{ __html: renderInlineMarkdown(displayText) }} />;
}
return (
<Copyable className={`st-row st-row-${variant}`} text={displayText} label={label} data-row-id={row.id}>
{body}
</Copyable>
);
}
// ---------------------------------------------------------------------------
// tool rows
// ---------------------------------------------------------------------------
function ToolDetailBlock({ label, text }: { label: string; text: string }) {
return (
<div className="st-tool-detail">
<div className="st-tool-detail-label">{label}</div>
<Copyable as="pre" text={text} label={label.toLowerCase()}>
{text}
</Copyable>
</div>
);
}
function ContentBlockView({ block }: { block: AcpContentBlock }) {
if (block.type === "text") {
if (!block.text) return null;
return (
<Copyable className="st-tool-text" text={block.text} label="output">
{block.text}
</Copyable>
);
}
if (block.type === "image") {
const source = safeMediaSource(block.mimeType, block.data, "image");
if (source) return <img className="st-tool-image" src={source} alt="Tool output" loading="lazy" />;
return <ToolDetailBlock label="Image" text={block.uri ?? block.mimeType ?? "Unavailable"} />;
}
if (block.type === "audio") {
const source = safeMediaSource(block.mimeType, block.data, "audio");
if (source) return <audio className="st-tool-audio" controls src={source} />;
return <ToolDetailBlock label="Audio" text={block.mimeType ?? "Unavailable"} />;
}
if (block.type === "resource_link") {
return (
<div className="st-tool-resource">
<span className="st-tool-resource-name">{block.title ?? block.name ?? "Resource"}</span>
{block.uri && (
<Copyable as="code" className="st-tool-resource-uri" text={block.uri} label="resource URI">
{block.uri}
</Copyable>
)}
{block.description && <span className="st-tool-resource-description">{block.description}</span>}
</div>
);
}
const resource = block.resource;
if (typeof resource?.text === "string") {
return (
<ToolDetailBlock label={resource.uri ? `Resource · ${resource.uri}` : "Resource"} text={resource.text} />
);
}
const printed = resource === undefined ? "" : JSON.stringify(resource, null, 2);
return printed ? <ToolDetailBlock label="Resource" text={printed} /> : null;
}
function ToolBody({ tool, content }: { tool: AcpToolCallLike; content: AcpToolCallContent[] }) {
const payload = toolPayload(tool, content);
const hasRaw = payload.raw.input !== null || payload.raw.output !== null;
return (
<>
{content.map((block, index) => {
if (block.type === "content") return <ContentBlockView key={index} block={block.content} />;
if (block.type === "diff") return <DiffView key={index} diff={block} />;
return (
<div key={index} className="st-tool-terminal">
{block.terminalId ? `Terminal · ${block.terminalId}` : "Embedded terminal"}
</div>
);
})}
{payload.details.map((detail, index) => (
<ToolDetailBlock key={`${detail.label}-${index}`} label={detail.label} text={detail.text} />
))}
{payload.exitCode !== null && (
<div className="st-tool-fact">
<span className="st-tool-fact-label">Exit code</span>
<code className={payload.exitCode === 0 ? "st-tool-exit-ok" : "st-tool-exit-failed"}>
{payload.exitCode}
</code>
</div>
)}
{hasRaw && (
<details className="st-tool-raw">
<summary>Raw JSON</summary>
<div className="st-tool-raw-body">
{payload.raw.input !== null && <ToolDetailBlock label="Input" text={payload.raw.input} />}
{payload.raw.output !== null && <ToolDetailBlock label="Output" text={payload.raw.output} />}
</div>
</details>
)}
</>
);
}
/** True when a tool call has anything at all to disclose — a head with nothing
* behind it stays a plain line rather than an empty disclosure. */
function hasToolBody(tool: AcpToolCallLike, content: AcpToolCallContent[]): boolean {
if (content.length > 0) return true;
const payload = toolPayload(tool, content);
return payload.details.length > 0 || payload.exitCode !== null || payload.raw.input !== null || payload.raw.output !== null;
}
export interface ToolRowProps {
row: Row;
expanded: boolean;
onExpandedChange(expanded: boolean): void;
}
export function ToolRow({ row, expanded, onExpandedChange }: ToolRowProps) {
const tool = (row.data ?? {}) as AcpToolCallLike;
const content = tool.content ?? [];
const hasDiff = content.some((c) => c.type === "diff");
const { label, arg } = splitToolTitle(tool.title, tool.name, tool.kind);
const meta = statusMeta(tool.status, hasDiff);
const metaParts: string[] = [];
if (hasDiff) metaParts.push(toolDiffMeta(content));
if (meta) metaParts.push(meta.text);
const disclosable = hasToolBody(tool, content);
const open = expanded && disclosable;
return (
<div className="st-row st-row-tool" data-row-id={row.id}>
<div className="st-tool">
<button
type="button"
className="st-tool-head"
aria-expanded={open}
onClick={() => disclosable && onExpandedChange(!open)}
>
<ToolIcon kind={tool.kind} />
<span className="st-tool-name">{label}</span>
<span className="st-tool-arg" title={arg}>
{arg}
</span>
{metaParts.length > 0 && (
<span className={meta ? `st-tool-meta st-tool-meta-${meta.kind}` : "st-tool-meta"}>
{metaParts.join(" · ")}
</span>
)}
</button>
{disclosable && (
<div
className={open ? "st-tool-out st-disclosure is-expanded" : "st-tool-out st-disclosure"}
aria-hidden={!open}
inert={!open}
>
<div className="st-disclosure-clip">
<div className="st-disclosure-inner">
{/* Built only once opened: a long transcript would otherwise
render every tool's whole output up front. */}
{open && <ToolBody tool={tool} content={content} />}
</div>
</div>
</div>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// notices and turn footers
// ---------------------------------------------------------------------------
function compactNumber(value: number): string {
if (Math.abs(value) < 1_000) return String(value);
if (Math.abs(value) < 1_000_000) return `${(value / 1_000).toFixed(value % 1_000 === 0 ? 0 : 1)}k`;
return `${(value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 1)}m`;
}
export function TurnMetaRow({ meta }: { meta: TurnMeta }) {
const parts = [formatElapsed(meta.elapsedMs)];
if (typeof meta.contextDelta === "number") {
const sign = meta.contextDelta > 0 ? "+" : "";
parts.push(`${sign}${compactNumber(meta.contextDelta)} context tokens`);
} else if (typeof meta.contextTokens === "number") {
parts.push(`${compactNumber(meta.contextTokens)} context`);
}
if (meta.model) parts.push(meta.model);
if (meta.outcome !== "completed") parts.push(meta.outcome);
const text = parts.join(" · ");
return (
<Copyable
className={`st-turn-meta st-turn-meta-${meta.outcome}`}
text={text}
label="turn details"
aria-label={`Turn ${meta.outcome}`}
>
<span className="st-turn-meta-dot" aria-hidden="true" />
<span>{text}</span>
</Copyable>
);
}
/** The floor presentation docs/STRUCTURED-VIEW.md allows for a `Notice` the
* client has nothing better to do with: an opaque, collapsed line. */
export function NoticeRow({ row }: { row: Row }) {
const text = `[${row.kind}]`;
return (
<Copyable className="st-row st-row-notice" text={text} label="notice" data-row-id={row.id}>
<div className="st-body">{text}</div>
</Copyable>
);
}
// ---------------------------------------------------------------------------
// dispatch
// ---------------------------------------------------------------------------
export interface TranscriptRowProps {
row: Row;
/** This row is the one live approval (docs/STRUCTURED-VIEW.md's nonce
* rules). Every other approval row is history. */
pendingApproval?: boolean;
onDecide?: (decision: StructuredApprovalDecision) => Promise<void>;
expanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
imageUrls?: Map<string, string>;
}
/** One transcript row, or `null` for the rows the web client omits (`plan`,
* and the state-only notices it consumes for `/` autocomplete). */
export function TranscriptRow({
row,
pendingApproval = false,
onDecide,
expanded = false,
onExpandedChange,
imageUrls,
}: TranscriptRowProps) {
switch (row.kind) {
case "user":
return <ContentRow row={row} variant="user" imageUrls={imageUrls} />;
case "agent_text":
return <ContentRow row={row} variant="agent" />;
case "agent_thought":
return <ContentRow row={row} variant="thought" />;
case "tool_call":
case "tool_diff":
return (
<ToolRow row={row} expanded={expanded} onExpandedChange={(next) => onExpandedChange?.(next)} />
);
case "approval": {
const request = (row.data ?? {}) as { toolCall?: AcpToolCallLike; options?: AcpPermissionOption[] };
return (
<ApprovalView
request={request}
pending={pendingApproval}
onDecide={onDecide ?? (() => Promise.resolve())}
/>
);
}
case "plan":
return null;
case "notice": {
if (isStateOnlyNotice(row)) return null;
const meta = turnMeta(row);
return meta ? <TurnMetaRow meta={meta} /> : <NoticeRow row={row} />;
}
default:
return <NoticeRow row={row} />;
}
}
+13
View File
@@ -0,0 +1,13 @@
export { StructuredPane, type StructuredPaneHandle, type StructuredPaneProps } from "./StructuredPane.js";
export { TranscriptRow, type TranscriptRowProps } from "./TranscriptRow.js";
export { DiffView, type DiffViewProps } from "./DiffView.js";
export { ApprovalView, type ApprovalViewProps } from "./ApprovalView.js";
export { ToolGroup, type ToolGroupProps } from "./ToolGroup.js";
export { ToolIcon, toolGlyph, type ToolIconProps } from "./ToolIcon.js";
export { Composer, type ComposerProps } from "./Composer.js";
export {
useStructuredSession,
type StructuredSession,
type StructuredSocketFactory,
type StructuredStatus,
} from "./useStructuredSession.js";
+252
View File
@@ -0,0 +1,252 @@
import { describe, expect, it } from "vitest";
import type { ReducedState, TranscriptRow } from "../../types.js";
import {
activityGroupsByTurn,
applyCommand,
commandsFromTranscript,
commandTokenAt,
contentBlockText,
filterCommands,
isStateOnlyNotice,
offeredDecisions,
orderConfigOptions,
pendingApprovalRowId,
splitToolTitle,
statusMeta,
toolDiffMeta,
toolKindLabel,
transcriptItems,
turnMeta,
} from "./model.js";
function row(id: string, kind: TranscriptRow["kind"], data: unknown = {}): TranscriptRow {
return { id, kind, data };
}
function tool(id: string): TranscriptRow {
return row(id, "tool_call", { title: `Read(${id})`, status: "completed" });
}
function text(id: string, kind: "user" | "agent_text" | "agent_thought", body: string): TranscriptRow {
return row(id, kind, { content: { type: "text", text: body } });
}
const idle: ReducedState = { turn: { responding: false, thinking: false, active_tool_calls: [] } };
describe("row taxonomy", () => {
it("splits an adapter's Name(argument) title, and falls back to the kind", () => {
expect(splitToolTitle("Read(src/main.ts)", null, "read")).toEqual({ label: "Read", arg: "src/main.ts" });
expect(splitToolTitle("something opaque", null, "search")).toEqual({
label: "Search",
arg: "something opaque",
});
expect(splitToolTitle(undefined, null, undefined)).toEqual({ label: "Tool", arg: "" });
});
it("labels an unknown tool kind rather than leaving it blank", () => {
expect(toolKindLabel("execute")).toBe("Execute");
expect(toolKindLabel("no_such_kind")).toBe("Tool");
expect(toolKindLabel(undefined)).toBe("Tool");
});
it("names non-text content blocks instead of dropping them", () => {
expect(contentBlockText({ type: "text", text: "hi" })).toBe("hi");
expect(contentBlockText({ type: "image" })).toBe("[image]");
expect(contentBlockText({ type: "resource_link", name: "spec.md" })).toBe("[resource: spec.md]");
expect(contentBlockText(undefined)).toBe("");
});
it("counts a tool call's diff lines", () => {
expect(toolDiffMeta([{ type: "diff", path: "a.ts", oldText: "one\ntwo\n", newText: "one\nTWO\nthree\n" }]))
.toBe("+2 −1");
expect(toolDiffMeta([])).toBe("+0 −0");
});
it("shows a status only while it is worth showing", () => {
expect(statusMeta("in_progress", false)).toEqual({ text: "running", kind: "running" });
expect(statusMeta("failed", true)).toEqual({ text: "failed", kind: "failed" });
expect(statusMeta("pending", true)).toBeNull(); // the diff counts already say it
expect(statusMeta("completed", false)).toBeNull();
});
});
describe("notices", () => {
it("reads a maestroTurnMeta footer, and only a well-formed one", () => {
const meta = { elapsedMs: 4200, outcome: "completed", model: "sonnet" };
expect(turnMeta(row("n1", "notice", { maestroTurnMeta: meta }))).toEqual(meta);
expect(turnMeta(row("n2", "notice", { maestroTurnMeta: { outcome: "completed" } }))).toBeNull();
expect(turnMeta(row("n3", "notice", { sessionUpdate: "session_info_update" }))).toBeNull();
});
it("marks the state-only notices the client consumes rather than shows", () => {
expect(isStateOnlyNotice(row("n1", "notice", { sessionUpdate: "available_commands_update" }))).toBe(true);
expect(isStateOnlyNotice(row("n2", "notice", { sessionUpdate: "session_info_update" }))).toBe(true);
expect(isStateOnlyNotice(row("n3", "notice", { sessionUpdate: "something_else" }))).toBe(false);
});
});
describe("approvals", () => {
it("offers only decisions the request actually backs", () => {
expect(
offeredDecisions([
{ optionId: "a", name: "Allow once", kind: "allow_once" },
{ optionId: "r", name: "Reject", kind: "reject_once" },
]),
).toEqual(["allow_once", "deny"]);
expect(offeredDecisions([{ optionId: "a", name: "Always", kind: "allow_always" }])).toEqual([
"allow_for_session",
]);
expect(offeredDecisions([])).toEqual([]);
});
it("points a live pending approval at the newest approval row", () => {
const rows = [row("approval:1", "approval"), tool("tool:1"), row("approval:2", "approval")];
const pending: ReducedState = {
...idle,
pending_approval: { nonce: "n", request: {} as never },
};
expect(pendingApprovalRowId(rows, pending)).toBe("approval:2");
expect(pendingApprovalRowId(rows, idle)).toBeNull();
});
});
describe("activity grouping", () => {
it("groups a turn's tools once there are two of them", () => {
const rows = [text("user:1", "user", "go"), tool("tool:1"), tool("tool:2")];
const groups = activityGroupsByTurn(rows, null, false);
expect([...groups.keys()]).toEqual(["turn:user:1"]);
expect(groups.get("turn:user:1")).toMatchObject({ toolCount: 2 });
});
it("leaves a single tool call standalone", () => {
const rows = [text("user:1", "user", "go"), tool("tool:1")];
expect(activityGroupsByTurn(rows, null, false).size).toBe(0);
});
it("absorbs intermediate narration but keeps the final answer outside", () => {
const rows = [
text("user:1", "user", "go"),
tool("tool:1"),
text("agent:1", "agent_text", "working"),
tool("tool:2"),
text("agent:2", "agent_text", "done"),
];
const group = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "agent:1", "tool:2"]);
expect(group.toolCount).toBe(2);
});
it("keeps the newest thought outside while the turn is still live", () => {
const rows = [
text("user:1", "user", "go"),
tool("tool:1"),
tool("tool:2"),
text("thought:1", "agent_thought", "hmm"),
text("thought:2", "agent_thought", "aha"),
];
const live = activityGroupsByTurn(rows, null, true).get("turn:user:1")!;
expect(live.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2", "thought:1"]);
const ended = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
expect(ended.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2", "thought:1", "thought:2"]);
});
it("absorbs a resolved approval, never the pending one", () => {
const rows = [
text("user:1", "user", "go"),
tool("tool:1"),
row("approval:1", "approval"),
tool("tool:2"),
row("approval:2", "approval"),
tool("tool:3"),
];
const group = activityGroupsByTurn(rows, "approval:2", false).get("turn:user:1")!;
expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "approval:1", "tool:2", "tool:3"]);
});
});
describe("transcript render list", () => {
it("renders a group once, at its first member's position", () => {
const rows = [text("user:1", "user", "go"), tool("tool:1"), tool("tool:2"), text("agent:1", "agent_text", "done")];
const items = transcriptItems(rows, null, false);
expect(items.map((item) => (item.type === "row" ? item.row.id : item.id))).toEqual([
"user:1",
"turn:user:1",
"agent:1",
]);
});
it("omits plan rows, state-only notices and standalone resolved approvals", () => {
const rows = [
text("user:1", "user", "go"),
row("plan", "plan", { entries: [] }),
row("notice:1", "notice", { sessionUpdate: "available_commands_update", availableCommands: [] }),
row("approval:1", "approval"),
text("agent:1", "agent_text", "done"),
];
const items = transcriptItems(rows, null, false);
expect(items.map((item) => (item.type === "row" ? item.row.id : item.id))).toEqual(["user:1", "agent:1"]);
});
it("keeps a turn footer only while it is the final item", () => {
const footer = row("turn_meta:1", "notice", { maestroTurnMeta: { elapsedMs: 10, outcome: "completed" } });
expect(transcriptItems([text("user:1", "user", "go"), footer], null, false)).toHaveLength(2);
const later = [text("user:1", "user", "go"), footer, text("user:2", "user", "again")];
expect(transcriptItems(later, null, false).map((i) => (i.type === "row" ? i.row.id : i.id))).toEqual([
"user:1",
"user:2",
]);
});
});
describe("composer commands", () => {
const notice = row("notice:9", "notice", {
sessionUpdate: "available_commands_update",
availableCommands: [
{ name: "compact", description: "Compact the\n conversation" },
{ name: "cost", description: "Show cost", input: { hint: "[period]" } },
{ name: 42 },
],
});
it("takes the newest available_commands_update, skipping malformed entries", () => {
const stale = row("notice:1", "notice", {
sessionUpdate: "available_commands_update",
availableCommands: [{ name: "old", description: "gone" }],
});
expect(commandsFromTranscript([stale, notice]).map((c) => c.name)).toEqual(["compact", "cost"]);
expect(commandsFromTranscript([])).toEqual([]);
});
it("only matches a /token under the caret", () => {
expect(commandTokenAt("/comp", 5, 5)).toEqual({ start: 0, end: 5, query: "comp" });
expect(commandTokenAt("hello /co there", 9, 9)).toEqual({ start: 6, end: 9, query: "co" });
expect(commandTokenAt("no slash", 3, 3)).toBeNull();
expect(commandTokenAt("/comp", 2, 5)).toBeNull(); // a selection is not a caret
});
it("ranks prefix matches ahead of substring matches", () => {
const commands = commandsFromTranscript([notice]);
expect(filterCommands(commands, "co").map((c) => c.name)).toEqual(["compact", "cost"]);
expect(filterCommands(commands, "act").map((c) => c.name)).toEqual(["compact"]);
});
it("replaces only the token under the caret", () => {
const token = commandTokenAt("say /co now", 7, 7)!;
expect(applyCommand("say /co now", token, "compact")).toEqual({ value: "say /compact now", cursor: 13 });
const trailing = commandTokenAt("/co", 3, 3)!;
expect(applyCommand("/co", trailing, "compact")).toEqual({ value: "/compact ", cursor: 9 });
});
});
describe("session config", () => {
it("orders the menu by category, unknown categories last", () => {
const options = [
{ id: "d", category: null },
{ id: "b", category: "thought_level" },
{ id: "a", category: "model" },
{ id: "c", category: "mode" },
];
expect(orderConfigOptions(options).map((o) => o.id)).toEqual(["a", "b", "c", "d"]);
});
});
+369
View File
@@ -0,0 +1,369 @@
// Pure transcript logic for the structured pane (#246) — the decisions
// docs/STRUCTURED-VIEW.md leaves to a client, kept out of the components so
// they can be tested without a DOM or a socket. Ported from structured.ts's
// private helpers and the parts of structuredRender.ts that were data, not
// markup.
import type {
AcpContentBlock,
AcpPermissionOption,
AcpToolCallContent,
ReducedState,
StructuredApprovalDecision,
TranscriptRow,
TranscriptRowKind,
TurnMeta,
} from "../../types.js";
import { computeDiffLines, diffStats } from "../../structuredDiff.js";
/** Human label behind a `ToolKind`. An unrecognised/absent kind is "Tool" —
* never a blank label. */
const KIND_LABEL: Record<string, string> = {
read: "Read",
edit: "Edit",
delete: "Delete",
move: "Move",
search: "Search",
execute: "Execute",
think: "Think",
fetch: "Fetch",
switch_mode: "Switch mode",
};
export function toolKindLabel(kind: string | undefined): string {
return (kind && KIND_LABEL[kind]) || "Tool";
}
/** Split a title into `{label, arg}` when it follows the adapter's own
* `Name(argument)` convention; otherwise the whole title is the arg and the
* tool's `kind` supplies a generic label. Never guesses at a shape the data
* doesn't actually show. */
export function splitToolTitle(
title: string | undefined,
name: string | null | undefined,
kind: string | undefined,
): { label: string; arg: string } {
const label = name ?? toolKindLabel(kind);
if (!title) return { label, arg: "" };
const m = /^(\S+)\(([\s\S]*)\)$/.exec(title);
if (m) return { label: name ?? m[1], arg: m[2] };
return { label, arg: title };
}
/** Content-block text, or a labelled placeholder for anything that isn't
* text — never dropped silently, never fabricated. */
export function contentBlockText(block: AcpContentBlock | undefined): string {
if (!block) return "";
switch (block.type) {
case "text":
return block.text;
case "image":
return "[image]";
case "audio":
return "[audio]";
case "resource_link":
return `[resource: ${block.name ?? block.uri ?? "linked"}]`;
case "resource":
return "[resource]";
default:
return "";
}
}
/** `+added −removed` across every diff block in a tool call's content — real
* derived data, never a fabricated duration. */
export function toolDiffMeta(content: AcpToolCallContent[]): string {
let added = 0;
let removed = 0;
for (const block of content) {
if (block.type !== "diff") continue;
const stats = diffStats(computeDiffLines(block.oldText, block.newText));
added += stats.added;
removed += stats.removed;
}
return `+${added} −${removed}`;
}
export function statusMeta(
status: string | undefined,
hasDiff: boolean,
): { text: string; kind: string } | null {
if (status === "in_progress") return { text: "running", kind: "running" };
if (status === "failed") return { text: "failed", kind: "failed" };
if (status === "pending" && !hasDiff) return { text: "pending", kind: "pending" };
return null;
}
/** The `maestroTurnMeta` footer a `Notice` row carries when it is a completed,
* cancelled or failed turn's durable footer — `null` for every other notice. */
export function turnMeta(row: TranscriptRow): TurnMeta | null {
if (row.kind !== "notice" || !row.data || typeof row.data !== "object") return null;
const value = (row.data as { maestroTurnMeta?: unknown }).maestroTurnMeta;
if (!value || typeof value !== "object") return null;
const meta = value as Partial<TurnMeta>;
if (typeof meta.elapsedMs !== "number" || typeof meta.outcome !== "string") return null;
return meta as TurnMeta;
}
/** Notices the web client consumes as state and omits from the visible
* transcript (docs/STRUCTURED-VIEW.md, "A client decides"). */
export function isStateOnlyNotice(row: TranscriptRow): boolean {
if (row.kind !== "notice" || !row.data || typeof row.data !== "object") return false;
const update = (row.data as { sessionUpdate?: string }).sessionUpdate;
return update === "available_commands_update" || update === "session_info_update";
}
/** Which decisions a permission request actually backs, by the
* `PermissionOptionKind` values it offers (docs/STRUCTURED-VIEW.md's
* "Decision mapping"). A decision with no matching option resolves
* `Cancelled` server-side regardless of what the client sent — offering its
* button would let the operator "allow" something that silently no-ops. */
export function offeredDecisions(options: AcpPermissionOption[]): StructuredApprovalDecision[] {
const kinds = new Set(options.map((o) => o.kind));
const decisions: StructuredApprovalDecision[] = [];
if (kinds.has("allow_once")) decisions.push("allow_once");
if (kinds.has("allow_always")) decisions.push("allow_for_session");
if (kinds.has("reject_once") || kinds.has("reject_always")) decisions.push("deny");
return decisions;
}
/** The row id a live `pending_approval` names: the newest `Approval` row.
* Only one approval is ever live (docs/STRUCTURED-VIEW.md, "One live
* approval at a time"), so the last one is the pending one. */
export function pendingApprovalRowId(rows: TranscriptRow[], state: ReducedState): string | null {
return state.pending_approval ? lastRowIdOfKind(rows, "approval") : null;
}
export function lastRowIdOfKind(rows: TranscriptRow[], kind: TranscriptRowKind): string | null {
for (let i = rows.length - 1; i >= 0; i--) if (rows[i].kind === kind) return rows[i].id;
return null;
}
export function turnIsActive(state: ReducedState): boolean {
return state.turn.responding || state.turn.thinking;
}
export interface ActivityGroup {
/** Header label count — tool rows only, never absorbed text. */
toolCount: number;
/** Tools plus absorbed intermediate text/approvals, in transcript order. */
rows: TranscriptRow[];
}
/**
* Group tool rows (and intermediate agent text / resolved approvals between
* them) by turn. An agent_text/agent_thought row is "intermediate" when a tool
* row follows it in the same turn — it gets absorbed into the group so the
* collapsed block preserves chronological order. Resolved approval rows (not
* the currently pending one) are absorbed the same way. The *last* agent text
* with no subsequent tool stays standalone (the prominent final answer).
*
* Returns groups keyed by `turn:<userId>`.
*/
export function activityGroupsByTurn(
rows: TranscriptRow[],
pendingApprovalId: string | null,
turnActive: boolean,
): Map<string, ActivityGroup> {
// First: count tools per turn to know which turns qualify for grouping.
const toolCounts = new Map<string, number>();
let turnId = "before-first-message";
for (const row of rows) {
if (row.kind === "user") turnId = row.id;
if (row.kind !== "tool_call" && row.kind !== "tool_diff") continue;
const gid = `turn:${turnId}`;
toolCounts.set(gid, (toolCounts.get(gid) ?? 0) + 1);
}
// Second: build groups with interleaved text for turns with 2+ tools.
const groups = new Map<string, ActivityGroup>();
turnId = "before-first-message";
// Pending rows that might be intermediate (not yet confirmed by a following
// tool). If no tool follows, they stay standalone.
let pending: TranscriptRow[] = [];
let currentGid = `turn:${turnId}`;
for (const row of rows) {
if (row.kind === "user") {
turnId = row.id;
currentGid = `turn:${turnId}`;
pending = [];
continue;
}
const count = toolCounts.get(currentGid) ?? 0;
if (count < 2) continue;
const isIntermediateText = row.kind === "agent_text" || row.kind === "agent_thought";
const isResolvedApproval = row.kind === "approval" && row.id !== pendingApprovalId;
if (isIntermediateText || isResolvedApproval) {
pending.push(row);
continue;
}
if (row.kind === "tool_call" || row.kind === "tool_diff") {
const group = groups.get(currentGid) ?? { toolCount: count, rows: [] };
// Flush any pending rows — a tool follows, so they're intermediate.
group.rows.push(...pending);
pending = [];
group.rows.push(row);
groups.set(currentGid, group);
}
}
// A final agent text is the answer, so it stays outside. Thought rows are
// working commentary, though: when a turn already has a disclosure they
// belong with its activity even if the next tool call has not arrived yet.
// While the turn is still live the most recent one stays outside as visible
// narration (docs/STRUCTURED-VIEW.md), and is absorbed when the turn ends.
const lastGroup = groups.get(currentGid);
if (lastGroup && pending.length > 0 && pending.every((row) => row.kind === "agent_thought")) {
const absorb = turnActive ? pending.slice(0, -1) : pending;
lastGroup.rows.push(...absorb);
}
return groups;
}
/** One entry of the flattened render list: either a standalone row or a whole
* activity group. */
export type TranscriptItem =
| { type: "row"; row: TranscriptRow }
| { type: "group"; id: string; group: ActivityGroup };
/**
* The transcript in render order: rows, with each turn's tool activity folded
* into a single group item where one exists. Resolved approvals not absorbed
* into a group are dropped — the tool row itself already identifies the
* action. `plan` rows and state-only notices are dropped too (both clients
* omit `Plan` from the transcript). A turn footer only survives as the final
* item, so old footers stop reading like transcript content.
*/
export function transcriptItems(
rows: TranscriptRow[],
pendingApprovalId: string | null,
turnActive: boolean,
): TranscriptItem[] {
const groups = activityGroupsByTurn(rows, pendingApprovalId, turnActive);
const grouped = new Set<string>();
for (const group of groups.values()) for (const row of group.rows) grouped.add(row.id);
const items: TranscriptItem[] = [];
let turnId = "before-first-message";
const emitted = new Set<string>();
for (const row of rows) {
if (row.kind === "user") turnId = row.id;
const groupId = `turn:${turnId}`;
const group = groups.get(groupId);
if (group && grouped.has(row.id)) {
// The group renders once, at its first member's position.
if (!emitted.has(groupId)) {
emitted.add(groupId);
items.push({ type: "group", id: groupId, group });
}
continue;
}
if (row.kind === "plan") continue;
if (row.kind === "notice" && isStateOnlyNotice(row)) continue;
// A resolved approval outside a group adds nothing the tool row does not
// already say. A pending one always renders.
if (row.kind === "approval" && row.id !== pendingApprovalId) continue;
items.push({ type: "row", row });
}
return items.filter(
(item, index) =>
!(item.type === "row" && turnMeta(item.row) !== null) || index === items.length - 1,
);
}
// ---------------------------------------------------------------------------
// composer: `/` command autocomplete off `available_commands_update` notices
// ---------------------------------------------------------------------------
export interface AvailableCommand {
name: string;
description: string;
input?: { hint?: string };
}
/** The live command list is whatever the most recent
* `available_commands_update` notice carries (docs/STRUCTURED-VIEW.md). */
export function commandsFromTranscript(rows: TranscriptRow[]): AvailableCommand[] {
for (let i = rows.length - 1; i >= 0; i--) {
if (rows[i].kind !== "notice") continue;
const data = rows[i].data as { sessionUpdate?: string; availableCommands?: unknown[] } | undefined;
if (data?.sessionUpdate !== "available_commands_update") continue;
return (data.availableCommands ?? []).flatMap((value) => {
if (!value || typeof value !== "object") return [];
const command = value as Partial<AvailableCommand>;
if (typeof command.name !== "string" || typeof command.description !== "string") return [];
return [{ name: command.name, description: command.description, input: command.input }];
});
}
return [];
}
export interface CommandToken {
start: number;
end: number;
query: string;
}
/** The whitespace-delimited token under the caret, when it is a `/command`
* being typed. Completing one replaces only that token. */
export function commandTokenAt(
value: string,
selectionStart: number,
selectionEnd: number,
): CommandToken | null {
if (selectionStart !== selectionEnd) return null;
let start = selectionStart;
while (start > 0 && !/\s/.test(value[start - 1])) start--;
let end = selectionStart;
while (end < value.length && !/\s/.test(value[end])) end++;
const match = /^\/([^\s/]*)$/.exec(value.slice(start, selectionStart));
return match ? { start, end, query: match[1].toLocaleLowerCase() } : null;
}
/** Substring match, prefix matches first, then alphabetical. */
export function filterCommands(commands: AvailableCommand[], query: string): AvailableCommand[] {
return commands
.filter((command) => command.name.toLocaleLowerCase().includes(query))
.sort((a, b) => {
const aStarts = a.name.toLocaleLowerCase().startsWith(query);
const bStarts = b.name.toLocaleLowerCase().startsWith(query);
return aStarts === bStarts ? a.name.localeCompare(b.name) : aStarts ? -1 : 1;
});
}
/** Replace the token under the caret with `/name`, returning the new draft and
* where the caret lands. A trailing space is added only when the completion
* ends the draft. */
export function applyCommand(
value: string,
token: CommandToken,
name: string,
): { value: string; cursor: number } {
const before = value.slice(0, token.start);
const after = value.slice(token.end);
let replacement = `/${name}`;
let cursor = before.length + replacement.length;
if (after.length === 0) {
replacement += " ";
cursor++;
} else if (/^[ \t]/.test(after)) {
cursor++;
}
return { value: before + replacement + after, cursor };
}
// ---------------------------------------------------------------------------
// session config menu
// ---------------------------------------------------------------------------
/** Category order the config menu sorts by; anything else lands after. */
const CONFIG_CATEGORY_ORDER = ["model", "thought_level", "mode", "model_config"];
export function orderConfigOptions<T extends { category?: string | null }>(options: T[]): T[] {
return [...options].sort((a, b) => {
const rank = (category?: string | null) => CONFIG_CATEGORY_ORDER.indexOf(category ?? "");
const aRank = rank(a.category);
const bRank = rank(b.category);
return (aRank < 0 ? 99 : aRank) - (bRank < 0 ? 99 : bRank);
});
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,112 @@
// What a tool row shows below its head, derived from `rawInput`/`rawOutput`.
// The adapters emit a handful of recognisable payload shapes (a shell command,
// an MCP call, a search, a plain function call); anything else falls through to
// the raw JSON disclosure rather than being guessed at.
import type { AcpContentBlock, AcpToolCallContent, AcpToolCallLike } from "../../types.js";
/** One labelled `<pre>` block under a tool head. */
export interface ToolDetail {
label: string;
text: string;
}
export interface ToolPayload {
details: ToolDetail[];
/** A shell command's exit code, when the output carried one. */
exitCode: number | null;
raw: { input: string | null; output: string | null };
}
function printable(value: unknown): string | null {
if (value === undefined || value === null) return null;
if (typeof value === "string") return value;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function jsonPrintable(value: unknown): string | null {
if (value === undefined) return null;
try {
return JSON.stringify(value, null, 2) ?? null;
} catch {
return printable(value);
}
}
function record(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
/** Push a detail unless it is empty, or repeats text the content blocks above
* already show verbatim. */
function push(details: ToolDetail[], label: string, value: unknown, duplicates?: Set<string>): void {
const text = printable(value);
if (text !== null && text !== "" && !duplicates?.has(text)) details.push({ label, text });
}
/** The text of every `content` block, so a `rawOutput` echoing it is not shown
* a second time. */
function contentTexts(content: AcpToolCallContent[]): Set<string> {
return new Set(
content
.filter((block): block is Extract<AcpToolCallContent, { type: "content" }> => block.type === "content")
.filter((block) => block.content.type === "text")
.map((block) => (block.content as Extract<AcpContentBlock, { type: "text" }>).text),
);
}
export function toolPayload(tool: AcpToolCallLike, content: AcpToolCallContent[]): ToolPayload {
const details: ToolDetail[] = [];
let exitCode: number | null = null;
const input = record(tool.rawInput);
const texts = contentTexts(content);
if (input) {
if (typeof input.command === "string") {
details.push({ label: "Command", text: input.command });
push(details, "Working directory", input.cwd);
const result = record(tool.rawOutput);
if (result) {
push(details, "Output", result.formatted_output ?? result.stdout ?? result.output, texts);
if (typeof result.exit_code === "number") exitCode = result.exit_code;
}
} else if (typeof input.server === "string" && typeof input.tool === "string" && "arguments" in input) {
push(details, "Arguments", input.arguments);
const result = record(tool.rawOutput);
if (result) {
push(details, "Result", result.result, texts);
push(details, "Error", result.error);
}
} else if (typeof input.query === "string" && (tool.kind === "search" || input.type === "webSearch")) {
details.push({ label: "Query", text: input.query });
push(details, "Action", input.action);
} else if ("arguments" in input) {
push(details, "Arguments", input.arguments);
const result = record(tool.rawOutput);
if (result) push(details, "Result", result.output, texts);
}
}
return {
details,
exitCode,
raw: { input: jsonPrintable(tool.rawInput), output: jsonPrintable(tool.rawOutput) },
};
}
/** A `data:` URL for an inline image/audio block, or `null` when the mime type
* is absent or does not name that family — never a URL built from an
* unvalidated string. */
export function safeMediaSource(
mimeType: string | undefined,
data: string | undefined,
family: "image" | "audio",
): string | null {
if (!mimeType || !data || !new RegExp(`^${family}/[a-z0-9.+-]+$`, "i").test(mimeType)) return null;
return `data:${mimeType};base64,${data}`;
}
@@ -0,0 +1,216 @@
// The `WS /api/structured/:id` half of the pane (#246). Every connect — fresh
// or reconnect — resends the whole transcript rather than resuming a cursor
// (docs/STRUCTURED-VIEW.md), so a snapshot replaces state wholesale instead of
// merging into what came before.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { structuredWsUrl } from "../../routes.js";
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "../../reconnect.js";
import type {
ReducedState,
StructuredEvent,
TranscriptDelta,
TranscriptRow,
Uuid,
} from "../../types.js";
export type StructuredStatus = "connecting" | "open" | "reconnecting" | "closed" | "error";
/** Injectable so tests can drive the socket without a daemon — the same seam
* structured.ts used. */
export type StructuredSocketFactory = (sessionId: Uuid) => WebSocket;
const idleState = (): ReducedState => ({
turn: { responding: false, thinking: false, active_tool_calls: [] },
});
function applyDelta(rows: TranscriptRow[], delta: TranscriptDelta): TranscriptRow[] {
if (delta.type === "append") {
// A patch can arrive for a row this connection has never seen (its
// snapshot predates it); treating an unknown id as an append keeps the
// transcript complete rather than dropping the row.
const at = rows.findIndex((row) => row.id === delta.row.id);
return at === -1 ? [...rows, delta.row] : rows.map((row, i) => (i === at ? delta.row : row));
}
if (delta.type === "patch") {
const at = rows.findIndex((row) => row.id === delta.row.id);
return at === -1 ? [...rows, delta.row] : rows.map((row, i) => (i === at ? delta.row : row));
}
return rows.filter((row) => row.id !== delta.id);
}
export interface StructuredSession {
rows: TranscriptRow[];
state: ReducedState;
status: StructuredStatus;
/** A daemon-supplied close reason (code ≥ 4000), when there is one. */
detail: string | null;
/** The number of sockets this pane has opened, newest last. A submission
* that straddles a reconnect uses it to notice its acceptance may predate
* the current snapshot. */
socketGeneration: number;
/** Drop the socket and reconnect, so the next snapshot cannot predate a
* POST that has already been accepted. */
reconnect(): void;
}
export interface UseStructuredSessionOptions {
shouldReconnect?: (code: number) => boolean;
onClosed?: () => void;
socketFactory?: StructuredSocketFactory;
}
/** Application-defined close codes (daemon rejection) and the clean-close code
* — retrying either would only loop. */
function defaultShouldReconnect(code: number): boolean {
return code < 4000 && code !== 1000;
}
export function useStructuredSession(
sessionId: Uuid,
{
shouldReconnect = defaultShouldReconnect,
onClosed,
socketFactory = (id) => new WebSocket(structuredWsUrl(id)),
}: UseStructuredSessionOptions = {},
): StructuredSession {
const [rows, setRows] = useState<TranscriptRow[]>([]);
const [state, setState] = useState<ReducedState>(idleState);
const [status, setStatus] = useState<StructuredStatus>("connecting");
const [detail, setDetail] = useState<string | null>(null);
const [socketGeneration, setSocketGeneration] = useState(0);
// Read through refs so a changed callback never tears the socket down.
const shouldReconnectRef = useRef(shouldReconnect);
const onClosedRef = useRef(onClosed);
const factoryRef = useRef(socketFactory);
shouldReconnectRef.current = shouldReconnect;
onClosedRef.current = onClosed;
factoryRef.current = socketFactory;
const reconnectRef = useRef<() => void>(() => {});
useEffect(() => {
let disposed = false;
let socket: WebSocket | null = null;
let reconnectMs = INITIAL_RECONNECT_MS;
let timer: number | null = null;
const closeSocket = () => {
const existing = socket;
socket = null;
if (!existing) return;
existing.onopen = null;
existing.onmessage = null;
existing.onerror = null;
existing.onclose = null;
try {
existing.close();
} catch {
// ignore — an already-closing socket has nothing to close
}
};
const cancelTimer = () => {
if (timer === null) return;
window.clearTimeout(timer);
timer = null;
};
function connect(): void {
if (disposed) return;
cancelTimer();
closeSocket();
const current = factoryRef.current(sessionId);
socket = current;
setSocketGeneration((n) => n + 1);
current.onopen = () => {
if (disposed || socket !== current) return;
reconnectMs = INITIAL_RECONNECT_MS;
setDetail(null);
setStatus("open");
};
current.onmessage = (ev) => {
if (disposed || socket !== current) return;
if (typeof ev.data !== "string") return;
let msg: StructuredEvent;
try {
msg = JSON.parse(ev.data) as StructuredEvent;
} catch {
return; // not a structured event we understand
}
switch (msg.type) {
case "transcript_snapshot":
setRows([...msg.snapshot.rows]);
break;
case "reduced_state":
setState(msg.state);
break;
case "projection_update":
if (msg.transcript) {
const delta = msg.transcript;
setRows((previous) => applyDelta(previous, delta));
}
setState(msg.state);
break;
}
};
current.onerror = () => {
if (disposed || socket !== current) return;
setStatus("error");
};
current.onclose = (ev) => {
if (disposed || socket !== current) return;
socket = null;
const reason = ev.reason?.trim();
if (ev.code >= 4000 && reason) {
setDetail(reason);
setStatus("error");
return;
}
if (shouldReconnectRef.current(ev.code)) {
const delay = Math.min(reconnectMs, MAX_RECONNECT_MS);
setStatus("reconnecting");
timer = window.setTimeout(() => {
timer = null;
if (disposed) return;
reconnectMs = Math.min(reconnectMs * 2, MAX_RECONNECT_MS);
connect();
}, delay);
return;
}
setDetail(String(ev.code));
setStatus("closed");
onClosedRef.current?.();
};
}
reconnectRef.current = () => {
if (disposed) return;
setStatus("reconnecting");
connect();
};
setStatus("connecting");
connect();
return () => {
disposed = true;
reconnectRef.current = () => {};
cancelTimer();
closeSocket();
};
}, [sessionId]);
const reconnect = useCallback(() => reconnectRef.current(), []);
return useMemo(
() => ({ rows, state, status, detail, socketGeneration, reconnect }),
[rows, state, status, detail, socketGeneration, reconnect],
);
}
+134
View File
@@ -0,0 +1,134 @@
// React port of ../../attach.ts's `buildKeyBar` — the Termux-style special-key
// bar for the soft keyboard (Esc/Tab/Ctrl/Alt/cursor keys/symbols it hides
// behind a page switch). Ctrl/Alt are one-shot sticky modifiers: arming one
// folds into the very next key, tapped here or typed on the soft keyboard,
// then disarms.
import { useCallback, useRef, useState } from "react";
import { applyModsToChar, cursorSeq, tildeSeq } from "../../keys.js";
type Modifier = "ctrl" | "alt";
/** CSI final bytes: arrows A-D, Home `H`, End `F`. */
type CursorFinal = "A" | "B" | "C" | "D" | "H" | "F";
type KeyDef =
| { label: string; mod: Modifier }
| { label: string; cursor: CursorFinal }
| { label: string; tilde: number }
| { label: string; char: string }
| { label: string; seq: string };
/**
* Row-major into an 8-column / 2-row grid (#7):
*
* Esc Tab Home ↑ End PgUp - /
* Ctrl Alt ← ↓ → PgDn | ~
*/
const KEY_DEFS: KeyDef[] = [
{ label: "Esc", seq: "\x1b" },
{ label: "Tab", seq: "\t" },
{ label: "Home", cursor: "H" },
{ label: "↑", cursor: "A" },
{ label: "End", cursor: "F" },
{ label: "PgUp", tilde: 5 },
{ label: "-", char: "-" },
{ label: "/", char: "/" },
{ label: "Ctrl", mod: "ctrl" },
{ label: "Alt", mod: "alt" },
{ label: "←", cursor: "D" },
{ label: "↓", cursor: "B" },
{ label: "→", cursor: "C" },
{ label: "PgDn", tilde: 6 },
{ label: "|", char: "|" },
{ label: "~", char: "~" },
];
/**
* Pending Ctrl/Alt state plus `consume`, which folds them into a soft-keyboard
* character before it reaches the PTY — used by the terminal's own `onData`
* handler, not by this component's own button clicks.
*/
export function useKeyBarModifiers() {
const armedRef = useRef<{ ctrl: boolean; alt: boolean }>({ ctrl: false, alt: false });
const [, setTick] = useState(0);
const rerender = useCallback(() => setTick((n) => n + 1), []);
const setMod = useCallback(
(mod: Modifier, on: boolean) => {
armedRef.current[mod] = on;
rerender();
},
[rerender],
);
const clearMods = useCallback(() => {
if (!armedRef.current.ctrl && !armedRef.current.alt) return;
armedRef.current = { ctrl: false, alt: false };
rerender();
}, [rerender]);
const consume = useCallback((data: string): string => {
const { ctrl, alt } = armedRef.current;
if ((ctrl || alt) && data.length === 1) {
const out = applyModsToChar(data, ctrl, alt);
armedRef.current = { ctrl: false, alt: false };
return out;
}
return data;
}, []);
return { armed: armedRef.current, setMod, clearMods, consume };
}
export interface KeyBarProps {
armed: { ctrl: boolean; alt: boolean };
onToggleMod(mod: Modifier): void;
onClearMods(): void;
onSend(data: string): void;
}
export function KeyBar({ armed, onToggleMod, onClearMods, onSend }: KeyBarProps) {
return (
<div
role="toolbar"
aria-label="Special keys"
className="flex flex-wrap gap-1 border-t border-white/10 bg-black/20 p-1"
>
{KEY_DEFS.map((def) => {
if ("mod" in def) {
return (
<button
key={def.label}
type="button"
aria-pressed={armed[def.mod]}
aria-label={`${def.label} modifier`}
className="min-w-8 rounded px-2 py-1 text-xs aria-pressed:bg-emerald-500/40"
onClick={() => onToggleMod(def.mod)}
>
{def.label}
</button>
);
}
const bytes = () => {
if ("cursor" in def) return cursorSeq(def.cursor, armed.ctrl, armed.alt);
if ("tilde" in def) return tildeSeq(def.tilde, armed.ctrl, armed.alt);
if ("char" in def) return applyModsToChar(def.char, armed.ctrl, armed.alt);
return def.seq;
};
return (
<button
key={def.label}
type="button"
className="min-w-8 rounded px-2 py-1 text-xs"
onClick={() => {
onSend(bytes());
onClearMods();
}}
>
{def.label}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,363 @@
// @vitest-environment happy-dom
import { cleanup, render } from "@testing-library/react";
import { createRef } from "react";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "../../reconnect.js";
import { attachWsUrl } from "../../routes.js";
import type { ClientControl } from "../../types.js";
class FakeTerminal {
static instances: FakeTerminal[] = [];
cols = 80;
rows = 24;
element: HTMLElement | undefined = undefined;
textarea: HTMLTextAreaElement | null = null;
disposed = false;
focus = vi.fn();
private dataCb: ((d: string) => void) | null = null;
constructor(public opts: unknown) {
FakeTerminal.instances.push(this);
}
loadAddon(): void {}
open(container: HTMLElement): void {
container.appendChild(document.createElement("div"));
}
onData(cb: (d: string) => void) {
this.dataCb = cb;
return { dispose: vi.fn() };
}
emitData(d: string): void {
this.dataCb?.(d);
}
onResize() {
return { dispose: vi.fn() };
}
onSelectionChange() {
return { dispose: vi.fn() };
}
write(_data: Uint8Array, cb?: () => void): void {
cb?.();
}
scrollToBottom(): void {}
resize(cols: number, rows: number): void {
this.cols = cols;
this.rows = rows;
}
clearTextureAtlas(): void {}
getSelection(): string {
return "";
}
dispose(): void {
this.disposed = true;
}
}
class FakeFitAddon {
fit = vi.fn();
}
class FakeWebglAddon {
static instances: FakeWebglAddon[] = [];
disposed = false;
private lossCb: (() => void) | null = null;
constructor() {
FakeWebglAddon.instances.push(this);
}
onContextLoss(cb: () => void): void {
this.lossCb = cb;
}
triggerLoss(): void {
this.lossCb?.();
}
dispose(): void {
this.disposed = true;
}
}
const policyEnabled = vi.fn(() => true);
const policyNoteLoss = vi.fn();
vi.mock("@xterm/xterm", () => ({ Terminal: FakeTerminal }));
vi.mock("@xterm/addon-fit", () => ({ FitAddon: FakeFitAddon }));
vi.mock("@xterm/addon-webgl", () => ({ WebglAddon: FakeWebglAddon }));
vi.mock("../../webglRenderer.js", () => ({
WebglPolicy: vi.fn().mockImplementation(() => ({
enabled: policyEnabled,
noteContextLoss: policyNoteLoss,
})),
}));
class FakeWebSocket {
static OPEN = 1;
static CONNECTING = 0;
static CLOSING = 2;
static CLOSED = 3;
static instances: FakeWebSocket[] = [];
readyState = FakeWebSocket.CONNECTING;
binaryType = "";
sent: (string | ArrayBufferView)[] = [];
onopen: (() => void) | null = null;
onmessage: ((ev: { data: unknown }) => void) | null = null;
onerror: (() => void) | null = null;
onclose: ((ev: { code: number; reason: string }) => void) | null = null;
constructor(public url: string) {
FakeWebSocket.instances.push(this);
}
send(data: string | ArrayBufferView): void {
this.sent.push(data);
}
close(): void {
this.readyState = FakeWebSocket.CLOSED;
}
open(): void {
this.readyState = FakeWebSocket.OPEN;
this.onopen?.();
}
drop(code = 1006, reason = ""): void {
this.readyState = FakeWebSocket.CLOSED;
this.onclose?.({ code, reason });
}
lastControl(): ClientControl {
return JSON.parse(this.sent.at(-1) as string) as ClientControl;
}
}
beforeAll(() => {
vi.stubGlobal(
"ResizeObserver",
class {
observe(): void {}
disconnect(): void {}
unobserve(): void {}
},
);
});
beforeEach(() => {
FakeTerminal.instances = [];
FakeWebglAddon.instances = [];
FakeWebSocket.instances = [];
policyEnabled.mockReturnValue(true);
policyEnabled.mockClear();
policyNoteLoss.mockClear();
vi.stubGlobal("WebSocket", FakeWebSocket);
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
// Imported after the mocks above so it picks up the mocked xterm/webgl modules.
const { TerminalPane } = await import("./TerminalPane.js");
describe("TerminalPane lifecycle", () => {
it("opens an xterm terminal and connects to the session's attach socket on mount", () => {
render(<TerminalPane sessionId="s1" />);
expect(FakeTerminal.instances).toHaveLength(1);
expect(FakeWebSocket.instances).toHaveLength(1);
expect(FakeWebSocket.instances[0].url).toBe(attachWsUrl("s1"));
});
it("sends the terminal size once the socket opens", () => {
render(<TerminalPane sessionId="s1" />);
const sock = FakeWebSocket.instances[0];
sock.open();
expect(sock.lastControl()).toEqual({ type: "Resize", cols: 80, rows: 24 });
});
it("disposes the terminal and closes the socket on unmount", () => {
const { unmount } = render(<TerminalPane sessionId="s1" />);
const term = FakeTerminal.instances[0];
const sock = FakeWebSocket.instances[0];
unmount();
expect(term.disposed).toBe(true);
expect(sock.readyState).toBe(FakeWebSocket.CLOSED);
});
it("reconnects to a fresh socket for a new sessionId", () => {
const { rerender } = render(<TerminalPane sessionId="s1" />);
rerender(<TerminalPane sessionId="s2" />);
expect(FakeWebSocket.instances).toHaveLength(2);
expect(FakeWebSocket.instances[1].url).toBe(attachWsUrl("s2"));
expect(FakeTerminal.instances[0].disposed).toBe(true);
});
});
describe("TerminalPane reconnect backoff", () => {
it("retries a dropped socket, doubling the delay from 500ms up to a 10s cap", () => {
vi.useFakeTimers();
render(<TerminalPane sessionId="s1" />);
const drop = () => FakeWebSocket.instances.at(-1)!.drop(1006, "");
const expectStillWaiting = (ms: number, countBefore: number) => {
vi.advanceTimersByTime(ms - 1);
expect(FakeWebSocket.instances).toHaveLength(countBefore);
vi.advanceTimersByTime(1);
expect(FakeWebSocket.instances).toHaveLength(countBefore + 1);
};
expect(FakeWebSocket.instances).toHaveLength(1);
drop();
expectStillWaiting(INITIAL_RECONNECT_MS, 1); // 500ms -> #2
drop();
expectStillWaiting(INITIAL_RECONNECT_MS * 2, 2); // 1000ms -> #3
drop();
expectStillWaiting(INITIAL_RECONNECT_MS * 4, 3); // 2000ms -> #4
drop();
expectStillWaiting(INITIAL_RECONNECT_MS * 8, 4); // 4000ms -> #5
drop();
expectStillWaiting(INITIAL_RECONNECT_MS * 16, 5); // 8000ms -> #6
drop();
expectStillWaiting(MAX_RECONNECT_MS, 6); // capped at 10s -> #7
drop();
expectStillWaiting(MAX_RECONNECT_MS, 7); // stays capped -> #8
});
it("resets the backoff once a reconnected socket opens", () => {
vi.useFakeTimers();
render(<TerminalPane sessionId="s1" />);
FakeWebSocket.instances[0].drop(1006, "");
vi.advanceTimersByTime(INITIAL_RECONNECT_MS);
FakeWebSocket.instances[1].open();
FakeWebSocket.instances[1].drop(1006, "");
vi.advanceTimersByTime(INITIAL_RECONNECT_MS - 1);
expect(FakeWebSocket.instances).toHaveLength(2);
vi.advanceTimersByTime(1);
expect(FakeWebSocket.instances).toHaveLength(3);
});
it("does not retry a clean close, and reports it closed", () => {
vi.useFakeTimers();
const onClosed = vi.fn();
render(<TerminalPane sessionId="s1" onClosed={onClosed} />);
FakeWebSocket.instances[0].drop(1000, "");
vi.advanceTimersByTime(MAX_RECONNECT_MS * 2);
expect(FakeWebSocket.instances).toHaveLength(1);
expect(onClosed).toHaveBeenCalledOnce();
});
it("does not retry an application-level rejection, and writes it into the terminal", () => {
vi.useFakeTimers();
const onClosed = vi.fn();
render(<TerminalPane sessionId="s1" onClosed={onClosed} />);
const term = FakeTerminal.instances[0];
const writeSpy = vi.spyOn(term, "write");
FakeWebSocket.instances[0].drop(4004, "unknown session");
vi.advanceTimersByTime(MAX_RECONNECT_MS * 2);
expect(FakeWebSocket.instances).toHaveLength(1);
expect(onClosed).not.toHaveBeenCalled();
expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining("unknown session"));
});
it("honors a caller-supplied shouldReconnect policy", () => {
vi.useFakeTimers();
const onClosed = vi.fn();
render(<TerminalPane sessionId="s1" shouldReconnect={() => false} onClosed={onClosed} />);
FakeWebSocket.instances[0].drop(1006, "");
vi.advanceTimersByTime(MAX_RECONNECT_MS * 2);
expect(FakeWebSocket.instances).toHaveLength(1);
expect(onClosed).toHaveBeenCalledOnce();
});
});
describe("TerminalPane WebGL context budget", () => {
it("acquires a WebGL context on mount, shared policy allowing", () => {
render(<TerminalPane sessionId="s1" />);
expect(policyEnabled).toHaveBeenCalled();
expect(FakeWebglAddon.instances).toHaveLength(1);
});
it("never creates a context once the shared budget is exhausted", () => {
policyEnabled.mockReturnValue(false);
render(<TerminalPane sessionId="s1" />);
expect(FakeWebglAddon.instances).toHaveLength(0);
});
it("releases its context on unmount, handing the budget back", () => {
const { unmount } = render(<TerminalPane sessionId="s1" />);
const addon = FakeWebglAddon.instances[0];
unmount();
expect(addon.disposed).toBe(true);
});
it("counts a context loss and re-acquires while still mounted", () => {
render(<TerminalPane sessionId="s1" />);
const first = FakeWebglAddon.instances[0];
first.triggerLoss();
expect(policyNoteLoss).toHaveBeenCalledOnce();
expect(first.disposed).toBe(true);
expect(FakeWebglAddon.instances).toHaveLength(2);
});
it("does not re-acquire a lost context once the budget is spent", () => {
render(<TerminalPane sessionId="s1" />);
const first = FakeWebglAddon.instances[0];
policyEnabled.mockReturnValue(false);
first.triggerLoss();
expect(FakeWebglAddon.instances).toHaveLength(1);
});
});
describe("TerminalPane imperative handle", () => {
it("sendInput writes bytes to the PTY only once the socket is open", () => {
const ref = createRef<import("./TerminalPane.js").TerminalPaneHandle>();
render(<TerminalPane sessionId="s1" ref={ref} />);
const sock = FakeWebSocket.instances[0];
ref.current!.sendInput("nope");
expect(sock.sent).toHaveLength(0);
sock.open();
sock.sent.length = 0; // drop the resize control frame from open()
ref.current!.sendInput("hi");
expect(sock.sent).toHaveLength(1);
expect(new TextDecoder().decode(sock.sent[0] as Uint8Array)).toBe("hi");
});
it("focusTerminal focuses the underlying xterm instance", () => {
const ref = createRef<import("./TerminalPane.js").TerminalPaneHandle>();
render(<TerminalPane sessionId="s1" ref={ref} />);
ref.current!.focusTerminal();
expect(FakeTerminal.instances[0].focus).toHaveBeenCalledOnce();
});
it("routes typed keystrokes through to the socket", () => {
render(<TerminalPane sessionId="s1" />);
const sock = FakeWebSocket.instances[0];
sock.open();
sock.sent.length = 0;
FakeTerminal.instances[0].emitData("q");
expect(new TextDecoder().decode(sock.sent[0] as Uint8Array)).toBe("q");
});
});
@@ -0,0 +1,362 @@
// React port of ../../attach.ts: an xterm terminal bound to the daemon's
// attach WebSocket, plus ../../webglRenderer.ts's shared GPU-context budget
// and ../../reconnect.ts's backoff schedule. Pane chrome (title, disconnect
// button, sessions drawer) stays out of scope here — this is the thin
// ref-based xterm wrapper the issue asked for; a header composes around it.
import { FitAddon } from "@xterm/addon-fit";
import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "../../reconnect.js";
import { attachWsUrl } from "../../routes.js";
import type { ClientControl, Uuid } from "../../types.js";
import { WebglPolicy } from "../../webglRenderer.js";
import { KeyBar, useKeyBarModifiers } from "./KeyBar.js";
/** One budget for the whole page — every mounted `TerminalPane` shares it, the
* same reasoning as attach.ts's own module-level policy. */
const webglPolicy = new WebglPolicy();
export type TerminalStatus = "connecting" | "open" | "reconnecting" | "closed" | "error";
/** Application-defined close codes (daemon rejection) and the clean-close
* code — retrying either would only loop. Mirrors reconnect.ts's
* `shouldRetryClose`, minus the catalog lookup a standalone pane doesn't have. */
function defaultShouldReconnect(code: number): boolean {
return code < 4000 && code !== 1000;
}
export interface TerminalPaneProps {
sessionId: Uuid;
/** Grabs the keyboard once the socket opens. A sidebar preview passes `false`. */
focus?: boolean;
/** Asked on every close: should the socket be retried? */
shouldReconnect?: (code: number) => boolean;
/** The socket closed for good (retry declined, or the daemon rejected the
* session) — not called on an ordinary unmount. */
onClosed?: () => void;
/** Connection status changes, for a header/status badge to render. */
onStatusChange?: (status: TerminalStatus, detail?: string) => void;
className?: string;
}
export interface TerminalPaneHandle {
focusTerminal(): void;
sendInput(data: string): void;
}
export const TerminalPane = forwardRef<TerminalPaneHandle, TerminalPaneProps>(
function TerminalPane(
{ sessionId, focus = true, shouldReconnect = defaultShouldReconnect, onClosed, onStatusChange, className },
ref,
) {
const bodyRef = useRef<HTMLDivElement | null>(null);
const sendInputRef = useRef<(data: string) => void>(() => {});
const focusTerminalRef = useRef<() => void>(() => {});
const keyBar = useKeyBarModifiers();
const [keybarActive, setKeybarActive] = useState(false);
useImperativeHandle(
ref,
() => ({
focusTerminal: () => focusTerminalRef.current(),
sendInput: (data: string) => sendInputRef.current(data),
}),
[],
);
useEffect(() => {
const body = bodyRef.current;
if (!body) return;
const term = new Terminal({
cursorBlink: true,
fontFamily:
'"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
fontSize: 13,
convertEol: false,
theme: {
background: "#121419",
foreground: "#e6e8ec",
cursor: "#5fe3a1",
cursorAccent: "#121419",
selectionBackground: "#2b6b4f",
},
scrollback: 5000,
allowProposedApi: true,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(body);
let webgl: WebglAddon | null = null;
let dprMedia: MediaQueryList | null = null;
const onDprChange = () => {
try {
term.clearTextureAtlas();
} catch {
// ignore — nothing to rebuild if the renderer already went away
}
watchDpr();
};
function watchDpr(): void {
dprMedia?.removeEventListener("change", onDprChange);
dprMedia = null;
try {
dprMedia = window.matchMedia?.(`(resolution: ${window.devicePixelRatio}dppx)`) ?? null;
dprMedia?.addEventListener("change", onDprChange);
} catch {
// ignore — a browser without `resolution` media queries just keeps
// the atlas it has
}
}
function releaseWebgl(): void {
dprMedia?.removeEventListener("change", onDprChange);
dprMedia = null;
if (!webgl) return;
const addon = webgl;
webgl = null;
try {
addon.dispose();
} catch {
// ignore — a lost context can throw on the way out
}
}
function acquireWebgl(): void {
if (webgl || disposed || !webglPolicy.enabled()) return;
try {
const addon = new WebglAddon();
addon.onContextLoss(() => {
webglPolicy.noteContextLoss();
releaseWebgl();
if (mounted) acquireWebgl();
});
term.loadAddon(addon);
webgl = addon;
watchDpr();
} catch {
webglPolicy.noteContextLoss();
webgl = null;
}
}
let mounted = false;
const tryFit = () => {
if (!mounted) return;
try {
const el2 = term.element;
const parent = el2?.parentElement;
const core = (
term as unknown as {
_core?: { _renderService?: { dimensions?: { css?: { cell?: { width: number; height: number } } } } };
}
)._core;
const cell = core?._renderService?.dimensions?.css?.cell;
if (!el2 || !parent || !cell || !cell.width || !cell.height) {
fit.fit();
return;
}
const vp = el2.querySelector(".xterm-viewport") as HTMLElement | null;
let availW: number;
let availH: number;
if (vp && vp.clientWidth > 0) {
availW = vp.clientWidth;
availH = vp.clientHeight;
} else {
const ps = getComputedStyle(parent);
const ts = getComputedStyle(el2);
availW = parseInt(ps.width) - (parseInt(ts.paddingLeft) + parseInt(ts.paddingRight) || 0);
availH = parseInt(ps.height) - (parseInt(ts.paddingTop) + parseInt(ts.paddingBottom) || 0);
}
const cols = Math.max(2, Math.floor(availW / cell.width));
const rows = Math.max(1, Math.floor(availH / cell.height));
if (cols !== term.cols || rows !== term.rows) term.resize(cols, rows);
} catch {
try {
fit.fit();
} catch {
// ignore — fit fails if the container is detached or zero-sized
}
}
};
let ws: WebSocket;
let reconnectMs = INITIAL_RECONNECT_MS;
let reconnectTimer: number | null = null;
let awaitingReplay = true;
let disposed = false;
const sendControl = (msg: ClientControl) => {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify(msg));
};
const sendInput = (data: string) => {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(new TextEncoder().encode(data));
};
sendInputRef.current = sendInput;
focusTerminalRef.current = () => term.focus();
const onResize = () => {
tryFit();
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
};
function connect(): void {
awaitingReplay = true;
ws = new WebSocket(attachWsUrl(sessionId));
ws.binaryType = "arraybuffer";
ws.onopen = () => {
reconnectMs = INITIAL_RECONNECT_MS;
onStatusChange?.("open");
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
if (mounted && focus) term.focus();
};
ws.onmessage = (ev) => {
if (typeof ev.data === "string") {
try {
JSON.parse(ev.data) as ClientControl;
} catch {
// ignore unknown control frames
}
return;
}
if (ev.data instanceof ArrayBuffer) {
const replay = awaitingReplay;
awaitingReplay = false;
term.write(new Uint8Array(ev.data), () => {
if (replay) term.scrollToBottom();
});
} else if (ev.data instanceof Blob) {
void ev.data.arrayBuffer().then((b) => {
const replay = awaitingReplay;
awaitingReplay = false;
term.write(new Uint8Array(b), () => {
if (replay) term.scrollToBottom();
});
});
}
};
ws.onerror = () => {
onStatusChange?.("error");
};
ws.onclose = (ev) => {
const reason = ev.reason?.trim();
if (ev.code >= 4000 && reason && !disposed) {
onStatusChange?.("error", reason);
term.write(`\r\n\x1b[31m⚠ cannot attach: ${reason}\x1b[0m\r\n`);
return;
}
if (disposed) return;
if (shouldReconnect(ev.code)) {
const delay = Math.min(reconnectMs, MAX_RECONNECT_MS);
onStatusChange?.("reconnecting");
reconnectTimer = window.setTimeout(() => {
reconnectTimer = null;
reconnectMs = Math.min(reconnectMs * 2, MAX_RECONNECT_MS);
connect();
}, delay);
return;
}
onStatusChange?.("closed", String(ev.code));
onClosed?.();
};
}
onStatusChange?.("connecting");
connect();
const dataDisp = term.onData((data) => {
sendInput(keyBar.consume(data));
});
const resizeDisp = term.onResize(({ cols, rows }) => {
sendControl({ type: "Resize", cols, rows });
});
const selectionDisp = term.onSelectionChange(() => {
const text = term.getSelection();
if (text) void navigator.clipboard?.writeText(text).catch(() => {});
});
const ta = term.textarea;
const onTaFocus = () => {
setKeybarActive(true);
requestAnimationFrame(tryFit);
};
const onTaBlur = () => {
setKeybarActive(false);
requestAnimationFrame(tryFit);
};
ta?.addEventListener("focus", onTaFocus);
ta?.addEventListener("blur", onTaBlur);
window.addEventListener("resize", onResize);
const visual = window.visualViewport ?? null;
visual?.addEventListener("resize", onResize);
const ro = new ResizeObserver(() => tryFit());
ro.observe(body);
document.fonts?.ready?.then(() => tryFit()).catch(() => {});
mounted = true;
acquireWebgl();
requestAnimationFrame(() => {
tryFit();
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
if (focus && ws.readyState === WebSocket.OPEN) term.focus();
});
return () => {
disposed = true;
mounted = false;
if (reconnectTimer !== null) {
window.clearTimeout(reconnectTimer);
reconnectTimer = null;
}
ro.disconnect();
window.removeEventListener("resize", onResize);
visual?.removeEventListener("resize", onResize);
ta?.removeEventListener("focus", onTaFocus);
ta?.removeEventListener("blur", onTaBlur);
dataDisp.dispose();
resizeDisp.dispose();
selectionDisp.dispose();
releaseWebgl();
try {
ws.close();
} catch {
// ignore
}
term.dispose();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- reconnecting
// a session change is a fresh connection, not a prop-driven re-render
}, [sessionId]);
return (
<div className={className ? `flex h-full flex-col ${className}` : "flex h-full flex-col"}>
<div ref={bodyRef} className="min-h-0 flex-1" />
{keybarActive && (
<KeyBar
armed={keyBar.armed}
onToggleMod={(mod) => keyBar.setMod(mod, !keyBar.armed[mod])}
onClearMods={keyBar.clearMods}
onSend={(data) => sendInputRef.current(data)}
/>
)}
</div>
);
},
);
+13 -9
View File
@@ -1,4 +1,6 @@
// @vitest-environment happy-dom
import "@testing-library/jest-dom/vitest";
import { within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ConnectionPool } from "./connections.js";
@@ -15,6 +17,7 @@ function fakeConn(kind: string, id: string) {
el.tabIndex = -1; // focusable via .focus(), like a real input/terminal
el.dataset.kind = kind;
el.dataset.session = id;
el.dataset.testid = id; // lets a scoped `within(...).getByTestId` find this fake connection
return {
sessionId: id,
kind,
@@ -219,8 +222,8 @@ describe("ConnectionPool: view resolution races", () => {
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");
expect(within(hidden).getByTestId(B)).toBeInTheDocument();
expect(within(hidden).getByTestId(A)).toHaveAttribute("data-kind", "structured");
});
it("does not replace a connection whose resolved view already matches", () => {
@@ -271,8 +274,9 @@ describe("ConnectionPool: view resolution races", () => {
document.body.appendChild(pane);
pool.mount(A, pane);
pool.setFocus(A);
(pane.firstElementChild as HTMLElement).focus();
expect(document.activeElement).toBe(pane.firstElementChild);
const conn = within(pane).getByTestId(A);
conn.focus();
expect(conn).toHaveFocus();
mounts.length = 0;
socket.push([session(A, "Structured")]);
@@ -294,12 +298,12 @@ describe("ConnectionPool: view resolution races", () => {
document.body.appendChild(pane);
pool.mount(A, pane);
pool.setFocus(A);
(pane.firstElementChild as HTMLElement).focus();
within(pane).getByTestId(A).focus();
const sidebarInput = document.createElement("input");
document.body.appendChild(sidebarInput);
sidebarInput.focus();
expect(document.activeElement).toBe(sidebarInput);
expect(sidebarInput).toHaveFocus();
mounts.length = 0;
socket.push([session(A, "Structured")]);
@@ -319,9 +323,9 @@ describe("ConnectionPool: view resolution races", () => {
pool.mount(A, pane);
pool.setFocus(A);
const textarea = document.createElement("textarea");
(pane.firstElementChild as HTMLElement).appendChild(textarea);
within(pane).getByTestId(A).appendChild(textarea);
textarea.focus();
expect(document.activeElement).toBe(textarea);
expect(textarea).toHaveFocus();
mounts.length = 0;
socket.push([session(A, "Structured")]);
@@ -339,7 +343,7 @@ describe("ConnectionPool: view resolution races", () => {
pool.mount(A, pane);
pool.setFocus(A);
(document.activeElement as HTMLElement | null)?.blur();
expect(document.activeElement).toBe(document.body);
expect(document.body).toHaveFocus();
mounts.length = 0;
socket.push([session(A, "Structured")]);
+14 -14
View File
@@ -1,5 +1,7 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { fireEvent, screen } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { closeContextMenu, openContextMenu } from "./contextMenu.js";
@@ -9,7 +11,7 @@ afterEach(() => {
});
function labels(): string[] {
return [...document.querySelectorAll(".context-menu-item")].map((b) => b.textContent ?? "");
return screen.getAllByRole("menuitem").map((b) => b.textContent ?? "");
}
describe("context menu (#125)", () => {
@@ -19,40 +21,38 @@ describe("context menu (#125)", () => {
{ label: "Kill session", onSelect: () => {}, danger: true },
]);
expect(labels()).toEqual(["Rename…", "Kill session"]);
expect(document.querySelector(".context-menu-item.is-danger")?.textContent).toBe(
"Kill session",
);
expect(screen.getByRole("menuitem", { name: "Kill session" })).toHaveClass("is-danger");
});
it("runs the selected entry and closes", () => {
const chosen = vi.fn();
openContextMenu(0, 0, [{ label: "Rename…", onSelect: chosen }]);
(document.querySelector(".context-menu-item") as HTMLButtonElement).click();
fireEvent.click(screen.getByRole("menuitem", { name: "Rename…" }));
expect(chosen).toHaveBeenCalledOnce();
expect(document.querySelector(".context-menu")).toBeNull();
expect(screen.queryByRole("menu")).toBeNull();
});
it("opening a second menu replaces the first", () => {
openContextMenu(0, 0, [{ label: "first", onSelect: () => {} }]);
openContextMenu(0, 0, [{ label: "second", onSelect: () => {} }]);
expect(document.querySelectorAll(".context-menu")).toHaveLength(1);
expect(screen.getAllByRole("menu")).toHaveLength(1);
expect(labels()).toEqual(["second"]);
});
it("closes on Escape and on a press outside it", () => {
openContextMenu(0, 0, [{ label: "Rename…", onSelect: () => {} }]);
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
expect(document.querySelector(".context-menu")).toBeNull();
fireEvent.keyDown(window, { key: "Escape" });
expect(screen.queryByRole("menu")).toBeNull();
openContextMenu(0, 0, [{ label: "Rename…", onSelect: () => {} }]);
document.body.dispatchEvent(new Event("pointerdown", { bubbles: true }));
expect(document.querySelector(".context-menu")).toBeNull();
fireEvent.pointerDown(document.body);
expect(screen.queryByRole("menu")).toBeNull();
});
it("stays open for a press inside itself", () => {
openContextMenu(0, 0, [{ label: "Rename…", onSelect: () => {} }]);
const menu = document.querySelector(".context-menu") as HTMLElement;
menu.dispatchEvent(new Event("pointerdown", { bubbles: true }));
expect(document.querySelector(".context-menu")).not.toBeNull();
const menu = screen.getByRole("menu");
fireEvent.pointerDown(menu);
expect(screen.queryByRole("menu")).not.toBeNull();
});
});
+22 -20
View File
@@ -1,4 +1,6 @@
// @vitest-environment happy-dom
import "@testing-library/jest-dom/vitest";
import { fireEvent, screen } from "@testing-library/react";
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
vi.mock("./api.js", () => ({ listDir: vi.fn() }));
@@ -13,27 +15,27 @@ let nextField: HTMLInputElement;
let docKeydown: ReturnType<typeof vi.fn>;
let ac: DirAutocompleteHandle | undefined;
function popup(): HTMLUListElement {
return document.querySelector(".dir-ac") as HTMLUListElement;
/** The dropdown stays in the DOM (with `hidden` toggling) rather than being
* added/removed, so queries need `{ hidden: true }` to see it while closed. */
function popup(): HTMLElement {
return screen.getByRole("listbox", { hidden: true });
}
function items(): HTMLElement[] {
return [...popup().querySelectorAll<HTMLElement>(".dir-ac-item")];
return screen.getAllByRole("option", { hidden: true });
}
async function type(value: string): Promise<void> {
input.focus();
input.value = value;
input.dispatchEvent(new Event("input", { bubbles: true }));
fireEvent.input(input, { target: { value } });
await vi.advanceTimersByTimeAsync(100); // past the fetch debounce
}
/** Dispatch a cancelable, bubbling keydown on the input; report whether the
/** Fire a cancelable, bubbling keydown on the input; report whether the
* default browser behaviour (focus move, form submit) was suppressed. */
function press(key: string): { defaultPrevented: boolean } {
const evt = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
input.dispatchEvent(evt);
return { defaultPrevented: evt.defaultPrevented };
const notPrevented = fireEvent.keyDown(input, { key });
return { defaultPrevented: !notPrevented };
}
beforeEach(() => {
@@ -57,26 +59,26 @@ describe("dirAutocomplete dismissal", () => {
it("Escape closes the popup, keeps the typed value and does not propagate", async () => {
ac = attachDirAutocomplete(input);
await type("/tmp/pro");
expect(popup().hidden).toBe(false);
expect(popup()).toBeVisible();
const { defaultPrevented } = press("Escape");
expect(defaultPrevented).toBe(true);
expect(popup().hidden).toBe(true);
expect(popup()).not.toBeVisible();
expect(input.value).toBe("/tmp/pro");
expect(document.activeElement).toBe(input);
expect(input).toHaveFocus();
expect(docKeydown).not.toHaveBeenCalled(); // a draft pane's Escape handler must not fire
});
it("blur dismisses the popup", async () => {
ac = attachDirAutocomplete(input);
await type("/tmp/pro");
expect(popup().hidden).toBe(false);
expect(popup()).toBeVisible();
input.blur();
await vi.advanceTimersByTimeAsync(150);
expect(popup().hidden).toBe(true);
expect(popup()).not.toBeVisible();
});
it("does not reopen on refocus until the operator types again", async () => {
@@ -87,10 +89,10 @@ describe("dirAutocomplete dismissal", () => {
input.focus();
await vi.advanceTimersByTimeAsync(150);
expect(popup().hidden).toBe(true);
expect(popup()).not.toBeVisible();
await type("/tmp/ot");
expect(popup().hidden).toBe(false);
expect(popup()).toBeVisible();
});
});
@@ -104,13 +106,13 @@ describe("dirAutocomplete keyboard model", () => {
expect(input.value).toBe("/tmp/proj-a/");
expect(defaultPrevented).toBe(false); // native tab navigation proceeds
expect(document.activeElement).toBe(nextField); // focus was not yanked back
expect(nextField).toHaveFocus(); // focus was not yanked back
});
it("Tab with nothing highlighted just moves on", async () => {
ac = attachDirAutocomplete(input);
await type("/tmp/nope-matches-nothing");
expect(popup().hidden).toBe(true);
expect(popup()).not.toBeVisible();
const { defaultPrevented } = press("Tab");
@@ -123,7 +125,7 @@ describe("dirAutocomplete keyboard model", () => {
await type("/tmp/");
press("ArrowDown");
expect(items()[1].classList.contains("is-active")).toBe(true);
expect(items()[1]).toHaveAttribute("aria-selected", "true");
const enter = press("Enter");
expect(enter.defaultPrevented).toBe(true); // no form submit
@@ -137,7 +139,7 @@ describe("dirAutocomplete opening", () => {
input.focus();
await vi.advanceTimersByTimeAsync(50);
expect(popup().hidden).toBe(false);
expect(popup()).toBeVisible();
expect(listDirMock).toHaveBeenCalled();
});
});
+5 -4
View File
@@ -1,5 +1,6 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from "vitest";
import { fireEvent } from "@testing-library/react";
import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "./catalog.js";
import { installDrawer, type Drawer } from "./drawer.js";
@@ -23,25 +24,25 @@ describe("mobile drawer", () => {
it("closes when a session row is tapped", () => {
const { shell, sidebar } = mount();
(sidebar.querySelector(".tree-leaf") as HTMLElement).click();
fireEvent.click(sidebar.querySelector(".tree-leaf") as HTMLElement);
expect(shell.classList.contains("drawer-open")).toBe(false);
});
it("closes when a spawn is requested", () => {
const { shell } = mount();
document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT));
fireEvent(document, new CustomEvent(OPEN_SPAWN_EVENT));
expect(shell.classList.contains("drawer-open")).toBe(false);
});
it("closes when spawn similar is requested", () => {
const { shell } = mount();
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: {} }));
fireEvent(document, new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: {} }));
expect(shell.classList.contains("drawer-open")).toBe(false);
});
it("leaves the backdrop click closing it", () => {
const { shell } = mount();
(shell.lastElementChild as HTMLElement).click();
fireEvent.click(shell.lastElementChild as HTMLElement);
expect(shell.classList.contains("drawer-open")).toBe(false);
});
});
+24 -24
View File
@@ -1,5 +1,6 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { fireEvent, screen } from "@testing-library/react";
import { defineCommands, keyCaps, publishCommands, publishedCommands } from "./commands.js";
import { closeHelp, groupedCommands, installHelpKeybind, isHelpOpen, openHelp } from "./help.js";
@@ -14,11 +15,15 @@ afterEach(() => {
});
function press(key: string, init: KeyboardEventInit = {}): void {
window.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, ...init }));
fireEvent.keyDown(window, { key, ...init });
}
function rows(): string[] {
return [...document.querySelectorAll(".help-row")].map((r) =>
function overlay(): HTMLElement {
return screen.getByRole("dialog", { name: "Keyboard shortcuts" });
}
function rows(root: HTMLElement): string[] {
return [...root.querySelectorAll(".help-row")].map((r) =>
[...r.querySelectorAll("kbd")].map((k) => k.textContent).join(" "),
);
}
@@ -26,22 +31,23 @@ function rows(): string[] {
describe("help overlay", () => {
it("renders exactly the commands the registry has bound, grouped", () => {
openHelp();
const dialog = overlay();
const groups = groupedCommands();
expect([...document.querySelectorAll(".help-group-title")].map((h) => h.textContent)).toEqual(
expect([...dialog.querySelectorAll(".help-group-title")].map((h) => h.textContent)).toEqual(
groups.map((g) => g.title),
);
expect(rows()).toEqual(
groups.flatMap((g) => g.items.map((c) => keyCaps(c.keys[0]).join(" "))),
);
expect([...document.querySelectorAll(".help-desc")].map((d) => d.textContent)).toEqual(
expect(rows(dialog)).toEqual(groups.flatMap((g) => g.items.map((c) => keyCaps(c.keys[0]).join(" "))));
expect([...dialog.querySelectorAll(".help-desc")].map((d) => d.textContent)).toEqual(
groups.flatMap((g) => g.items.map((c) => c.label)),
);
});
it("names each row by its command id, so a row can't drift from its command", () => {
openHelp();
expect([...document.querySelectorAll<HTMLElement>(".help-row")].map((r) => r.dataset.commandId))
.toEqual(publishedCommands().map((c) => c.id));
const dialog = overlay();
expect(
[...dialog.querySelectorAll<HTMLElement>(".help-row")].map((r) => r.dataset.commandId),
).toEqual(publishedCommands().map((c) => c.id));
});
// #159: the leader comes from the daemon, so the overlay must render the
@@ -54,11 +60,9 @@ describe("help overlay", () => {
publishCommands(bound);
try {
openHelp();
const dialog = overlay();
const keysOf = (id: string) =>
[
...(document.querySelector(`.help-row[data-command-id="${id}"]`) as HTMLElement)
.querySelectorAll("kbd"),
]
[...dialog.querySelectorAll(`.help-row[data-command-id="${id}"] kbd`)]
.map((k) => k.textContent)
.join(" ");
expect(keysOf("pane.literalCtrlK")).toBe("Ctrl B then Ctrl K");
@@ -84,34 +88,30 @@ describe("help overlay", () => {
expect(isHelpOpen()).toBe(false);
openHelp();
(document.querySelector(".help-close") as HTMLButtonElement).click();
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(isHelpOpen()).toBe(false);
openHelp();
(document.querySelector(".help-overlay") as HTMLElement).dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
fireEvent.click(overlay());
expect(isHelpOpen()).toBe(false);
});
it("stays open for a click inside the panel", () => {
openHelp();
(document.querySelector(".help-panel") as HTMLElement).dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
fireEvent.click(screen.getByRole("heading", { name: "Keyboard shortcuts", level: 2 }));
expect(isHelpOpen()).toBe(true);
});
it("opening twice leaves one overlay", () => {
openHelp();
openHelp();
expect(document.querySelectorAll(".help-overlay")).toHaveLength(1);
expect(screen.getAllByRole("dialog")).toHaveLength(1);
});
it("ignores ? while typing and when a non-shift modifier is held", () => {
const input = document.createElement("input");
document.body.appendChild(input);
input.dispatchEvent(new KeyboardEvent("keydown", { key: "?", bubbles: true, shiftKey: true }));
fireEvent.keyDown(input, { key: "?", shiftKey: true });
expect(isHelpOpen()).toBe(false);
press("?", { ctrlKey: true });
@@ -122,6 +122,6 @@ describe("help overlay", () => {
installHelpKeybind();
press("?", { shiftKey: true });
expect(isHelpOpen()).toBe(true);
expect(document.querySelectorAll(".help-overlay")).toHaveLength(1);
expect(screen.getAllByRole("dialog")).toHaveLength(1);
});
});
+174
View File
@@ -0,0 +1,174 @@
// @vitest-environment happy-dom
import { act, cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { leaves } from "../layout.js";
import { createLayoutStore, type LayoutStoreApi } from "../stores/layoutStore.js";
import { usePreview, type UsePreviewResult } from "./usePreview.js";
function fakeScheduler() {
const timers = new Map<number, () => void>();
let id = 0;
return {
scheduler: {
setTimer: (fn: () => void) => {
const h = ++id;
timers.set(h, fn);
return h;
},
clearTimer: (h: number) => {
timers.delete(h);
},
},
flush() {
for (const fn of [...timers.values()]) fn();
timers.clear();
},
};
}
/** Hand-driven clock + timer queue for the preview schedule's own throttle,
* independent of the store's save-debounce scheduler above. */
function fakeClock() {
let now = 1_000;
const timers = new Map<number, { at: number; fn: () => void }>();
let handle = 1;
return {
now: () => now,
setTimer: (fn: () => void, ms: number) => {
const h = handle++;
timers.set(h, { at: now + ms, fn });
return h;
},
clearTimer: (h: number) => {
timers.delete(h);
},
advance(ms: number) {
now += ms;
for (const [h, t] of [...timers]) {
if (t.at <= now) {
timers.delete(h);
act(() => t.fn());
}
}
},
};
}
function newStore(): LayoutStoreApi {
return createLayoutStore({ load: () => null, save: vi.fn(), scheduler: fakeScheduler().scheduler });
}
let controls: UsePreviewResult;
function Probe({ store, clock }: { store: LayoutStoreApi; clock: ReturnType<typeof fakeClock> }) {
controls = usePreview({
store,
scheduleDeps: { now: clock.now, setTimer: clock.setTimer, clearTimer: clock.clearTimer },
});
return <div>{controls.previewedSessionId ?? "none"}</div>;
}
afterEach(cleanup);
describe("usePreview", () => {
it("shows the first preview immediately — no settling delay", () => {
const store = newStore();
store.getState().openSession("s1");
const clock = fakeClock();
const view = render(<Probe store={store} clock={clock} />);
act(() => controls.preview("s9"));
expect(view.container.textContent).toBe("s9");
expect(leaves(store.getState().layout.tabs[1]?.root ?? null).map((l) => l.sessionId)).toEqual(["s9"]);
});
it("throttles a burst to one leading + one trailing pane swap", () => {
const store = newStore();
store.getState().openSession("s1");
const clock = fakeClock();
const view = render(<Probe store={store} clock={clock} />);
act(() => {
controls.preview("s1");
controls.preview("s8");
controls.preview("s9");
});
expect(view.container.textContent).toBe("s1");
clock.advance(120);
expect(view.container.textContent).toBe("s9");
});
it("touch never previews", () => {
const store = newStore();
store.getState().openSession("s1");
const clock = fakeClock();
const view = render(<Probe store={store} clock={clock} />);
act(() => controls.previewFromPointer("s9", "touch"));
expect(view.container.textContent).toBe("none");
expect(store.getState().preview).toBeNull();
});
it("previewFromPointer still previews for mouse/pen", () => {
const store = newStore();
store.getState().openSession("s1");
const clock = fakeClock();
const view = render(<Probe store={store} clock={clock} />);
act(() => controls.previewFromPointer("s9", "mouse"));
expect(view.container.textContent).toBe("s9");
});
it("commit cancels a pending trailing preview and opens for real", () => {
const store = newStore();
store.getState().openSession("s1");
const clock = fakeClock();
const view = render(<Probe store={store} clock={clock} />);
act(() => {
controls.preview("s1"); // leading edge: previews the already-open s1
controls.preview("s9"); // queued, gap not elapsed yet
controls.commit("s9");
});
expect(view.container.textContent).toBe("none"); // committed: no longer a preview
expect(leaves(store.getState().layout.tabs[0].root).map((l) => l.sessionId)).toEqual(["s9"]);
clock.advance(120);
// The cancelled trailing preview never fires.
expect(view.container.textContent).toBe("none");
});
it("end drops a preview immediately, bypassing the throttle", () => {
const store = newStore();
store.getState().openSession("s1");
const clock = fakeClock();
const view = render(<Probe store={store} clock={clock} />);
act(() => controls.preview("s9"));
expect(view.container.textContent).toBe("s9");
act(() => controls.end());
expect(view.container.textContent).toBe("none");
expect(store.getState().layout.tabs).toHaveLength(1);
});
it("cancels its pending trailing preview on unmount", () => {
const store = newStore();
store.getState().openSession("s1");
store.getState().addTab();
store.getState().openSession("s2"); // leading edge below will preview this, already on screen
const clock = fakeClock();
const view = render(<Probe store={store} clock={clock} />);
act(() => {
controls.preview("s2"); // leading edge: fires now
controls.preview("s9"); // queued trailing preview
});
expect(store.getState().preview?.sessionId).toBe("s2");
view.unmount();
clock.advance(120);
// The queued trailing preview must not fire after unmount.
expect(store.getState().preview?.sessionId).toBe("s2");
});
});
+81
View File
@@ -0,0 +1,81 @@
// React port of previewSchedule.ts + multiview.ts's preview controller
// (issue #251). Owns the throttled schedule, hands the layout store the
// throttled result, and exposes the three ways a preview ends for real —
// see docs/PREVIEW.md, the contract this mirrors.
import { useCallback, useEffect, useRef } from "react";
import { useStore } from "zustand";
import { createPreviewSchedule, type PreviewScheduleDeps } from "../previewSchedule.js";
import type { LayoutStore, LayoutStoreApi } from "../stores/layoutStore.js";
import type { Uuid } from "../types.js";
export interface UsePreviewOptions {
store: LayoutStoreApi;
/** Test seam for the schedule's clock/timers. */
scheduleDeps?: PreviewScheduleDeps;
}
export interface UsePreviewResult {
/** The session currently shown as a preview, or null. */
previewedSessionId: Uuid | null;
/** Throttled: show `sessionId` in the content pane, or `null` to put back
* whatever was on screen (a highlight move, or the highlight leaving
* every session). Never delayed on the leading edge. */
preview(sessionId: Uuid | null): void;
/** Pointer-hover entry point: touch has no hover, so a touch pointer never
* previews (docs/PREVIEW.md). */
previewFromPointer(sessionId: Uuid | null, pointerType: string): void;
/** Choosing the previewed session — click/Enter on its row, click into its
* pane, or handing it the keyboard (Tab). Keeps exactly what is on
* screen: a throwaway tab becomes real, a focus move inside an existing
* tab stands. Nothing moves a second time. */
commit(sessionId: Uuid): void;
/** Any other real change — opening the command bar, a dialog, navigating
* away — ends a pending or active preview immediately, bypassing the
* throttle. */
end(): void;
}
/** Preview state management (docs/PREVIEW.md): wraps the layout store's
* `previewSession`/`commitPreview` with the 120ms pane-swap throttle so the
* UI never has to schedule it by hand. */
export function usePreview({ store, scheduleDeps }: UsePreviewOptions): UsePreviewResult {
const previewedSessionId = useStore(store, (s: LayoutStore) => s.preview?.sessionId ?? null);
const scheduleRef = useRef<ReturnType<typeof createPreviewSchedule> | null>(null);
if (!scheduleRef.current) {
scheduleRef.current = createPreviewSchedule((id) => store.getState().previewSession(id), scheduleDeps);
}
useEffect(() => {
const schedule = scheduleRef.current;
return () => schedule?.cancel();
}, []);
const preview = useCallback((sessionId: Uuid | null) => {
scheduleRef.current?.(sessionId);
}, []);
const previewFromPointer = useCallback(
(sessionId: Uuid | null, pointerType: string) => {
if (pointerType === "touch") return;
preview(sessionId);
},
[preview],
);
const commit = useCallback(
(sessionId: Uuid) => {
scheduleRef.current?.cancel();
store.getState().openSession(sessionId);
},
[store],
);
const end = useCallback(() => {
scheduleRef.current?.cancel();
store.getState().previewSession(null);
}, [store]);
return { previewedSessionId, preview, previewFromPointer, commit, end };
}
+8 -9
View File
@@ -1,10 +1,9 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from "vitest";
import {
closeForceKillDialog,
openForceKillDialog,
openKillConfirmDialog,
} from "./killDialog.js";
import { screen } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { closeForceKillDialog, openForceKillDialog, openKillConfirmDialog } from "./killDialog.js";
describe("kill dialogs", () => {
beforeEach(() => {
@@ -13,21 +12,21 @@ describe("kill dialogs", () => {
});
it("focuses the kill button, so Enter confirms", () => {
const el = openKillConfirmDialog({
openKillConfirmDialog({
name: "alpha",
blockers: null,
onKill: () => Promise.resolve(),
});
expect(document.activeElement).toBe(el.querySelector(".kill-dialog-kill"));
expect(screen.getByRole("button", { name: "Kill" })).toHaveFocus();
});
it("focuses the force button too", () => {
const el = openForceKillDialog({
openForceKillDialog({
name: "alpha",
blockers: null,
reason: "worktree is dirty.",
onForce: () => Promise.resolve(),
});
expect(document.activeElement).toBe(el.querySelector(".kill-dialog-force"));
expect(screen.getByRole("button", { name: "Force kill" })).toHaveFocus();
});
});
+9 -11
View File
@@ -1,5 +1,7 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, screen } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { addTab, emptyLayout, leaves, type Layout } from "./layout.js";
import { splitLeaf } from "./layout.js";
@@ -82,7 +84,7 @@ describe("multiview empty panes", () => {
await flush();
expect(mv.el.querySelectorAll(".mv-pane")).toHaveLength(2);
expect(mv.el.querySelector(".mv-pane-empty")).toBeTruthy();
expect(screen.getByText(/Empty pane/)).toBeTruthy();
expect(mv.el.querySelector(".spawn-form")).toBeNull();
});
});
@@ -124,13 +126,9 @@ describe("multiview pane-drag swap", () => {
targetPane,
];
marker.dispatchEvent(
new PointerEvent("pointerdown", { clientX: 0, clientY: 0, bubbles: true, cancelable: true }),
);
window.dispatchEvent(
new PointerEvent("pointermove", { clientX: 150, clientY: 50, bubbles: true, cancelable: true }),
);
window.dispatchEvent(new PointerEvent("pointerup", { clientX: 150, clientY: 50, bubbles: true, cancelable: true }));
fireEvent.pointerDown(marker, { clientX: 0, clientY: 0, bubbles: true, cancelable: true });
fireEvent.pointerMove(window, { clientX: 150, clientY: 50, bubbles: true, cancelable: true });
fireEvent.pointerUp(window, { clientX: 150, clientY: 50, bubbles: true, cancelable: true });
await flush();
}
@@ -384,7 +382,7 @@ describe("multiview mobile single pane", () => {
document.body.appendChild(mv.el);
mv.render();
expect(mv.el.classList.contains("mv-mobile-solo")).toBe(true);
expect(mv.el).toHaveClass("mv-mobile-solo");
});
});
@@ -406,7 +404,7 @@ describe("multiview solo tab bar", () => {
document.body.appendChild(mv.el);
mv.render();
expect(mv.el.classList.contains("mv-solo-tab")).toBe(true);
expect(mv.el).toHaveClass("mv-solo-tab");
});
it("shows the tab bar again once a second tab appears", () => {
@@ -419,6 +417,6 @@ describe("multiview solo tab bar", () => {
document.body.appendChild(mv.el);
mv.render();
expect(mv.el.classList.contains("mv-solo-tab")).toBe(false);
expect(mv.el).not.toHaveClass("mv-solo-tab");
});
});
+28 -24
View File
@@ -1,5 +1,6 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, within } from "@testing-library/react";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
import type { Project } from "./types.js";
@@ -34,10 +35,14 @@ function stubFetch(info: unknown, projects: Project[]): void {
);
}
function projectBoxes(form: HTMLFormElement): HTMLButtonElement[] {
return within(form)
.getAllByRole("button")
.filter((b): b is HTMLButtonElement => b.classList.contains("project-box"));
}
function boxPaths(form: HTMLFormElement): string[] {
return [...form.querySelectorAll<HTMLButtonElement>(".project-box")].map(
(b) => b.dataset.cwd ?? "",
);
return projectBoxes(form).map((b) => b.dataset.cwd ?? "");
}
describe("spawnForm project picker (#95: boxes, full paths, mains only)", () => {
@@ -74,7 +79,7 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () =>
});
const form = (await mount([stale, fresh])).el;
expect(boxPaths(form)).toEqual(["/home/user/fresh", "/home/user/stale"]);
const boxes = [...form.querySelectorAll<HTMLButtonElement>(".project-box")];
const boxes = projectBoxes(form);
expect(boxes[1].classList.contains("is-stale")).toBe(true);
expect(boxes[0].classList.contains("is-stale")).toBe(false);
});
@@ -82,19 +87,19 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () =>
it("shows the name as the primary line and its parent directory below", async () => {
const p = project({ name: "nixos-config", path: "/home/user/tea/nixos-config" });
const form = (await mount([p])).el;
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
expect(box.querySelector(".project-name")!.textContent).toBe("nixos-config");
const box = within(form).getByRole("button", { name: /nixos-config/ });
expect(within(box).getByText("nixos-config")).toBeTruthy();
// Only the stem: repeating the last segment would just echo the name. Not
// `~/t`, since the stem is what distinguishes two projects sharing a
// basename (#95).
expect(box.querySelector(".project-path")!.textContent).toBe("~/tea");
expect(within(box).getByText("~/tea")).toBeTruthy();
});
it("keeps the last command out of the box", async () => {
const p = project({ name: "maestro", path: "/home/user/maestro", last_command: "claude" });
const form = (await mount([p])).el;
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
expect(box.textContent).not.toContain("claude");
const box = within(form).getByRole("button", { name: /maestro/ });
expect(within(box).queryByText(/claude/)).toBeNull();
});
it("lists only main checkouts as boxes — worktrees belong to step two", async () => {
@@ -120,18 +125,15 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () =>
base_repo: a.path,
});
const form = (await mount([a, b, wt])).el;
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
filter.value = "tea";
filter.dispatchEvent(new Event("input"));
const filter = within(form).getByPlaceholderText("Filter projects…");
fireEvent.input(filter, { target: { value: "tea" } });
expect(boxPaths(form)).toEqual(["/home/user/work/tea"]);
// A query that only matches a worktree still surfaces its repository box.
filter.value = "bulgarians";
filter.dispatchEvent(new Event("input"));
fireEvent.input(filter, { target: { value: "bulgarians" } });
expect(boxPaths(form)).toEqual(["/home/user/maestro"]);
filter.value = "zzz";
filter.dispatchEvent(new Event("input"));
fireEvent.input(filter, { target: { value: "zzz" } });
expect(boxPaths(form)).toEqual([]);
expect(form.querySelector(".picker-empty")).toBeTruthy();
expect(within(form).getByText("No projects match.")).toBeTruthy();
});
it("picking a project selects its cwd and leaves the command alone", async () => {
@@ -159,10 +161,12 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () =>
for (let i = 0; i < 20; i++) await Promise.resolve();
onChange.mockClear();
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
box.click();
const box = within(form).getByRole("button", { name: /maestro/ });
fireEvent.click(box);
const name = form.querySelector<HTMLInputElement>("input[name=name]")!.value;
const name = within(form).getByPlaceholderText(
"named after your first prompt",
) as HTMLInputElement;
const last = onChange.mock.lastCall![0];
expect(last.cwd).toBe("/home/user/maestro");
// The directory and the command are independent choices: the preset stays
@@ -170,7 +174,7 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () =>
expect(last.command).toBe("claude");
// Picking a project is not naming a session: the field stays empty and
// the daemon names it (#131).
expect(name).toBe("");
expect(name.value).toBe("");
// The raw input stays hidden — the picked box is the source of truth.
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(true);
expect(box.classList.contains("is-active")).toBe(true);
@@ -179,15 +183,15 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () =>
it("keeps an 'Other directory…' escape hatch that reveals the raw input", async () => {
const p = project({ name: "maestro", path: "/home/user/maestro" });
const form = (await mount([p])).el;
const other = form.querySelector<HTMLButtonElement>(".project-other")!;
other.click();
const other = within(form).getByRole("button", { name: "Other directory…" });
fireEvent.click(other);
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(false);
});
it("falls back to the raw input when no projects exist", async () => {
const form = (await mount([])).el;
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(false);
const raw = form.querySelector<HTMLInputElement>(".cwd-raw")!;
const raw = within(form).getByPlaceholderText("/home/user/project") as HTMLInputElement;
expect(raw.value).toBe("/home/user");
});
});
+27 -31
View File
@@ -1,5 +1,6 @@
// @vitest-environment happy-dom
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { fireEvent, screen, within } from "@testing-library/react";
import { renderSidebar } from "./catalog.js";
import { closeContextMenu } from "./contextMenu.js";
@@ -65,16 +66,12 @@ function cardIn(el: HTMLElement, s: Session): HTMLElement {
function openCardMenu(s: Session): { el: HTMLElement; row: HTMLElement } {
const el = mount(s);
const row = cardIn(el, s);
row.dispatchEvent(new Event("contextmenu", { bubbles: true, cancelable: true }));
fireEvent.contextMenu(row);
return { el, row };
}
function menuItem(label: string): HTMLButtonElement {
const found = [...document.querySelectorAll<HTMLButtonElement>(".context-menu-item")].find(
(b) => b.textContent === label,
);
if (!found) throw new Error(`no "${label}" entry in the context menu`);
return found;
function menuItem(label: string): HTMLElement {
return screen.getByRole("menuitem", { name: label });
}
describe("session name normalisation (#125)", () => {
@@ -93,10 +90,9 @@ describe("sidebar card context menu (#125)", () => {
it("suppresses the browser menu and offers rename, spawn similar and kill", () => {
const s = session();
const row = cardIn(mount(s), s);
const ev = new Event("contextmenu", { bubbles: true, cancelable: true });
row.dispatchEvent(ev);
expect(ev.defaultPrevented).toBe(true);
expect([...document.querySelectorAll(".context-menu-item")].map((b) => b.textContent)).toEqual([
const notCancelled = fireEvent.contextMenu(row);
expect(notCancelled).toBe(false);
expect(screen.getAllByRole("menuitem").map((b) => b.textContent)).toEqual([
"Rename…",
"Spawn similar",
"Kill session",
@@ -108,7 +104,7 @@ describe("sidebar card context menu (#125)", () => {
openCardMenu(s);
const seen = vi.fn();
document.addEventListener("catalog-spawn-similar", seen);
menuItem("Spawn similar").click();
fireEvent.click(menuItem("Spawn similar"));
document.removeEventListener("catalog-spawn-similar", seen);
expect(seen).toHaveBeenCalledOnce();
});
@@ -117,40 +113,40 @@ describe("sidebar card context menu (#125)", () => {
describe("inline rename (#125)", () => {
it("pre-fills the field with the session's own name", () => {
const { row } = openCardMenu(session());
menuItem("Rename…").click();
const input = row.querySelector<HTMLInputElement>("input.leaf-rename");
expect(input?.value).toBe("amber-falcon");
fireEvent.click(menuItem("Rename…"));
const input = within(row).getByRole("textbox") as HTMLInputElement;
expect(input.value).toBe("amber-falcon");
expect(row.querySelector("span.leaf-name")).toBeNull();
});
it("starts empty for a session that has no name of its own", () => {
const { row } = openCardMenu(session({ metadata: {} }));
menuItem("Rename…").click();
expect(row.querySelector<HTMLInputElement>("input.leaf-rename")?.value).toBe("");
fireEvent.click(menuItem("Rename…"));
expect((within(row).getByRole("textbox") as HTMLInputElement).value).toBe("");
});
it("holds the tree still while the field is open", () => {
const s = session();
const { el, row } = openCardMenu(s);
menuItem("Rename…").click();
const input = row.querySelector<HTMLInputElement>("input.leaf-rename");
fireEvent.click(menuItem("Rename…"));
const input = within(row).getByRole("textbox");
// A catalog delta mid-edit must not rebuild the row out from under it.
document.dispatchEvent(new CustomEvent("catalog-rerender"));
expect(cardIn(el, s).querySelector("input.leaf-rename")).toBe(input);
fireEvent(document, new CustomEvent("catalog-rerender"));
expect(within(cardIn(el, s)).getByRole("textbox")).toBe(input);
});
it("Enter PATCHes the session with the typed name", () => {
const s = session();
const { row } = openCardMenu(s);
menuItem("Rename…").click();
const input = row.querySelector<HTMLInputElement>("input.leaf-rename") as HTMLInputElement;
input.value = "crimson-otter";
fireEvent.click(menuItem("Rename…"));
const input = within(row).getByRole("textbox") as HTMLInputElement;
fireEvent.input(input, { target: { value: "crimson-otter" } });
const fetchMock = vi.fn(() =>
Promise.resolve(new Response(JSON.stringify(s), { status: 200 })),
);
vi.stubGlobal("fetch", fetchMock);
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
fireEvent.keyDown(input, { key: "Enter" });
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
@@ -162,17 +158,17 @@ describe("inline rename (#125)", () => {
it("Escape cancels without a request and restores the name", () => {
const s = session();
const { el, row } = openCardMenu(s);
menuItem("Rename…").click();
const input = row.querySelector<HTMLInputElement>("input.leaf-rename") as HTMLInputElement;
input.value = "discarded";
fireEvent.click(menuItem("Rename…"));
const input = within(row).getByRole("textbox") as HTMLInputElement;
fireEvent.input(input, { target: { value: "discarded" } });
const fetchMock = vi.fn(() => Promise.reject(new Error("offline")));
vi.stubGlobal("fetch", fetchMock);
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
fireEvent.keyDown(input, { key: "Escape" });
expect(fetchMock).not.toHaveBeenCalled();
// The row is rebuilt from the store, so the old name comes back.
expect(cardIn(el, s).querySelector(".leaf-name")?.textContent).toBe("amber-falcon");
expect(cardIn(el, s).querySelector("input.leaf-rename")).toBeNull();
expect(within(cardIn(el, s)).getByText("amber-falcon")).toBeTruthy();
expect(within(cardIn(el, s)).queryByRole("textbox")).toBeNull();
});
});
+23 -16
View File
@@ -1,5 +1,6 @@
// @vitest-environment happy-dom
import { beforeAll, describe, expect, it } from "vitest";
import { fireEvent, screen, within } from "@testing-library/react";
import { FOCUS_PANE_EVENT, renderSidebar } from "./catalog.js";
import type { CatalogListener, CatalogStore } from "./events.js";
@@ -50,7 +51,7 @@ function twoFolders(opts: Parameters<typeof renderSidebar>[1] = {}): {
}
function press(el: HTMLElement, key: string, init: KeyboardEventInit = {}): void {
el.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, ...init }));
fireEvent.keyDown(el, { key, ...init });
}
function highlighted(el: HTMLElement): string | null {
@@ -61,11 +62,17 @@ function tree(el: HTMLElement): HTMLElement {
return el.querySelector<HTMLElement>(".tree")!;
}
function folderHeaders(el: HTMLElement): HTMLButtonElement[] {
return within(el)
.getAllByRole("button")
.filter((b): b is HTMLButtonElement => b.classList.contains("tree-folder-header"));
}
describe("sidebar keyboard navigation", () => {
it("walks the cards and never stops on an expanded folder", () => {
const { el } = twoFolders();
// The folders are there — the walk just refuses to land on them.
expect(el.querySelectorAll(".tree-folder-header")).toHaveLength(2);
expect(folderHeaders(el)).toHaveLength(2);
press(tree(el), "j");
expect(highlighted(el)).toBe(`session:${A}`);
@@ -89,8 +96,8 @@ describe("sidebar keyboard navigation", () => {
it("stops on a collapsed folder, since its cards are gone", () => {
const { el } = twoFolders();
const header = el.querySelectorAll<HTMLElement>(".tree-folder-header")[0];
header.click(); // collapse /one
const header = folderHeaders(el)[0];
fireEvent.click(header); // collapse /one
press(tree(el), "j");
expect(highlighted(el)).toBe("folder:one");
@@ -122,8 +129,8 @@ describe("sidebar keyboard navigation", () => {
it("c collapses and expands the highlighted folder", () => {
const { el } = twoFolders();
const header = el.querySelectorAll<HTMLElement>(".tree-folder-header")[0];
header.click(); // collapse /one, so the walk stops on its header
const header = folderHeaders(el)[0];
fireEvent.click(header); // collapse /one, so the walk stops on its header
press(tree(el), "j");
expect(highlighted(el)).toBe("folder:one");
@@ -136,11 +143,11 @@ describe("sidebar keyboard navigation", () => {
const { el } = twoFolders();
press(tree(el), "j");
press(tree(el), "r");
const field = el.querySelector<HTMLInputElement>(".leaf-rename");
const field = within(el).queryByRole("textbox");
expect(field).not.toBeNull();
// Escape settles the rename, so the module-level "a row is being renamed"
// flag doesn't leak into the next test.
field!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
fireEvent.keyDown(field!, { key: "Escape" });
});
it("x kills the highlighted session, once confirmed (#160)", async () => {
@@ -154,7 +161,7 @@ describe("sidebar keyboard navigation", () => {
press(tree(el), "j");
press(tree(el), "x");
expect(killed.some((r) => r.startsWith("DELETE"))).toBe(false);
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
fireEvent.click(screen.getByRole("button", { name: "Kill" }));
await Promise.resolve();
expect(killed.some((r) => r.startsWith("DELETE") && r.includes(A))).toBe(true);
});
@@ -164,7 +171,7 @@ describe("sidebar keyboard navigation", () => {
press(tree(el), "j");
const input = document.createElement("input");
tree(el).appendChild(input);
input.dispatchEvent(new KeyboardEvent("keydown", { key: "j", bubbles: true }));
fireEvent.keyDown(input, { key: "j" });
expect(highlighted(el)).toBe(`session:${A}`);
});
});
@@ -178,7 +185,7 @@ describe("sidebar preview requests (docs/PREVIEW.md)", () => {
press(tree(el), "j");
expect(seen).toEqual([A, B]);
tree(el).dispatchEvent(new FocusEvent("blur"));
fireEvent.blur(tree(el));
expect(seen.at(-1)).toBe(null);
});
@@ -188,18 +195,18 @@ describe("sidebar preview requests (docs/PREVIEW.md)", () => {
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
// Plain hover — no preview.
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
fireEvent.pointerOver(card, { pointerType: "mouse" });
expect(seen).toEqual([]);
// Ctrl+hover — preview fires.
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse", ctrlKey: true }));
fireEvent.pointerOver(card, { pointerType: "mouse", ctrlKey: true });
expect(seen).toEqual([A]);
// Deduplicated while still on the same card.
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse", ctrlKey: true }));
fireEvent.pointerOver(card, { pointerType: "mouse", ctrlKey: true });
expect(seen).toHaveLength(1);
tree(el).dispatchEvent(new PointerEvent("pointerleave", { pointerType: "mouse" }));
fireEvent.pointerLeave(tree(el), { pointerType: "mouse" });
expect(seen.at(-1)).toBe(null);
});
@@ -207,7 +214,7 @@ describe("sidebar preview requests (docs/PREVIEW.md)", () => {
const seen: (string | null)[] = [];
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "touch", ctrlKey: true }));
fireEvent.pointerOver(card, { pointerType: "touch", ctrlKey: true });
expect(seen).toEqual([]);
});
});
+14 -9
View File
@@ -3,11 +3,13 @@
// The sidebar's search field is the touch entry point to the command bar
// (#157, docs/COMMAND-BAR.md "Touch entry"): tapping or focusing it asks the
// shell to open the bar with an empty query, instead of filtering the tree.
import "@testing-library/jest-dom/vitest";
import { fireEvent, within } from "@testing-library/react";
import { beforeAll, describe, expect, it } from "vitest";
import { OPEN_COMMAND_BAR_EVENT, renderSidebar } from "./catalog.js";
import { sessionBarCandidates } from "./commandBar.js";
import { matchAndRank } from "./matcher.js";
import { matchAndRank } from "./utils/matcher.js";
import type { CatalogListener, CatalogStore } from "./events.js";
import type { Session } from "./types.js";
@@ -45,7 +47,9 @@ function storeWith(sessions: Session[]): CatalogStore {
function mount(sessions: Session[] = [session("alpha", "/home/n/alpha")]) {
const handle = renderSidebar(storeWith(sessions));
document.body.appendChild(handle.el);
const search = handle.el.querySelector<HTMLInputElement>(".search")!;
const search = within(handle.el).getByRole("searchbox", {
name: "Search sessions",
}) as HTMLInputElement;
const opens: number[] = [];
const listener = () => opens.push(1);
document.addEventListener(OPEN_COMMAND_BAR_EVENT, listener);
@@ -64,14 +68,14 @@ function mount(sessions: Session[] = [session("alpha", "/home/n/alpha")]) {
describe("sidebar search field opens the command bar (#157)", () => {
it("asks the shell to open the bar when the field is focused", () => {
const t = mount();
t.search.dispatchEvent(new FocusEvent("focus"));
fireEvent.focus(t.search);
expect(t.opens).toHaveLength(1);
t.dispose();
});
it("opens on a tap, where a focus event may never arrive", () => {
const t = mount();
t.search.dispatchEvent(new Event("pointerdown", { bubbles: true }));
fireEvent.pointerDown(t.search);
expect(t.opens).toHaveLength(1);
t.dispose();
});
@@ -79,7 +83,7 @@ describe("sidebar search field opens the command bar (#157)", () => {
it("does not trap the keyboard in the field: it gives focus up on open", () => {
const t = mount();
t.search.focus();
expect(document.activeElement).not.toBe(t.search);
expect(t.search).not.toHaveFocus();
t.dispose();
});
@@ -98,15 +102,16 @@ describe("sidebar search field opens the command bar (#157)", () => {
it("no longer filters the tree in place", () => {
const t = mount([session("alpha", "/home/n/alpha"), session("beta", "/home/n/beta")]);
t.search.value = "alpha";
t.search.dispatchEvent(new Event("input"));
expect(t.handle.el.querySelectorAll(".tree-leaf")).toHaveLength(2);
fireEvent.input(t.search, { target: { value: "alpha" } });
// Both cards share one command ("claude") and so one accessible name; the
// DEV-only "kit" link would also match a bare role query for "link".
expect(within(t.handle.el).getAllByRole("link", { name: "claude, running, Idle" })).toHaveLength(2);
t.dispose();
});
it("has no 'No matches' state left: an empty catalog is empty, not filtered", () => {
const t = mount([]);
expect(t.handle.el.querySelector(".empty-title")?.textContent).toBe("No sessions yet");
expect(within(t.handle.el).getByText("No sessions yet")).toBeTruthy();
t.dispose();
});
});
+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);
}
+45 -44
View File
@@ -4,6 +4,7 @@
// Enter to *choose* it instead (the next Enter submits), and the project
// filter must resolve to the highlighted project rather than sending
// whatever cwd was already set.
import { fireEvent, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
@@ -84,19 +85,28 @@ describe("spawnForm Enter-to-submit (#97)", () => {
}
function pressEnter(el: HTMLElement): { defaultPrevented: boolean } {
const evt = new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true });
el.dispatchEvent(evt);
return { defaultPrevented: evt.defaultPrevented };
const notCanceled = fireEvent.keyDown(el, { key: "Enter" });
return { defaultPrevented: !notCanceled };
}
const box = (form: HTMLFormElement, path: string) =>
form.querySelector<HTMLButtonElement>(`.project-box[data-cwd="${path}"]`)!;
/** The step-one project box for `project` — a plain button whose accessible
* name starts with its name, followed by its abbreviated parent path. */
const box = (container: HTMLElement, project: Project): HTMLButtonElement =>
within(container).getByRole("button", { name: new RegExp(`^${project.name}`) }) as HTMLButtonElement;
/** A step-two worktree choice by its label — inside the collapsed "Advanced"
* disclosure, so the role query must opt back in to hidden content. */
const wtChoice = (container: HTMLElement, name: string): HTMLButtonElement =>
within(container).getByRole("button", {
name: new RegExp(`^${name}`),
hidden: true,
}) as HTMLButtonElement;
it("toggles the worktree checkbox on Enter instead of submitting (TUI parity)", async () => {
const form = await mount([MAESTRO]);
box(form, MAESTRO.path).click();
fireEvent.click(box(form, MAESTRO));
await drain();
const toggle = form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!;
const toggle = within(form).getByLabelText("Create git worktree") as HTMLInputElement;
expect(toggle.checked).toBe(true);
const spy = submitSpy();
form.addEventListener("submit", spy);
@@ -113,36 +123,35 @@ describe("spawnForm Enter-to-submit (#97)", () => {
describe("submits immediately from ordinary fields", () => {
it("name field", async () => {
const form = await mount([MAESTRO]);
box(form, MAESTRO.path).click();
fireEvent.click(box(form, MAESTRO));
await drain();
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(form.querySelector<HTMLInputElement>("input[name=name]")!);
pressEnter(within(form).getByPlaceholderText("named after your first prompt"));
expect(spy).toHaveBeenCalledTimes(1);
});
it("worktree branch field", async () => {
const form = await mount([MAESTRO]);
box(form, MAESTRO.path).click();
fireEvent.click(box(form, MAESTRO));
await drain();
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(form.querySelector<HTMLInputElement>("input[name=worktree_branch]")!);
pressEnter(within(form).getByLabelText("New branch name"));
expect(spy).toHaveBeenCalledTimes(1);
});
it("custom command field", async () => {
const form = await mount([MAESTRO]);
box(form, MAESTRO.path).click();
fireEvent.click(box(form, MAESTRO));
await drain();
form.querySelector<HTMLButtonElement>('.cmd-tiles .harness-card[data-id="custom"]')!.click();
const raw = form.querySelector<HTMLInputElement>(".cmd-raw")!;
raw.value = "bash -l";
raw.dispatchEvent(new Event("input"));
fireEvent.click(within(form).getByRole("button", { name: /Custom/ }));
const raw = within(form).getByPlaceholderText("bash -l") as HTMLInputElement;
fireEvent.input(raw, { target: { value: "bash -l" } });
const spy = submitSpy();
form.addEventListener("submit", spy);
@@ -153,10 +162,9 @@ describe("spawnForm Enter-to-submit (#97)", () => {
it("raw cwd field once the autocomplete dropdown is closed", async () => {
const form = await mount([MAESTRO]);
form.querySelector<HTMLButtonElement>(".project-other")!.click();
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
raw.value = "/opt/xyz-nothing-here";
raw.dispatchEvent(new Event("input"));
fireEvent.click(within(form).getByRole("button", { name: "Other directory…" }));
const raw = within(form).getByPlaceholderText("/home/user/project") as HTMLInputElement;
fireEvent.input(raw, { target: { value: "/opt/xyz-nothing-here" } });
await vi.advanceTimersByTimeAsync(300);
const spy = submitSpy();
form.addEventListener("submit", spy);
@@ -168,11 +176,10 @@ describe("spawnForm Enter-to-submit (#97)", () => {
it("does not submit while the raw cwd autocomplete has a highlighted suggestion", async () => {
const form = await mount([MAESTRO]);
form.querySelector<HTMLButtonElement>(".project-other")!.click();
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
fireEvent.click(within(form).getByRole("button", { name: "Other directory…" }));
const raw = within(form).getByPlaceholderText("/home/user/project") as HTMLInputElement;
raw.focus();
raw.value = "/opt/";
raw.dispatchEvent(new Event("input", { bubbles: true }));
fireEvent.input(raw, { target: { value: "/opt/" } });
await vi.advanceTimersByTimeAsync(150);
const spy = submitSpy();
form.addEventListener("submit", spy);
@@ -188,7 +195,7 @@ describe("spawnForm Enter-to-submit (#97)", () => {
describe("two-step picker: first Enter chooses, second submits", () => {
it("project box", async () => {
const form = await mount([MAESTRO]);
const b = box(form, MAESTRO.path);
const b = box(form, MAESTRO);
b.focus();
const spy = submitSpy();
form.addEventListener("submit", spy);
@@ -204,9 +211,9 @@ describe("spawnForm Enter-to-submit (#97)", () => {
it("worktree choice", async () => {
const form = await mount([MAESTRO, WT_A]);
box(form, MAESTRO.path).click();
fireEvent.click(box(form, MAESTRO));
await drain();
const choice = form.querySelector<HTMLButtonElement>(`.wt-choice[data-cwd="${WT_A.path}"]`)!;
const choice = wtChoice(form, WT_A.name);
expect(choice.classList.contains("is-active")).toBe(false); // "+ new" is the default
const spy = submitSpy();
form.addEventListener("submit", spy);
@@ -223,16 +230,15 @@ describe("spawnForm Enter-to-submit (#97)", () => {
describe("project filter", () => {
it("submits with the highlighted (first matching) project, not the filter text", async () => {
const form = await mount([MAESTRO, OTHER]);
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
filter.value = "rev";
filter.dispatchEvent(new Event("input"));
const filter = within(form).getByPlaceholderText("Filter projects…") as HTMLInputElement;
fireEvent.input(filter, { target: { value: "rev" } });
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(filter);
expect(spy).toHaveBeenCalledTimes(1);
expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true);
expect(box(form, OTHER).classList.contains("is-active")).toBe(true);
});
it("submits in the seeded project rather than the freshest one", async () => {
@@ -243,37 +249,32 @@ describe("spawnForm Enter-to-submit (#97)", () => {
cwd: OTHER.path,
cwdRawVisible: false,
});
expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true);
expect(box(form, OTHER).classList.contains("is-active")).toBe(true);
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(form.querySelector<HTMLInputElement>(".project-filter")!);
pressEnter(within(form).getByPlaceholderText("Filter projects…"));
expect(spy).toHaveBeenCalledTimes(1);
expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true);
expect(box(form, OTHER).classList.contains("is-active")).toBe(true);
});
it("does not clobber an already-chosen worktree when the filter still matches it", async () => {
const form = await mount([MAESTRO, WT_A]);
box(form, MAESTRO.path).click();
fireEvent.click(box(form, MAESTRO));
await drain();
form.querySelector<HTMLButtonElement>(`.wt-choice[data-cwd="${WT_A.path}"]`)!.click();
fireEvent.click(wtChoice(form, WT_A.name));
await drain();
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
filter.value = "maestro";
filter.dispatchEvent(new Event("input"));
const filter = within(form).getByPlaceholderText("Filter projects…") as HTMLInputElement;
fireEvent.input(filter, { target: { value: "maestro" } });
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(filter);
expect(spy).toHaveBeenCalledTimes(1);
expect(
form.querySelector<HTMLButtonElement>(`.wt-choice[data-cwd="${WT_A.path}"]`)!.classList.contains(
"is-active",
),
).toBe(true);
expect(wtChoice(form, WT_A.name).classList.contains("is-active")).toBe(true);
});
});
});
+14 -8
View File
@@ -1,5 +1,6 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, within } from "@testing-library/react";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
@@ -52,14 +53,19 @@ describe("spawnForm naming (#131)", () => {
return handle.el;
}
// Forms from earlier tests in this file are never removed from
// `document.body` (only `destroy()`ed), so lookups are scoped to the form
// under test rather than going through the global `screen`.
const nameOf = (form: HTMLFormElement) =>
form.querySelector<HTMLInputElement>("input[name=name]")!.value;
(within(form).getByPlaceholderText("named after your first prompt") as HTMLInputElement).value;
const branchOf = (form: HTMLFormElement) =>
form.querySelector<HTMLInputElement>("input[name=worktree_branch]")!.value;
(within(form).getByLabelText("New branch name") as HTMLInputElement).value;
it("leaves the name field empty so the daemon names the session", async () => {
const form = await mount();
expect(nameOf(form)).toBe("");
// No reroll control exists in the current markup; kept to guard against
// its (re-)introduction without a matching test update.
expect(form.querySelector(".reroll")).toBeNull();
});
@@ -78,9 +84,10 @@ describe("spawnForm naming (#131)", () => {
it("seeds the branch from a typed name, overriding the fetched handle", async () => {
const form = await mount();
const input = form.querySelector<HTMLInputElement>("input[name=name]")!;
input.value = "hand-typed";
input.dispatchEvent(new Event("input"));
const input = within(form).getByPlaceholderText(
"named after your first prompt",
) as HTMLInputElement;
fireEvent.input(input, { target: { value: "hand-typed" } });
expect(nameOf(form)).toBe("hand-typed");
expect(branchOf(form)).toBe("hand-typed");
@@ -92,9 +99,8 @@ describe("spawnForm naming (#131)", () => {
const form = handle.el;
document.body.appendChild(form);
const branch = form.querySelector<HTMLInputElement>("input[name=worktree_branch]")!;
branch.value = "feat/mine";
branch.dispatchEvent(new Event("input"));
const branch = within(form).getByLabelText("New branch name") as HTMLInputElement;
fireEvent.input(branch, { target: { value: "feat/mine" } });
for (let i = 0; i < 40; i++) await Promise.resolve();
expect(branchOf(form)).toBe("feat/mine");
+17 -16
View File
@@ -1,5 +1,7 @@
// @vitest-environment happy-dom
import "@testing-library/jest-dom/vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { fireEvent, within } from "@testing-library/react";
import { createSpawnForm, type SpawnFormHandle, type SpawnFormValues } from "./spawnForm.js";
@@ -54,6 +56,14 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
for (let i = 0; i < 40; i++) await Promise.resolve();
}
// Tiles fold their icon's accessible name into the button's computed name,
// so lookups go by the stable `data-id` rather than by accessible role/name.
function tileById(form: HTMLFormElement, id: string): HTMLButtonElement {
return [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
(t) => t.dataset.id === id,
)!;
}
it("keeps an operator-picked tile after /api/info resolves late", async () => {
const { resolveInfo } = stubDeferredInfo();
let values: SpawnFormValues | undefined;
@@ -63,11 +73,9 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
await drain();
// Fallback presets are up; the operator picks "zsh" before /api/info lands.
const zshTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
(t) => t.dataset.id === "zsh",
)!;
zshTile.click();
expect(zshTile.classList.contains("is-active")).toBe(true);
const zshTile = tileById(form, "zsh");
fireEvent.click(zshTile);
expect(zshTile).toHaveClass("is-active");
// /api/info lands late with a preset list that doesn't even include "zsh".
resolveInfo();
@@ -88,13 +96,9 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
document.body.appendChild(form);
await drain();
const customTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
(t) => t.dataset.id === "custom",
)!;
customTile.click();
const cmdRaw = form.querySelector<HTMLInputElement>(".cmd-raw")!;
cmdRaw.value = "htop";
cmdRaw.dispatchEvent(new Event("input"));
fireEvent.click(tileById(form, "custom"));
const cmdRaw = within(form).getByPlaceholderText("bash -l") as HTMLInputElement;
fireEvent.input(cmdRaw, { target: { value: "htop" } });
resolveInfo();
await drain();
@@ -116,9 +120,6 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
// Untouched: the refined preset list is still free to settle the command.
expect(values?.command).toBe("claude");
const fishTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
(t) => t.dataset.id === "fish",
);
expect(fishTile).toBeDefined();
expect(tileById(form, "fish")).toBeDefined();
});
});
+13 -13
View File
@@ -1,5 +1,6 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, within } from "@testing-library/react";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
@@ -93,15 +94,14 @@ describe("spawnForm gitroot probe debounce (#74)", () => {
};
function typeRaw(form: HTMLFormElement, value: string): void {
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
raw.value = value;
raw.dispatchEvent(new Event("input"));
const raw = within(form).getByPlaceholderText("/home/user/project") as HTMLInputElement;
fireEvent.input(raw, { target: { value } });
}
it("a keystroke burst fires exactly one probe after the quiet period", async () => {
const { form, stub } = await mount();
form.querySelector<HTMLElement>(".project-other")!.click();
fireEvent.click(within(form).getByRole("button", { name: "Other directory…" }));
// Burst: every keystroke reschedules; nothing may hit /api/gitroot yet.
for (let i = 1; i <= "/opt/somewhere".length; i++) {
typeRaw(form, "/opt/somewhere".slice(0, i));
@@ -119,7 +119,7 @@ describe("spawnForm gitroot probe debounce (#74)", () => {
it("a stale probe answer cannot overwrite a newer input", async () => {
const { form, stub } = await mount({ holdGitRootFor: ["/repo/old"] });
form.querySelector<HTMLElement>(".project-other")!.click();
fireEvent.click(within(form).getByRole("button", { name: "Other directory…" }));
// Probe for the old target, held in flight.
typeRaw(form, "/repo/old");
@@ -131,18 +131,18 @@ describe("spawnForm gitroot probe debounce (#74)", () => {
await drain();
expect(stub.gitRootCalls).toEqual(["/repo/old", "/repo/new"]);
expect(form.querySelector<HTMLElement>(".wt-repo")!.textContent).toContain("/repo/new");
expect(
form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked,
).toBe(true);
expect(within(form).getByText((content) => content.includes("/repo/new"))).toBeTruthy();
expect((within(form).getByLabelText("Create git worktree") as HTMLInputElement).checked).toBe(
true,
);
// The late answer to the old target lands last but must lose.
stub.releaseGitRoot("/repo/old");
await drain();
expect(form.querySelector<HTMLElement>(".wt-repo")!.textContent).toContain("/repo/new");
expect(
form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked,
).toBe(true);
expect(within(form).getByText((content) => content.includes("/repo/new"))).toBeTruthy();
expect((within(form).getByLabelText("Create git worktree") as HTMLInputElement).checked).toBe(
true,
);
});
});
+28 -32
View File
@@ -1,5 +1,7 @@
// @vitest-environment happy-dom
import "@testing-library/jest-dom/vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { fireEvent, within } from "@testing-library/react";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
@@ -50,25 +52,23 @@ describe("spawn form structured view", () => {
document.body.appendChild(handle.el);
await drain();
const wrap = handle.el.querySelector<HTMLElement>(".structured-view")!;
const checkbox = wrap.querySelector<HTMLInputElement>("input")!;
const disclosure = handle.el.querySelector<HTMLElement>(".wt-collapsible")!;
expect(disclosure.hidden).toBe(true);
handle.el.querySelector<HTMLButtonElement>(".wt-toggle")!.click();
expect(disclosure.hidden).toBe(false);
const checkbox = within(handle.el).getByLabelText("Structured view") as HTMLInputElement;
const wrap = checkbox.closest(".structured-view") as HTMLElement;
const toggle = within(handle.el).getByRole("button", { name: /Advanced/ });
expect(toggle).toHaveAttribute("aria-expanded", "false");
fireEvent.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(wrap.hidden).toBe(false);
expect(checkbox.checked).toBe(true);
const custom = handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!;
custom.click();
const command = handle.el.querySelector<HTMLInputElement>(".cmd-raw")!;
command.value = "FOO=1 /usr/local/bin/codex";
command.dispatchEvent(new Event("input"));
fireEvent.click(custom);
const command = within(handle.el).getByPlaceholderText("bash -l") as HTMLInputElement;
fireEvent.input(command, { target: { value: "FOO=1 /usr/local/bin/codex" } });
expect(wrap.hidden).toBe(false);
expect(checkbox.checked).toBe(true);
command.value = "opencode";
command.dispatchEvent(new Event("input"));
fireEvent.input(command, { target: { value: "opencode" } });
expect(wrap.hidden).toBe(true);
expect(checkbox.checked).toBe(false);
});
@@ -79,15 +79,14 @@ describe("spawn form structured view", () => {
document.body.appendChild(handle.el);
await drain();
const checkbox = handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!;
const checkbox = within(handle.el).getByLabelText("Structured view") as HTMLInputElement;
expect(checkbox.checked).toBe(true);
checkbox.click();
fireEvent.click(checkbox);
expect(checkbox.checked).toBe(false);
handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!.click();
const command = handle.el.querySelector<HTMLInputElement>(".cmd-raw")!;
command.value = "codex";
command.dispatchEvent(new Event("input"));
fireEvent.click(handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!);
const command = within(handle.el).getByPlaceholderText("bash -l") as HTMLInputElement;
fireEvent.input(command, { target: { value: "codex" } });
expect(checkbox.checked).toBe(false);
});
@@ -98,16 +97,14 @@ describe("spawn form structured view", () => {
document.body.appendChild(handle.el);
await drain();
handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!.click();
const command = handle.el.querySelector<HTMLInputElement>(".cmd-raw")!;
command.value = "env -i codex";
command.dispatchEvent(new Event("input"));
fireEvent.click(handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!);
const command = within(handle.el).getByPlaceholderText("bash -l") as HTMLInputElement;
fireEvent.input(command, { target: { value: "env -i codex" } });
const checkbox = handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!;
const checkbox = within(handle.el).getByLabelText("Structured view") as HTMLInputElement;
expect(checkbox.checked).toBe(true);
command.value = "opencode";
command.dispatchEvent(new Event("input"));
fireEvent.input(command, { target: { value: "opencode" } });
expect(checkbox.closest<HTMLElement>(".structured-view")!.hidden).toBe(true);
expect(checkbox.checked).toBe(false);
@@ -123,13 +120,12 @@ describe("spawn form structured view", () => {
await drain();
if (tool === "codex") {
handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!.click();
const command = handle.el.querySelector<HTMLInputElement>(".cmd-raw")!;
command.value = "codex";
command.dispatchEvent(new Event("input"));
fireEvent.click(handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!);
const command = within(handle.el).getByPlaceholderText("bash -l") as HTMLInputElement;
fireEvent.input(command, { target: { value: "codex" } });
}
handle.el.requestSubmit();
fireEvent.submit(handle.el);
await drain();
expect(requests).toHaveLength(1);
@@ -148,8 +144,8 @@ describe("spawn form structured view", () => {
document.body.appendChild(handle.el);
await drain();
handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!.click();
handle.el.requestSubmit();
fireEvent.click(within(handle.el).getByLabelText("Structured view"));
fireEvent.submit(handle.el);
await drain();
expect(requests).toHaveLength(1);
+28 -44
View File
@@ -1,4 +1,5 @@
// @vitest-environment happy-dom
import { fireEvent, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createSpawnForm, type SpawnFormHandle, type SpawnFormValues } from "./spawnForm.js";
@@ -80,15 +81,13 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
};
function pickProject(form: HTMLFormElement): void {
form.querySelector<HTMLButtonElement>(".project-other")!.click();
form
.querySelectorAll<HTMLButtonElement>(".project-box")
.forEach((box) => box.dataset.cwd === "/home/user/maestro" && box.click());
fireEvent.click(within(form).getByRole("button", { name: "Other directory…" }));
fireEvent.click(within(form).getByRole("button", { name: /^maestro/ }));
}
it("defaults the worktree toggle on for a picked project with no gitroot probe", async () => {
const form = await mount();
expect(form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked).toBe(
expect((within(form).getByLabelText("Create git worktree") as HTMLInputElement).checked).toBe(
true,
);
@@ -96,9 +95,7 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
await drain();
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false);
expect(form.querySelector<HTMLElement>(".wt-repo")!.textContent).toContain(
"/home/user/maestro",
);
expect(within(form).getByText(/\/home\/user\/maestro/)).toBeTruthy();
});
it("defaults the worktree toggle off for a row that is itself a worktree (#91)", async () => {
@@ -117,14 +114,12 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
document.body.appendChild(handle.el);
for (let i = 0; i < 40; i++) await Promise.resolve();
handle.el
.querySelectorAll<HTMLButtonElement>(".project-box")
.forEach((box) => box.dataset.cwd === "/home/user/wt-91" && box.click());
fireEvent.click(within(handle.el).getByRole("button", { name: /^wt-91/ }));
await drain();
expect(handle.el.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked).toBe(
false,
);
expect(
(within(handle.el).getByLabelText("Create git worktree") as HTMLInputElement).checked,
).toBe(false);
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false);
expect(handle.el.querySelector<HTMLElement>(".wt-body")!.hidden).toBe(true);
});
@@ -134,10 +129,9 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
// Escape hatch: switch to a free-text dir; only now does /api/gitroot
// fire — debounced, so the quiet period must elapse first (#74).
form.querySelector<HTMLElement>(".project-other")!.click();
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
raw.value = "/opt/somewhere";
raw.dispatchEvent(new Event("input"));
fireEvent.click(within(form).getByRole("button", { name: "Other directory…" }));
const raw = within(form).getByPlaceholderText("/home/user/project") as HTMLInputElement;
fireEvent.input(raw, { target: { value: "/opt/somewhere" } });
await vi.advanceTimersByTimeAsync(250);
await drain();
@@ -148,13 +142,10 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
const form = await mount();
pickProject(form);
const radio = form.querySelector<HTMLInputElement>(
'input[name=worktree_branch_mode][value="existing"]',
)!;
radio.click();
fireEvent.click(within(form).getByLabelText("Existing branch"));
await drain();
const select = form.querySelector<HTMLSelectElement>(".branch-select")!;
const select = within(form).getByLabelText("Pick a branch") as HTMLSelectElement;
expect(urls.some((u) => u.includes("/api/branches?path="))).toBe(true);
const options = [...select.options];
expect(options.map((o) => o.value)).toEqual(["main", "spawn-v2", "free-branch"]);
@@ -169,10 +160,7 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
const form = await mount(undefined, (v) => changes.push(v));
await drain();
const radio = form.querySelector<HTMLInputElement>(
'input[name=worktree_branch_mode][value="existing"]',
)!;
radio.click();
fireEvent.click(within(form).getByLabelText("Existing branch"));
await drain();
const modes = changes.map((c) => (c as { worktreeBranchMode?: string }).worktreeBranchMode);
@@ -197,14 +185,14 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
it("collapses the advanced (worktree) section by default (#93)", async () => {
const form = await mount();
const toggle = form.querySelector<HTMLButtonElement>(".wt-toggle")!;
const toggle = within(form).getByRole("button", { name: /Advanced/ });
expect(toggle.getAttribute("aria-expanded")).toBe("false");
expect(form.querySelector<HTMLElement>(".wt-collapsible")!.hidden).toBe(true);
});
it("states in the collapsed header that a worktree will be created by default (#93)", async () => {
const form = await mount();
expect(form.querySelector<HTMLElement>(".wt-summary")!.textContent).toMatch(/creates? a worktree/i);
expect(within(form).getByText(/creates? a worktree/i)).toBeTruthy();
});
it("states in the collapsed header that no worktree will be created for a worktree row (#91, #93)", async () => {
@@ -223,27 +211,25 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
document.body.appendChild(handle.el);
for (let i = 0; i < 40; i++) await Promise.resolve();
handle.el
.querySelectorAll<HTMLButtonElement>(".project-box")
.forEach((box) => box.dataset.cwd === "/home/user/wt-91" && box.click());
fireEvent.click(within(handle.el).getByRole("button", { name: /^wt-91/ }));
await drain();
expect(handle.el.querySelector<HTMLElement>(".wt-summary")!.textContent).toMatch(/no worktree/i);
expect(within(handle.el).getByText(/no worktree/i)).toBeTruthy();
// Still collapsed — picking a project must not force it open.
expect(handle.el.querySelector<HTMLButtonElement>(".wt-toggle")!.getAttribute("aria-expanded")).toBe(
"false",
);
expect(
within(handle.el).getByRole("button", { name: /Advanced/ }).getAttribute("aria-expanded"),
).toBe("false");
});
it("expanding the advanced section does not reorder the fields above it (#93)", async () => {
const form = await mount();
const before = [...form.querySelectorAll(".field")];
form.querySelector<HTMLButtonElement>(".wt-toggle")!.click();
fireEvent.click(within(form).getByRole("button", { name: /Advanced/ }));
expect(form.querySelector<HTMLButtonElement>(".wt-toggle")!.getAttribute("aria-expanded")).toBe(
"true",
);
expect(
within(form).getByRole("button", { name: /Advanced/ }).getAttribute("aria-expanded"),
).toBe("true");
expect(form.querySelector<HTMLElement>(".wt-collapsible")!.hidden).toBe(false);
expect([...form.querySelectorAll(".field")]).toStrictEqual(before);
});
@@ -256,16 +242,14 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
for (let i = 0; i < 40; i++) await Promise.resolve();
pickProject(handle.el);
handle.el
.querySelector<HTMLInputElement>('input[name=worktree_branch_mode][value="existing"]')!
.click();
fireEvent.click(within(handle.el).getByLabelText("Existing branch"));
handle.destroy();
gate.release?.();
await drain();
expect(
handle.el.querySelector<HTMLSelectElement>(".branch-select")!.options.length,
(within(handle.el).getByLabelText("Pick a branch") as HTMLSelectElement).options.length,
).toBe(0);
});
});
+29 -35
View File
@@ -1,4 +1,6 @@
// @vitest-environment happy-dom
import "@testing-library/jest-dom/vitest";
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { closeSpawnModal, openSpawnModal } from "./spawnModal.js";
@@ -26,8 +28,6 @@ async function flush(): Promise<void> {
for (let i = 0; i < 20; i++) await Promise.resolve();
}
const modal = () => document.getElementById("modal-host");
describe("spawn modal", () => {
beforeEach(() => {
stubFetch();
@@ -41,66 +41,63 @@ describe("spawn modal", () => {
it("mounts the shared spawn form inside a dialog", async () => {
openSpawnModal({ onSuccess: () => {} });
await flush();
const host = modal()!;
expect(host).toBeTruthy();
expect(host.querySelector('[role="dialog"]')).toBeTruthy();
expect(host.querySelector("form.spawn-form")).toBeTruthy();
expect(host.querySelector("#spawn-title")?.textContent).toBe("Spawn session");
const dialog = await screen.findByRole("dialog", { name: "Spawn session" });
expect(within(dialog).getByRole("button", { name: "Spawn" })).toBeTruthy();
// The modal adds the Cancel the shared form doesn't ship.
expect(host.querySelector(".form-actions .cancel")).toBeTruthy();
expect(within(dialog).getByRole("button", { name: "Cancel" })).toBeTruthy();
});
it("opens with the project filter focused, harness before project", async () => {
openSpawnModal({ onSuccess: () => {} });
await flush();
const form = modal()!.querySelector("form.spawn-form")!;
expect(document.activeElement).toBe(form.querySelector(".project-filter"));
const fields = [...form.querySelectorAll(".field .field-label")].map((el) => el.textContent);
expect(fields.slice(0, 2)).toEqual(["Harness", "Project"]);
const filterInput = await screen.findByPlaceholderText("Filter projects…");
expect(filterInput).toHaveFocus();
const dialog = screen.getByRole("dialog");
const fieldLabels = within(dialog)
.getAllByText(/.+/, { selector: ".field-label" })
.map((el) => el.textContent);
expect(fieldLabels.slice(0, 2)).toEqual(["Harness", "Project"]);
});
it("titles a seeded open as a clone", async () => {
openSpawnModal({ seed: { command: "claude" }, onSuccess: () => {} });
await flush();
expect(modal()!.querySelector("#spawn-title")?.textContent).toBe("Spawn similar");
expect(await screen.findByRole("dialog", { name: "Spawn similar" })).toBeTruthy();
});
it("closes on Escape, on Cancel and on a backdrop click", async () => {
for (const dismiss of [
() => window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })),
() => (modal()!.querySelector(".form-actions .cancel") as HTMLButtonElement).click(),
() => modal()!.dispatchEvent(new MouseEvent("click", { bubbles: true })),
() => (modal()!.querySelector(".close") as HTMLButtonElement).click(),
() => fireEvent.keyDown(window, { key: "Escape" }),
() => fireEvent.click(screen.getByRole("button", { name: "Cancel" })),
() => fireEvent.click(screen.getByRole("dialog").parentElement!),
() => fireEvent.click(screen.getByRole("button", { name: "Close" })),
]) {
openSpawnModal({ onSuccess: () => {} });
await flush();
await screen.findByRole("dialog");
dismiss();
expect(modal()).toBeNull();
expect(screen.queryByRole("dialog")).toBeNull();
}
});
it("leaves no stray Escape handler behind after closing", async () => {
openSpawnModal({ onSuccess: () => {} });
await flush();
await screen.findByRole("dialog");
closeSpawnModal();
// A second Escape with no modal up must not throw on the torn-down form.
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
expect(modal()).toBeNull();
fireEvent.keyDown(window, { key: "Escape" });
expect(screen.queryByRole("dialog")).toBeNull();
});
it("opening twice tears the first one down rather than stacking dialogs", async () => {
openSpawnModal({ onSuccess: () => {} });
await flush();
await screen.findByRole("dialog");
openSpawnModal({ onSuccess: () => {} });
await flush();
await screen.findByRole("dialog");
expect(document.querySelectorAll("#modal-host")).toHaveLength(1);
expect(document.querySelectorAll("form.spawn-form")).toHaveLength(1);
expect(screen.getAllByRole("dialog")).toHaveLength(1);
expect(screen.getAllByRole("button", { name: "Spawn" })).toHaveLength(1);
});
it("closes before handing the created session to its caller", async () => {
@@ -127,15 +124,12 @@ describe("spawn modal", () => {
// A plain, worktree-free spawn: the point here is where the created
// session is handed over, not what the form validates.
seed: { command: "zsh", cwd: "/x", cwdRawVisible: true, worktreeEnabled: false },
onSuccess: (session) => seen.push({ session, stillOpen: modal() != null }),
onSuccess: (session) => seen.push({ session, stillOpen: screen.queryByRole("dialog") != null }),
});
await flush();
(modal()!.querySelector("form.spawn-form") as HTMLFormElement).dispatchEvent(
new Event("submit", { bubbles: true, cancelable: true }),
);
await flush();
fireEvent.submit(screen.getByRole("button", { name: "Spawn" }).closest("form")!);
await waitFor(() => expect(seen).toHaveLength(1));
expect(seen).toHaveLength(1);
expect(seen[0].session.id).toBe("s1");
expect(seen[0].stillOpen).toBe(false);
});
+15 -12
View File
@@ -1,4 +1,6 @@
// @vitest-environment happy-dom
import "@testing-library/jest-dom/vitest";
import { fireEvent, screen, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { DaemonLink } from "./daemonLink.js";
@@ -48,11 +50,13 @@ describe("status indicator", () => {
await link.start();
expect(ind.el.dataset.state).toBe("online");
const tip = ind.el.querySelector(".status-tip")!.textContent ?? "";
// `ind.el` is never appended to the document, so it's queried directly
// rather than through `screen`.
const tip = within(ind.el).getByRole("tooltip").textContent ?? "";
expect(tip).toContain("f89be4a716c7");
expect(tip).toContain("2026-09-02");
expect(tip).toContain("konishi");
expect(ind.el.getAttribute("aria-label")).toContain("Connected");
expect(ind.el).toHaveAccessibleName(/Connected/);
ind.dispose();
});
@@ -92,20 +96,19 @@ describe("restart overlay", () => {
});
const overlay = installRestartOverlay(link);
await link.start();
expect(document.querySelector(".restart-backdrop")).toBeNull();
expect(screen.queryByRole("alertdialog")).toBeNull();
reply = null;
link.noteDisconnected();
expect(document.querySelector(".restart-backdrop")).toBeNull(); // grace
expect(screen.queryByRole("alertdialog")).toBeNull(); // grace
sched.run();
await Promise.resolve();
const dialog = document.querySelector(".restart-modal");
expect(dialog?.textContent).toContain("Maestro is restarting");
expect(screen.getByRole("alertdialog")).toHaveTextContent("Maestro is restarting");
// Restarted on the same build: no reload, and the overlay goes away.
reply = info("aaa", "i2");
await link.probe();
expect(document.querySelector(".restart-backdrop")).toBeNull();
expect(screen.queryByRole("alertdialog")).toBeNull();
overlay.dispose();
});
@@ -127,12 +130,12 @@ describe("restart overlay", () => {
link.noteDisconnected();
sched.run();
await Promise.resolve();
window.dispatchEvent(new KeyboardEvent("keydown", { key: "n", bubbles: true }));
fireEvent.keyDown(window, { key: "n" });
expect(sawKey).toBe(false);
overlay.dispose();
link.stop();
window.dispatchEvent(new KeyboardEvent("keydown", { key: "n", bubbles: true }));
fireEvent.keyDown(window, { key: "n" });
expect(sawKey).toBe(true);
});
@@ -149,9 +152,9 @@ describe("restart overlay", () => {
link.noteDisconnected();
reply = info("bbb", "i2");
await link.probe();
const dialog = document.querySelector(".restart-modal");
expect(dialog?.textContent).toContain("Maestro was updated");
expect(dialog?.textContent).toContain("bbb");
const dialog = screen.getByRole("alertdialog");
expect(dialog).toHaveTextContent("Maestro was updated");
expect(dialog).toHaveTextContent("bbb");
overlay.dispose();
});
});
+291
View File
@@ -0,0 +1,291 @@
import { describe, expect, it, vi } from "vitest";
import { leaves } from "../layout.js";
import { createLayoutStore, type LayoutStoreOptions } from "./layoutStore.js";
function fakeScheduler() {
const timers = new Map<number, () => void>();
let id = 0;
return {
scheduler: {
setTimer: (fn: () => void) => {
const h = ++id;
timers.set(h, fn);
return h;
},
clearTimer: (h: number) => {
timers.delete(h);
},
},
flush() {
for (const fn of [...timers.values()]) fn();
timers.clear();
},
};
}
function store(opts: Partial<LayoutStoreOptions> = {}) {
return createLayoutStore({
load: () => null,
save: vi.fn(),
scheduler: fakeScheduler().scheduler,
...opts,
});
}
describe("layoutStore", () => {
it("boots with a single empty tab when nothing is persisted", () => {
const { layout } = store().getState();
expect(layout.tabs).toHaveLength(1);
expect(layout.tabs[0].root).toBeNull();
});
it("boots from a persisted layout when one loads", () => {
const persisted = store().getState().layout;
persisted.tabs[0].title = "Restored";
const s = store({ load: () => persisted });
expect(s.getState().layout.tabs[0].title).toBe("Restored");
});
it("openSession seeds the active tab from empty", () => {
const s = store();
s.getState().openSession("s1");
expect(leaves(s.getState().layout.tabs[0].root).map((l) => l.sessionId)).toEqual(["s1"]);
});
it("openSession focuses an already-open session instead of moving it", () => {
const s = store();
s.getState().openSession("s1");
s.getState().openSessionBeside("s2");
s.getState().openSession("s1");
const ids = leaves(s.getState().layout.tabs[0].root)
.map((l) => l.sessionId)
.sort();
expect(ids).toEqual(["s1", "s2"]);
});
it("openSessionBeside splits next to the focused pane instead of replacing it", () => {
const s = store();
s.getState().openSession("s1");
s.getState().openSessionBeside("s2");
expect(leaves(s.getState().layout.tabs[0].root)).toHaveLength(2);
});
it("openSessionBeside(newTab) opens its own tab", () => {
const s = store();
s.getState().openSession("s1");
s.getState().openSessionBeside("s2", { newTab: true });
expect(s.getState().layout.tabs).toHaveLength(2);
expect(leaves(s.getState().layout.tabs[1].root).map((l) => l.sessionId)).toEqual(["s2"]);
});
it("closeSession removes the pane and closes a tab left with nothing else", () => {
const s = store();
s.getState().openSession("s1");
s.getState().addTab();
s.getState().openSession("s2");
s.getState().closeSession("s2");
expect(s.getState().layout.tabs).toHaveLength(1);
expect(leaves(s.getState().layout.tabs[0].root).map((l) => l.sessionId)).toEqual(["s1"]);
});
it("splitFocused adds a leaf beside the given one and focuses it", () => {
const s = store();
s.getState().openSession("s1");
const first = leaves(s.getState().layout.tabs[0].root)[0].id;
s.getState().splitFocused(first, "row");
const tab = s.getState().layout.tabs[0];
expect(leaves(tab.root)).toHaveLength(2);
expect(tab.focusedLeafId).not.toBe(first);
});
it("closePane collapses the split back to the surviving leaf", () => {
const s = store();
s.getState().openSession("s1");
const first = leaves(s.getState().layout.tabs[0].root)[0].id;
s.getState().splitFocused(first, "row");
s.getState().closePane(first);
const remaining = leaves(s.getState().layout.tabs[0].root);
expect(remaining).toHaveLength(1);
expect(remaining[0].sessionId).toBeNull();
});
it("addTab/selectTab/closeTab manage the tab strip", () => {
const s = store();
s.getState().addTab();
expect(s.getState().layout.tabs).toHaveLength(2);
const [first, second] = s.getState().layout.tabs;
s.getState().selectTab(first.id);
expect(s.getState().layout.activeTabId).toBe(first.id);
s.getState().closeTab(second.id);
expect(s.getState().layout.tabs).toHaveLength(1);
});
it("reorderTab moves a tab to sit where another one is", () => {
const s = store();
s.getState().addTab();
s.getState().addTab();
const [a, b, c] = s.getState().layout.tabs;
s.getState().reorderTab(c.id, a.id);
expect(s.getState().layout.tabs.map((t) => t.id)).toEqual([c.id, a.id, b.id]);
});
it("reconcile drops leaves for sessions no longer live, and closes a tab left with nothing else", () => {
const s = store();
s.getState().openSession("s1");
s.getState().addTab();
s.getState().openSession("dead");
s.getState().reconcile(new Set(["s1"]));
const { layout } = s.getState();
expect(layout.tabs).toHaveLength(1);
expect(leaves(layout.tabs[0].root).map((l) => l.sessionId)).toEqual(["s1"]);
});
it("reconcile leaves an intentionally empty tab alone", () => {
const s = store();
s.getState().addTab();
s.getState().reconcile(new Set());
expect(s.getState().layout.tabs).toHaveLength(2);
});
it("debounces persistence to a single write once idle", () => {
const save = vi.fn();
const { scheduler, flush } = fakeScheduler();
const s = store({ save, scheduler });
s.getState().openSession("s1");
s.getState().openSessionBeside("s2");
expect(save).not.toHaveBeenCalled();
flush();
expect(save).toHaveBeenCalledTimes(1);
expect(save.mock.calls[0][0]).toBe(s.getState().layout);
});
describe("previewSession (docs/PREVIEW.md)", () => {
it("brings a session's own tab forward, then puts the layout back", () => {
const s = store();
s.getState().openSession("s1");
s.getState().addTab();
s.getState().openSession("s2");
const before = JSON.stringify(s.getState().layout);
s.getState().previewSession("s1");
const tab1 = s.getState().layout.tabs.find((t) => leaves(t.root).some((l) => l.sessionId === "s1"))!;
expect(s.getState().layout.activeTabId).toBe(tab1.id);
expect(s.getState().preview?.sessionId).toBe("s1");
s.getState().previewSession(null);
expect(s.getState().preview).toBeNull();
expect(JSON.stringify(s.getState().layout)).toBe(before);
});
it("shows a session no pane holds in a throwaway tab that leaves no trace", () => {
const s = store();
s.getState().openSession("s1");
const before = JSON.stringify(s.getState().layout);
s.getState().previewSession("s9");
expect(s.getState().layout.tabs).toHaveLength(2);
const tempTab = s.getState().layout.tabs[1];
expect(leaves(tempTab.root).map((l) => l.sessionId)).toEqual(["s9"]);
s.getState().previewSession(null);
expect(s.getState().layout.tabs).toHaveLength(1);
expect(JSON.stringify(s.getState().layout)).toBe(before);
});
it("stepping between previews leaves only the newest one on screen", () => {
const s = store();
s.getState().openSession("s1");
const before = JSON.stringify(s.getState().layout);
s.getState().previewSession("s9");
s.getState().previewSession("s8");
expect(s.getState().layout.tabs).toHaveLength(2);
expect(s.getState().preview?.sessionId).toBe("s8");
s.getState().previewSession(null);
expect(JSON.stringify(s.getState().layout)).toBe(before);
});
it("never persists while up, and saves again once settled back", () => {
const save = vi.fn();
const { scheduler, flush } = fakeScheduler();
const s = store({ save, scheduler });
s.getState().openSession("s1");
flush();
save.mockClear();
s.getState().previewSession("s9");
expect(save).not.toHaveBeenCalled();
s.getState().previewSession(null);
flush();
expect(save).toHaveBeenCalledTimes(1);
});
it("commitPreview keeps the throwaway tab as a real one", () => {
const s = store();
s.getState().openSession("s1");
s.getState().previewSession("s9");
expect(s.getState().commitPreview("s9")).toBe(true);
expect(s.getState().preview).toBeNull();
expect(s.getState().layout.tabs).toHaveLength(2);
expect(leaves(s.getState().layout.tabs[1].root).map((l) => l.sessionId)).toEqual(["s9"]);
});
it("commitPreview discards a non-matching preview and reports false", () => {
const s = store();
s.getState().openSession("s1");
s.getState().previewSession("s9");
expect(s.getState().commitPreview("s1")).toBe(false);
expect(s.getState().preview).toBeNull();
expect(s.getState().layout.tabs).toHaveLength(1);
});
it("openSession settles a matching preview instead of moving it a second time", () => {
const s = store();
s.getState().openSession("s1");
s.getState().previewSession("s9");
s.getState().openSession("s9");
expect(s.getState().preview).toBeNull();
expect(s.getState().layout.tabs).toHaveLength(2);
expect(leaves(s.getState().layout.tabs[0].root).map((l) => l.sessionId)).toEqual(["s1"]);
});
it("any real layout change ends a preview first: splitFocused", () => {
const s = store();
s.getState().openSession("s1");
s.getState().previewSession("s9");
const first = leaves(s.getState().layout.tabs[0].root)[0].id;
s.getState().splitFocused(first, "row");
expect(s.getState().preview).toBeNull();
expect(s.getState().layout.tabs).toHaveLength(1);
expect(leaves(s.getState().layout.tabs[0].root).map((l) => l.sessionId).sort()).toEqual([null, "s1"]);
});
it("any real layout change ends a preview first: addTab/selectTab/closeTab", () => {
const s = store();
s.getState().openSession("s1");
s.getState().previewSession("s9");
s.getState().addTab();
expect(s.getState().preview).toBeNull();
// The throwaway tab was discarded before the real tab was added.
expect(s.getState().layout.tabs).toHaveLength(2);
});
it("closeSession drops the preview before closing the real pane", () => {
const s = store();
s.getState().openSession("s1");
s.getState().previewSession("s9");
s.getState().closeSession("s1");
expect(s.getState().preview).toBeNull();
expect(s.getState().layout.tabs).toHaveLength(1);
expect(s.getState().layout.tabs[0].root).toBeNull();
});
});
});
+368
View File
@@ -0,0 +1,368 @@
// Zustand wrapper around ../layout.js's layout tree model (issue #249).
// Actions mutate the tree in place via layout.js's own functions, then
// re-wrap it so `useStore` selectors relying on referential equality notice.
import { create } from "zustand";
import {
activeTab,
addTab as addTabToLayout,
closeTab as closeTabInLayout,
emptyLayout,
equalizeRatios,
findLeaf,
freshId,
leaves,
locateSession,
removeLeaf,
splitLeaf,
type Dir,
type Layout,
type Tab,
} from "../layout.js";
import { loadLayout, saveLayout } from "../persistence.js";
import type { Uuid } from "../types.js";
import type { Scheduler } from "./catalogStore.js";
const SAVE_DEBOUNCE_MS = 400;
const browserScheduler: Scheduler = {
setTimer: (fn, ms) => window.setTimeout(fn, ms),
clearTimer: (h) => window.clearTimeout(h),
};
export interface LayoutStoreOptions {
load?: () => Layout | null;
save?: (layout: Layout) => void;
scheduler?: Scheduler;
}
/** A provisional preview (docs/PREVIEW.md): records exactly what
* `previewSession` moved, so ending it restores the layout byte-for-byte. */
export interface PreviewState {
sessionId: Uuid;
/** Tab that was active before the preview. */
prevActiveTabId: string;
/** Focused leaf to restore, when the preview stole focus in a real tab. */
prevFocus: { tabId: string; leafId: string | null } | null;
/** Throwaway tab built for a session that no pane was showing. */
tempTabId: string | null;
}
export interface LayoutState {
layout: Layout;
/** Session shown provisionally in the content pane, or null. A preview is
* a view, not state: it never reaches `save` (docs/PREVIEW.md). */
preview: PreviewState | null;
}
export interface LayoutActions {
/** Bring `sessionId` into view: focus it if already open, else fill the
* focused pane (VS-Code-style replace), or seed an empty tab. Settles a
* matching preview in place rather than moving it a second time. */
openSession(sessionId: Uuid): void;
/** Same, but never evicts a running pane: reuses an empty leaf or splits
* beside the focused one. `newTab` opens its own tab instead. */
openSessionBeside(sessionId: Uuid, opts?: { newTab?: boolean }): void;
/** Drop the pane showing `sessionId` (closed/killed elsewhere). */
closeSession(sessionId: Uuid): void;
splitFocused(leafId: string, dir: Dir): void;
closePane(leafId: string): void;
focusLeaf(leafId: string): void;
selectTab(tabId: string): void;
addTab(): void;
closeTab(tabId: string): void;
/** Tab-strip drag-and-drop reordering: moves `fromId` to sit where `toId`
* currently is. */
reorderTab(fromId: string, toId: string): void;
/** Persist whatever a divider drag left on a split's `ratio` — the drag
* itself mutates the node directly (same trade as the vanilla version),
* this just re-wraps and schedules the save. */
commitLayout(): void;
/** Boot reconciliation: drop every leaf whose session isn't in `liveIds`,
* and close any tab that held nothing else. A session killed while its
* tab was closed is pruned instead of leaving a pane that can only ever
* show a dead socket. Tabs that were already empty are left alone. */
reconcile(liveIds: ReadonlySet<Uuid>): void;
/** Show `sessionId` in the content pane without committing to it
* (docs/PREVIEW.md): its own tab comes forward, or a throwaway tab
* carries it. `null` puts back whatever was on screen before. Never
* schedules a save while a preview is up. */
previewSession(sessionId: Uuid | null): void;
/** Keep what is being previewed: its throwaway tab becomes a real one, and
* the focus it moved stands. Any other pending preview is simply
* discarded. Returns whether `sessionId` was the one being previewed. */
commitPreview(sessionId: Uuid): boolean;
}
export type LayoutStore = LayoutState & LayoutActions;
export type LayoutStoreApi = ReturnType<typeof createLayoutStore>;
/** Builds an isolated layout store — load/save/scheduler are injectable
* (tests), or default to the real localStorage persistence.ts + browser
* timers. Same shape as {@link createCatalogStore}. */
export function createLayoutStore(opts: LayoutStoreOptions = {}) {
const load = opts.load ?? loadLayout;
const save = opts.save ?? saveLayout;
const scheduler = opts.scheduler ?? browserScheduler;
let saveTimer: number | null = null;
return create<LayoutStore>((set, get) => {
function touch(): void {
// A shallow clone, not the same reference back: `useStore` (and any
// other Object.is-based selector) needs the identity to change to
// notice a mutation made in place on the tree underneath.
set({ layout: { ...get().layout } });
}
function schedulePersist(): void {
if (saveTimer !== null) scheduler.clearTimer(saveTimer);
saveTimer = scheduler.setTimer(() => {
saveTimer = null;
save(get().layout);
}, SAVE_DEBOUNCE_MS);
}
function commit(): void {
touch();
schedulePersist();
}
function mutate(fn: (layout: Layout) => void): void {
fn(get().layout);
commit();
}
// ---- preview (docs/PREVIEW.md): a view, not a layout change — never
// schedules a save while it is up. ----
/** Undo the preview's moves. Mutates only — the caller re-renders /
* persists as it sees fit. Returns whether a preview was active. */
function restorePreview(): boolean {
const p = get().preview;
if (!p) return false;
set({ preview: null });
const layout = get().layout;
if (p.tempTabId) {
const i = layout.tabs.findIndex((t) => t.id === p.tempTabId);
if (i >= 0) layout.tabs.splice(i, 1);
} else if (p.prevFocus) {
const t = layout.tabs.find((x) => x.id === p.prevFocus!.tabId);
if (t) t.focusedLeafId = p.prevFocus.leafId;
}
layout.activeTabId = layout.tabs.some((t) => t.id === p.prevActiveTabId)
? p.prevActiveTabId
: layout.tabs[0].id;
return true;
}
function settlePreview(sessionId: Uuid): boolean {
const p = get().preview;
if (!p) return false;
if (p.sessionId !== sessionId) {
restorePreview();
return false;
}
set({ preview: null });
if (p.tempTabId) {
const layout = get().layout;
const t = layout.tabs.find((x) => x.id === p.tempTabId);
if (t) t.title = `Tab ${layout.tabs.length}`;
}
return true;
}
return {
layout: load() ?? emptyLayout(),
preview: null,
openSession(sessionId) {
if (get().commitPreview(sessionId)) return;
mutate((layout) => {
const loc = locateSession(layout, sessionId);
if (loc) {
layout.activeTabId = loc.tab.id;
loc.tab.focusedLeafId = loc.leaf.id;
return;
}
const tab = activeTab(layout);
if (!tab.root) {
tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true);
return;
}
const target = (tab.focusedLeafId && findLeaf(tab.root, tab.focusedLeafId)) || leaves(tab.root)[0];
target.sessionId = sessionId;
tab.focusedLeafId = target.id;
});
},
openSessionBeside(sessionId, placement) {
restorePreview();
mutate((layout) => {
const loc = locateSession(layout, sessionId);
if (loc) {
layout.activeTabId = loc.tab.id;
loc.tab.focusedLeafId = loc.leaf.id;
return;
}
if (placement?.newTab) {
const fresh = addTabToLayout(layout);
fresh.focusedLeafId = splitLeaf(fresh, "", sessionId, "row", true);
return;
}
const tab = activeTab(layout);
if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) {
tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true);
return;
}
const target = findLeaf(tab.root, tab.focusedLeafId)!;
if (!target.sessionId) {
target.sessionId = sessionId;
} else {
tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true);
equalizeRatios(tab.root);
}
});
},
closeSession(sessionId) {
restorePreview();
mutate((layout) => {
const loc = locateSession(layout, sessionId);
if (!loc) return;
removeLeaf(loc.tab, loc.leaf.id);
if (loc.tab.focusedLeafId === loc.leaf.id) {
loc.tab.focusedLeafId = leaves(loc.tab.root)[0]?.id ?? null;
}
if (!loc.tab.root) closeTabInLayout(layout, loc.tab.id);
});
},
splitFocused(leafId, dir) {
restorePreview();
mutate((layout) => {
const tab = activeTab(layout);
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
equalizeRatios(tab.root);
});
},
closePane(leafId) {
restorePreview();
mutate((layout) => {
const tab = activeTab(layout);
removeLeaf(tab, leafId);
if (tab.focusedLeafId === leafId) {
tab.focusedLeafId = leaves(tab.root)[0]?.id ?? null;
}
});
},
focusLeaf(leafId) {
mutate((layout) => {
const tab = activeTab(layout);
if (tab.focusedLeafId === leafId) return;
tab.focusedLeafId = leafId;
});
},
selectTab(tabId) {
restorePreview();
mutate((layout) => {
if (layout.tabs.some((t) => t.id === tabId)) layout.activeTabId = tabId;
});
},
addTab() {
restorePreview();
mutate((layout) => {
addTabToLayout(layout);
});
},
closeTab(tabId) {
restorePreview();
mutate((layout) => {
closeTabInLayout(layout, tabId);
});
},
reorderTab(fromId, toId) {
restorePreview();
mutate((layout) => {
if (fromId === toId) return;
const from = layout.tabs.findIndex((t) => t.id === fromId);
const to = layout.tabs.findIndex((t) => t.id === toId);
if (from < 0 || to < 0) return;
const [moved] = layout.tabs.splice(from, 1);
layout.tabs.splice(to, 0, moved);
});
},
commitLayout() {
commit();
},
reconcile(liveIds) {
mutate((layout) => {
const emptied: string[] = [];
for (const tab of [...layout.tabs]) {
let prunedAny = false;
for (const leaf of leaves(tab.root)) {
if (leaf.sessionId && !liveIds.has(leaf.sessionId)) {
removeLeaf(tab, leaf.id);
prunedAny = true;
}
}
if (!prunedAny) continue;
if (!tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) {
tab.focusedLeafId = leaves(tab.root)[0]?.id ?? null;
}
if (!tab.root) emptied.push(tab.id);
}
for (const id of emptied) closeTabInLayout(layout, id);
});
},
previewSession(sessionId) {
const current = get().preview;
if (current?.sessionId === sessionId) return;
if (sessionId === null) {
if (restorePreview()) commit();
return;
}
restorePreview();
const layout = get().layout;
const prevActiveTabId = layout.activeTabId;
const loc = locateSession(layout, sessionId);
let preview: PreviewState;
if (loc) {
preview = {
sessionId,
prevActiveTabId,
prevFocus: { tabId: loc.tab.id, leafId: loc.tab.focusedLeafId },
tempTabId: null,
};
layout.activeTabId = loc.tab.id;
loc.tab.focusedLeafId = loc.leaf.id;
} else {
// Not on screen anywhere: a throwaway tab carries it, so no
// existing pane is evicted and nothing the operator arranged moves.
const tab: Tab = { id: freshId("t"), title: "Preview", root: null, focusedLeafId: null };
tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true);
layout.tabs.push(tab);
layout.activeTabId = tab.id;
preview = { sessionId, prevActiveTabId, prevFocus: null, tempTabId: tab.id };
}
set({ layout: { ...layout }, preview });
},
commitPreview(sessionId) {
const ok = settlePreview(sessionId);
if (ok) commit();
return ok;
},
};
});
}
+166 -164
View File
@@ -1,5 +1,7 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, waitFor, within } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
vi.mock("./api.js", async () => {
const actual = await vi.importActual<typeof import("./api.js")>("./api.js");
@@ -99,16 +101,17 @@ describe("structured connection: data flow", () => {
it("converts long pasted text into a removable file and sends it without reply text", async () => {
const conn = mount({ sockets: [new FakeSocket()] });
const input = conn.el.querySelector("textarea")!;
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
const paste = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(paste, "clipboardData", { value: {
files: [], getData: () => "a".repeat(4000),
} });
input.dispatchEvent(paste);
fireEvent(input, paste);
expect(paste.defaultPrevented).toBe(true);
expect(input.value).toBe("");
expect(conn.el.querySelector(".structured-attachments")!.textContent).toContain("pasted-1.txt");
conn.el.querySelector("form")!.dispatchEvent(new Event("submit", { cancelable: true }));
expect(input).toHaveValue("");
const attachments = conn.el.querySelector<HTMLElement>(".structured-attachments")!;
expect(within(attachments).getByText(/pasted-1\.txt/)).toBeInTheDocument();
fireEvent.submit(conn.el.querySelector("form")!);
await vi.waitFor(() => expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", expect.stringContaining("/uploads/pasted.txt")));
expect(uploadStructuredFile).toHaveBeenCalledWith("s1", expect.objectContaining({ name: "pasted-1.txt", size: 4000 }));
conn.dispose();
@@ -118,7 +121,7 @@ describe("structured connection: data flow", () => {
const conn = mount({ sockets: [new FakeSocket()] });
const paste = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(paste, "clipboardData", { value: { files: [], getData: () => "hello" } });
conn.el.querySelector("textarea")!.dispatchEvent(paste);
fireEvent(within(conn.el).getByRole<HTMLTextAreaElement>("textbox"), paste);
expect(paste.defaultPrevented).toBe(false);
conn.dispose();
});
@@ -126,20 +129,19 @@ describe("structured connection: data flow", () => {
it("retains failed uploads for retry and lets the operator remove them", async () => {
vi.mocked(uploadStructuredFile).mockRejectedValueOnce(new Error("Upload failed"));
const conn = mount({ sockets: [new FakeSocket()] });
const input = conn.el.querySelector("textarea")!;
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
input.value = "Read this";
const paste = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(paste, "clipboardData", { value: {
files: [new File(["content"], "notes.txt")], getData: () => "",
} });
input.dispatchEvent(paste);
conn.el.querySelector("form")!.dispatchEvent(new Event("submit", { cancelable: true }));
await vi.waitFor(() => expect(conn.el.textContent).toContain("Upload failed"));
expect(input.value).toBe("Read this");
fireEvent(input, paste);
fireEvent.submit(conn.el.querySelector("form")!);
await waitFor(() => expect(within(conn.el).getByText(/Upload failed/)).toBeInTheDocument());
expect(input).toHaveValue("Read this");
expect(sendStructuredPrompt).not.toHaveBeenCalled();
const remove = conn.el.querySelector<HTMLButtonElement>('[aria-label="Remove notes.txt"]')!;
expect(remove).not.toBeNull();
remove.click();
const remove = within(conn.el).getByRole("button", { name: "Remove notes.txt" });
fireEvent.click(remove);
expect(conn.el.querySelector<HTMLElement>(".structured-attachments")!.hidden).toBe(true);
conn.dispose();
});
@@ -159,8 +161,8 @@ describe("structured connection: data flow", () => {
},
});
ws.emit({ type: "reduced_state", state: idleState });
expect(conn.el.textContent).toContain("hello");
expect(conn.el.textContent).toContain("hi there");
expect(within(conn.el).getByText("hello")).toBeInTheDocument();
expect(within(conn.el).getByText("hi there")).toBeInTheDocument();
conn.dispose();
});
@@ -223,12 +225,12 @@ describe("structured connection: data flow", () => {
},
});
const transcript = conn.el.querySelector(".structured-rows")!;
const transcript = conn.el.querySelector<HTMLElement>(".structured-rows")!;
expect(transcript.querySelectorAll(".st-row")).toHaveLength(2);
expect(transcript.querySelector('[data-row-id="plan"]')).toBeNull();
expect(transcript.textContent).toContain("before plan");
expect(transcript.textContent).toContain("after plan");
expect(transcript.textContent).not.toContain("[plan]");
expect(within(transcript).getByText("before plan")).toBeInTheDocument();
expect(within(transcript).getByText("after plan")).toBeInTheDocument();
expect(within(transcript).queryByText(/\[plan\]/)).not.toBeInTheDocument();
expect(conn.el.querySelector(".structured-rail")).toBeNull();
conn.dispose();
});
@@ -267,14 +269,13 @@ describe("structured connection: data flow", () => {
},
});
expect(conn.el.querySelector(".structured-config-trigger")?.getAttribute("aria-label")).toBe(
"Session settings. Model: Opus. Effort: High",
);
(conn.el.querySelector(".structured-config-trigger") as HTMLButtonElement).click();
const selects = conn.el.querySelectorAll<HTMLSelectElement>(".structured-config-select");
const trigger = within(conn.el).getByRole("button", {
name: "Session settings. Model: Opus. Effort: High",
});
fireEvent.click(trigger);
const selects = within(conn.el).getAllByRole<HTMLSelectElement>("combobox");
expect(selects).toHaveLength(2);
selects[0].value = "sonnet";
selects[0].dispatchEvent(new Event("change"));
fireEvent.change(selects[0], { target: { value: "sonnet" } });
expect(setStructuredConfig).toHaveBeenCalledWith("s1", {
config_id: "model",
value: "sonnet",
@@ -329,12 +330,12 @@ describe("structured connection: data flow", () => {
},
});
const transcript = conn.el.querySelector(".structured-rows")!;
const transcript = conn.el.querySelector<HTMLElement>(".structured-rows")!;
expect([...transcript.querySelectorAll<HTMLElement>(".st-row")].map((el) => el.dataset.rowId)).toEqual([
"user:1",
"agent:1",
]);
expect(transcript.textContent).not.toContain("updated");
expect(within(transcript).queryByText(/updated/)).not.toBeInTheDocument();
ws.emit({
type: "projection_update",
@@ -359,7 +360,7 @@ describe("structured connection: data flow", () => {
expect([...transcript.querySelectorAll<HTMLElement>(".st-row")].map((el) => el.dataset.rowId)).toEqual([
"agent:1",
]);
expect(transcript.textContent).toContain("patched neighbor");
expect(within(transcript).getByText("patched neighbor")).toBeInTheDocument();
conn.dispose();
});
@@ -383,7 +384,7 @@ describe("structured connection: data flow", () => {
const rows = conn.el.querySelectorAll(".st-row-agent");
expect(rows).toHaveLength(1);
expect(rows[0].classList).not.toContain("st-row-enter");
expect(conn.el.textContent).toContain("partial done");
expect(within(conn.el).getByText("partial done")).toBeInTheDocument();
expect(conn.el.textContent).not.toContain("partial\n"); // the un-patched text is gone, not appended beside it
conn.dispose();
});
@@ -407,7 +408,7 @@ describe("structured connection: data flow", () => {
});
const groups = conn.el.querySelectorAll<HTMLElement>(".st-tool-group");
expect(groups).toHaveLength(1);
expect(groups[0].querySelector(".st-tool-group-head")!.textContent).toContain("2 tool calls");
expect(within(groups[0]).getByText(/2 tool calls/)).toBeInTheDocument();
expect(groups[0].querySelector<HTMLElement>(".st-tool-group-body")!.getAttribute("aria-hidden")).toBe("true");
expect(conn.el.querySelector('[data-row-id="tool:3"]')?.closest(".st-tool-group")).toBeNull();
conn.dispose();
@@ -436,7 +437,7 @@ describe("structured connection: data flow", () => {
const groups = conn.el.querySelectorAll<HTMLElement>(".st-tool-group");
expect(groups).toHaveLength(1);
// Header counts only tool rows, not intermediate text.
expect(groups[0].querySelector(".st-tool-group-head")!.textContent).toContain("4 tool calls");
expect(within(groups[0]).getByText(/4 tool calls/)).toBeInTheDocument();
// Intermediate text (agent:2) is inside the group.
expect(conn.el.querySelector('[data-row-id="agent:2"]')?.closest(".st-tool-group")).not.toBeNull();
// First text (agent:1) is outside — it precedes all tools.
@@ -522,7 +523,7 @@ describe("structured connection: data flow", () => {
ws.emit({ type: "reduced_state", state: idleState });
const originalHead = conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!;
originalHead.click();
fireEvent.click(originalHead);
expect(originalHead.getAttribute("aria-expanded")).toBe("true");
const clickListener = vi.fn();
window.addEventListener("click", clickListener, { capture: true, once: true });
@@ -546,7 +547,7 @@ describe("structured connection: data flow", () => {
expect(patchedHead).not.toBe(originalHead);
expect(patchedHead.getAttribute("aria-expanded")).toBe("true");
expect(conn.el.querySelector<HTMLElement>(".st-tool-out")!.hidden).toBe(false);
expect(conn.el.textContent).toContain("still running");
expect(within(conn.el).getByText(/still running/)).toBeInTheDocument();
conn.dispose();
});
@@ -564,7 +565,7 @@ describe("structured connection: data flow", () => {
ws.emit({ type: "reduced_state", state: idleState });
const originalHead = conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!;
originalHead.focus();
expect(document.activeElement).toBe(originalHead);
expect(originalHead).toHaveFocus();
ws.emit({
type: "projection_update",
@@ -577,7 +578,7 @@ describe("structured connection: data flow", () => {
const patchedHead = conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!;
expect(patchedHead).not.toBe(originalHead);
expect(document.activeElement).toBe(patchedHead);
expect(patchedHead).toHaveFocus();
conn.dispose();
});
@@ -593,7 +594,7 @@ describe("structured connection: data flow", () => {
},
});
ws.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
input.focus();
ws.emit({
@@ -605,7 +606,7 @@ describe("structured connection: data flow", () => {
state: idleState,
});
expect(document.activeElement).toBe(input);
expect(input).toHaveFocus();
conn.dispose();
});
});
@@ -640,25 +641,24 @@ describe("structured connection: turn/composer gating", () => {
},
});
ws.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
const menu = conn.el.querySelector<HTMLElement>(".structured-command-menu")!;
input.value = "/";
input.dispatchEvent(new Event("input"));
fireEvent.input(input, { target: { value: "/" } });
expect(menu.hidden).toBe(false);
expect([...menu.querySelectorAll(".structured-command-name")].map((node) => node.textContent)).toEqual([
"/blitz",
"/yolo",
]);
expect(menu.querySelector(".structured-command-hint")!.textContent).toBe("<milestone>");
expect(within(menu).getByText("<milestone>")).toBeInTheDocument();
input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
expect(input.value).toBe("/yolo ");
fireEvent.keyDown(input, { key: "ArrowDown", bubbles: true });
fireEvent.keyDown(input, { key: "Enter", bubbles: true });
expect(input).toHaveValue("/yolo ");
expect(menu.hidden).toBe(true);
expect(sendStructuredPrompt).not.toHaveBeenCalled();
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
fireEvent.keyDown(input, { key: "Enter", bubbles: true });
expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "/yolo ");
conn.dispose();
});
@@ -681,15 +681,14 @@ describe("structured connection: turn/composer gating", () => {
},
});
ws.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
input.value = "/hum";
input.dispatchEvent(new Event("input"));
const options = conn.el.querySelectorAll<HTMLElement>(".structured-command-option");
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
fireEvent.input(input, { target: { value: "/hum" } });
const options = within(conn.el).getAllByRole("option");
expect(options).toHaveLength(1);
expect(options[0].textContent).toContain("/humanizer");
options[0].click();
expect(input.value).toBe("/humanizer ");
expect(document.activeElement).toBe(input);
fireEvent.click(options[0]);
expect(input).toHaveValue("/humanizer ");
expect(input).toHaveFocus();
conn.dispose();
});
@@ -711,18 +710,18 @@ describe("structured connection: turn/composer gating", () => {
},
});
ws.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
input.value = "asd /s dsa";
input.setSelectionRange(6, 6);
input.dispatchEvent(new Event("input"));
fireEvent.input(input);
const options = conn.el.querySelectorAll<HTMLElement>(".structured-command-option");
const options = within(conn.el).getAllByRole("option");
expect([...options].map((option) => option.querySelector(".structured-command-name")!.textContent)).toEqual([
"/security-review",
"/status",
]);
options[0].click();
expect(input.value).toBe("asd /security-review dsa");
fireEvent.click(options[0]);
expect(input).toHaveValue("asd /security-review dsa");
expect(input.selectionStart).toBe(input.value.indexOf("dsa"));
expect(input.selectionEnd).toBe(input.selectionStart);
conn.dispose();
@@ -740,12 +739,12 @@ describe("structured connection: turn/composer gating", () => {
},
});
ws.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
input.value = "before /stale after";
input.setSelectionRange(9, 9);
input.dispatchEvent(new Event("input"));
conn.el.querySelector<HTMLElement>(".structured-command-option")!.click();
expect(input.value).toBe("before /status after");
fireEvent.input(input);
fireEvent.click(within(conn.el).getByRole("option"));
expect(input).toHaveValue("before /status after");
conn.dispose();
});
@@ -754,16 +753,18 @@ describe("structured connection: turn/composer gating", () => {
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
const cancel = conn.el.querySelector(".structured-cancel") as HTMLButtonElement;
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
// happy-dom computes an empty accessible name for hidden elements, so a
// role+name query can't find this button while it starts out hidden.
const cancel = conn.el.querySelector(".structured-cancel")!;
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
expect(input.disabled).toBe(false);
expect(cancel.hidden).toBe(false);
expect((conn.el.querySelector(".structured-send span") as HTMLElement).textContent).toBe("Send");
expect(input).not.toBeDisabled();
expect(cancel).toBeVisible();
expect(within(conn.el).getByRole("button", { name: "Send" })).toBeInTheDocument();
ws.emit({ type: "reduced_state", state: idleState });
expect(input.disabled).toBe(false);
expect(cancel.hidden).toBe(true);
expect((conn.el.querySelector(".structured-send span") as HTMLElement).textContent).toBe("Send");
expect(input).not.toBeDisabled();
expect(cancel).not.toBeVisible();
expect(within(conn.el).getByRole("button", { name: "Send" })).toBeInTheDocument();
conn.dispose();
});
@@ -772,7 +773,7 @@ describe("structured connection: turn/composer gating", () => {
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
const activity = conn.el.querySelector<HTMLElement>(".structured-activity")!;
const activity = within(conn.el).getByRole("status", { hidden: true });
ws.emit({
type: "reduced_state",
state: {
@@ -786,7 +787,7 @@ describe("structured connection: turn/composer gating", () => {
usage: { used: 12_400, size: 200_000 },
},
});
expect(activity.hidden).toBe(false);
expect(activity).toBeVisible();
expect(activity.textContent).toContain("Thinking · 0s · 12.4k context / 200k context");
ws.emit({
@@ -798,7 +799,7 @@ describe("structured connection: turn/composer gating", () => {
});
expect(activity.textContent).toContain("Running 2 tools");
ws.emit({ type: "reduced_state", state: idleState });
expect(activity.hidden).toBe(true);
expect(activity).not.toBeVisible();
conn.dispose();
});
@@ -808,21 +809,21 @@ describe("structured connection: turn/composer gating", () => {
ws.open();
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
ws.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
input.value = "do the thing";
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
fireEvent.keyDown(input, { key: "Enter", bubbles: true, cancelable: true });
expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "do the thing");
expect(input.value).toBe("");
expect(input).toHaveValue("");
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
ws.emit({ type: "reduced_state", state: idleState });
vi.mocked(sendStructuredPrompt).mockRejectedValueOnce(new Error("boom"));
input.value = "retry me";
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
fireEvent.keyDown(input, { key: "Enter", bubbles: true, cancelable: true });
await Promise.resolve();
await Promise.resolve();
expect(input.value).toBe("retry me");
expect(conn.el.textContent).toContain("boom");
expect(input).toHaveValue("retry me");
expect(within(conn.el).getByText(/boom/)).toBeInTheDocument();
conn.dispose();
});
@@ -833,17 +834,17 @@ describe("structured connection: turn/composer gating", () => {
ws.open();
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
ws.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
const send = conn.el.querySelector(".structured-send") as HTMLButtonElement;
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
const send = within(conn.el).getByRole("button", { name: "Send" });
const form = conn.el.querySelector(".structured-composer") as HTMLFormElement;
input.value = "only once";
form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
fireEvent.submit(form);
input.value = "queued next";
form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
fireEvent.submit(form);
expect(input.disabled).toBe(false);
expect(send.disabled).toBe(false);
expect(input).not.toBeDisabled();
expect(send).not.toBeDisabled();
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "only once");
const queued = conn.el.querySelectorAll(".st-row-queued");
@@ -857,10 +858,10 @@ describe("structured connection: turn/composer gating", () => {
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
const submit = (text: string) => {
input.value = text;
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
fireEvent.keyDown(input, { key: "Enter", bubbles: true, cancelable: true });
};
submit("first");
@@ -891,9 +892,9 @@ describe("structured connection: turn/composer gating", () => {
ws.open();
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
ws.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
input.value = "first prompt";
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
fireEvent.keyDown(input, { key: "Enter", bubbles: true, cancelable: true });
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
ws.emit({ type: "reduced_state", state: idleState });
input.value = "newer draft";
@@ -901,8 +902,8 @@ describe("structured connection: turn/composer gating", () => {
reject(new Error("late failure"));
await Promise.resolve().then(() => Promise.resolve());
expect(input.value).toBe("newer draft");
expect(conn.el.textContent).toContain("late failure");
expect(input).toHaveValue("newer draft");
expect(within(conn.el).getByText(/late failure/)).toBeInTheDocument();
conn.dispose();
});
@@ -916,21 +917,21 @@ describe("structured connection: turn/composer gating", () => {
ws.open();
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
ws.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
input.value = "first prompt";
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
fireEvent.keyDown(input, { key: "Enter", bubbles: true, cancelable: true });
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
ws.emit({ type: "reduced_state", state: idleState });
input.value = "second prompt";
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
fireEvent.keyDown(input, { key: "Enter", bubbles: true, cancelable: true });
rejectFirst(new Error("stale failure"));
await Promise.resolve().then(() => Promise.resolve());
expect(sendStructuredPrompt).toHaveBeenCalledTimes(2);
expect(input.value).toBe("");
expect(input.disabled).toBe(false);
expect(conn.el.textContent).not.toContain("stale failure");
expect(input).toHaveValue("");
expect(input).not.toBeDisabled();
expect(within(conn.el).queryByText(/stale failure/)).not.toBeInTheDocument();
conn.dispose();
});
@@ -943,16 +944,16 @@ describe("structured connection: turn/composer gating", () => {
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
input.value = "do not restore";
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
fireEvent.keyDown(input, { key: "Enter", bubbles: true, cancelable: true });
conn.dispose();
input.value = "detached draft";
reject(new Error("late failure"));
await Promise.resolve().then(() => Promise.resolve());
expect(input.value).toBe("detached draft");
expect(input).toHaveValue("detached draft");
expect(conn.el.querySelector(".structured-error")).toBeNull();
});
@@ -962,7 +963,7 @@ describe("structured connection: turn/composer gating", () => {
ws.open();
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: true, active_tool_calls: [] } } });
(conn.el.querySelector(".structured-cancel") as HTMLButtonElement).click();
fireEvent.click(within(conn.el).getByRole("button", { name: "Cancel turn" }));
expect(cancelStructuredTurn).toHaveBeenCalledWith("s1");
conn.dispose();
});
@@ -984,9 +985,9 @@ describe("structured connection: approvals", () => {
},
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
});
const buttons = conn.el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
const buttons = within(conn.el).getAllByRole("button", { name: /^(Allow once|Allow for session|Deny)$/ });
expect(buttons).toHaveLength(3);
buttons[1].click(); // "Allow for session"
fireEvent.click(buttons[1]); // "Allow for session"
expect(resolveStructuredApproval).toHaveBeenCalledWith("s1", "n1", "allow_for_session");
conn.dispose();
});
@@ -1003,7 +1004,7 @@ describe("structured connection: approvals", () => {
type: "reduced_state",
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
});
expect(conn.el.querySelectorAll(".st-approval-acts button")).toHaveLength(3);
expect(within(conn.el).getAllByRole("button", { name: /^(Allow once|Allow for session|Deny)$/ })).toHaveLength(3);
// Resolved on another client: state clears with no transcript delta at all.
ws.emit({ type: "reduced_state", state: idleState });
expect(conn.el.querySelector(".st-approval-acts")).toBeNull();
@@ -1032,10 +1033,10 @@ describe("structured connection: approvals", () => {
// 2+ tools in the turn → tool group exists; resolved approval is inside it.
const group = conn.el.querySelector(".st-tool-group");
expect(group).not.toBeNull();
const compact = group!.querySelector(".st-approval-compact");
const compact = group!.querySelector<HTMLElement>(".st-approval-compact");
expect(compact).not.toBeNull();
expect(compact!.textContent).toContain("Write file.ts");
expect(compact!.textContent).toContain("approved");
expect(within(compact!).getByText(/Write file\.ts/)).toBeInTheDocument();
expect(within(compact!).getByText(/approved/)).toBeInTheDocument();
// No full-card approval anywhere on the transcript.
expect(conn.el.querySelector(".st-approval")).toBeNull();
conn.dispose();
@@ -1064,7 +1065,9 @@ describe("structured connection: approvals", () => {
// Pending approval stays outside the tool group as a full card.
const approvalCard = conn.el.querySelector(".st-approval");
expect(approvalCard).not.toBeNull();
expect(conn.el.querySelectorAll(".st-approval-acts button").length).toBeGreaterThan(0);
expect(
within(conn.el).getAllByRole("button", { name: /^(Allow once|Allow for session|Deny)$/ }).length,
).toBeGreaterThan(0);
// It should not be inside the tool group.
const group = conn.el.querySelector(".st-tool-group");
expect(group).not.toBeNull();
@@ -1084,12 +1087,12 @@ describe("structured connection: approvals", () => {
type: "reduced_state",
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
});
const buttons = conn.el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
const buttons = within(conn.el).getAllByRole("button", { name: /^(Allow once|Allow for session|Deny)$/ });
// Approval is superseded before the click is handled (buttons already
// disabled by the click handler is not the path under test here — this
// simulates the row having already flipped to "answered" underneath it).
ws.emit({ type: "reduced_state", state: idleState });
buttons[0].dispatchEvent(new MouseEvent("click", { bubbles: true }));
fireEvent.click(buttons[0]);
expect(resolveStructuredApproval).not.toHaveBeenCalled();
conn.dispose();
});
@@ -1107,13 +1110,13 @@ describe("structured connection: approvals", () => {
type: "reduced_state",
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
});
const buttons = conn.el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
buttons[0].click();
expect(buttons[0].disabled).toBe(true);
const buttons = within(conn.el).getAllByRole("button", { name: /^(Allow once|Allow for session|Deny)$/ });
fireEvent.click(buttons[0]);
expect(buttons[0]).toBeDisabled();
await Promise.resolve().then(() => Promise.resolve()).then(() => Promise.resolve());
expect(buttons[0].disabled).toBe(false);
expect(buttons[1].disabled).toBe(false);
expect(conn.el.textContent).toContain("network down");
expect(buttons[0]).not.toBeDisabled();
expect(buttons[1]).not.toBeDisabled();
expect(within(conn.el).getByText(/network down/)).toBeInTheDocument();
conn.dispose();
});
@@ -1133,8 +1136,8 @@ describe("structured connection: approvals", () => {
type: "reduced_state",
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
});
const buttons = conn.el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
buttons[0].click();
const buttons = within(conn.el).getAllByRole("button", { name: /^(Allow once|Allow for session|Deny)$/ });
fireEvent.click(buttons[0]);
expect(resolveStructuredApproval).toHaveBeenCalledWith("s1", "n1", "allow_once");
// Superseded by a new approval before the in-flight POST settles.
@@ -1146,10 +1149,10 @@ describe("structured connection: approvals", () => {
reject(new Error("too late"));
await Promise.resolve().then(() => Promise.resolve()).then(() => Promise.resolve());
expect(conn.el.textContent).not.toContain("too late");
const newButtons = conn.el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
expect(within(conn.el).queryByText(/too late/)).not.toBeInTheDocument();
const newButtons = within(conn.el).getAllByRole("button", { name: /^(Allow once|Allow for session|Deny)$/ });
expect(newButtons).toHaveLength(3);
expect(newButtons[0].disabled).toBe(false);
expect(newButtons[0]).not.toBeDisabled();
conn.dispose();
});
});
@@ -1175,7 +1178,7 @@ describe("structured connection: reconnection/error/loading", () => {
type: "transcript_snapshot",
snapshot: { cursor: 0, rows: [row("user:1", "user", { content: { type: "text", text: "before" } })] },
});
expect(conn.el.textContent).toContain("before");
expect(within(conn.el).getByText("before")).toBeInTheDocument();
first.drop(1006, "");
expect(state()).toBe("connecting"); // "reconnecting…"
@@ -1184,7 +1187,7 @@ describe("structured connection: reconnection/error/loading", () => {
second.open();
expect(state()).toBe("open");
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
expect(conn.el.textContent).not.toContain("before"); // rebuilt wholesale, not merged
expect(within(conn.el).queryByText("before")).not.toBeInTheDocument(); // rebuilt wholesale, not merged
conn.dispose();
});
@@ -1238,7 +1241,7 @@ describe("structured connection: reconnection/error/loading", () => {
type: "transcript_snapshot",
snapshot: { cursor: 2, rows: [toolRow("tool:1", "before"), diffRow("diff:1", "first")] },
});
for (const head of conn.el.querySelectorAll<HTMLButtonElement>(".st-tool-head")) head.click();
for (const head of conn.el.querySelectorAll<HTMLButtonElement>(".st-tool-head")) fireEvent.click(head);
first.drop();
vi.advanceTimersByTime(1000);
@@ -1272,7 +1275,7 @@ describe("structured connection: reconnection/error/loading", () => {
type: "transcript_snapshot",
snapshot: { cursor: 2, rows: [toolRow("tool:gone", "gone"), toolRow("tool:plain", "plain")] },
});
for (const head of conn.el.querySelectorAll<HTMLButtonElement>(".st-tool-head")) head.click();
for (const head of conn.el.querySelectorAll<HTMLButtonElement>(".st-tool-head")) fireEvent.click(head);
ws.emit({
type: "transcript_snapshot",
@@ -1300,20 +1303,20 @@ describe("structured connection: reconnection/error/loading", () => {
first.open();
first.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
first.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
input.value = "survive reconnect";
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
fireEvent.keyDown(input, { key: "Enter", bubbles: true, cancelable: true });
first.drop();
vi.advanceTimersByTime(1000);
second.open();
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
second.emit({ type: "reduced_state", state: idleState });
expect(input.disabled).toBe(false);
expect(input).not.toBeDisabled();
second.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
second.emit({ type: "reduced_state", state: idleState });
expect(input.disabled).toBe(false);
expect(input).not.toBeDisabled();
conn.dispose();
});
@@ -1325,9 +1328,9 @@ describe("structured connection: reconnection/error/loading", () => {
first.open();
first.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
first.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
input.value = "finishes while disconnected";
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
fireEvent.keyDown(input, { key: "Enter", bubbles: true, cancelable: true });
await Promise.resolve().then(() => Promise.resolve());
first.drop();
@@ -1336,7 +1339,7 @@ describe("structured connection: reconnection/error/loading", () => {
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
second.emit({ type: "reduced_state", state: idleState });
expect(input.disabled).toBe(false);
expect(input).not.toBeDisabled();
conn.dispose();
});
@@ -1352,27 +1355,27 @@ describe("structured connection: reconnection/error/loading", () => {
first.open();
first.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
first.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
input.value = "accepted after reconnect";
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
fireEvent.keyDown(input, { key: "Enter", bubbles: true, cancelable: true });
first.drop();
vi.advanceTimersByTime(1000);
second.open();
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
second.emit({ type: "reduced_state", state: idleState });
expect(input.disabled).toBe(false);
expect(input).not.toBeDisabled();
accept();
await Promise.resolve().then(() => Promise.resolve());
expect(input.disabled).toBe(false);
expect(input).not.toBeDisabled();
expect(second.closed).toBe(true);
third.open();
third.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
third.emit({ type: "reduced_state", state: idleState });
expect(input.disabled).toBe(false);
expect(input).not.toBeDisabled();
conn.dispose();
});
@@ -1391,9 +1394,9 @@ describe("structured connection: reconnection/error/loading", () => {
});
first.open();
first.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
input.value = "accepted during backoff";
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
fireEvent.keyDown(input, { key: "Enter", bubbles: true, cancelable: true });
first.drop();
vi.advanceTimersByTime(1000);
@@ -1407,7 +1410,7 @@ describe("structured connection: reconnection/error/loading", () => {
reconciliation.open();
reconciliation.emit({ type: "reduced_state", state: idleState });
expect(input.disabled).toBe(false);
expect(input).not.toBeDisabled();
conn.dispose();
expect(reconciliation.closed).toBe(true);
});
@@ -1423,9 +1426,9 @@ describe("structured connection: reconnection/error/loading", () => {
const conn = mount({ sockets: [first, second, late], shouldReconnect: () => true });
first.open();
first.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
input.value = "accepted after disposal";
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
fireEvent.keyDown(input, { key: "Enter", bubbles: true, cancelable: true });
first.drop();
vi.advanceTimersByTime(1000);
@@ -1595,11 +1598,10 @@ describe("structured connection: transcript scrolling", () => {
runFrames();
values.top = top;
if (manuallyScrolled) scroll.dispatchEvent(new Event("scroll"));
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
if (manuallyScrolled) fireEvent.scroll(scroll);
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
Object.defineProperty(input, "scrollHeight", { configurable: true, value: 120 });
input.value = "one\ntwo\nthree";
input.dispatchEvent(new Event("input"));
fireEvent.input(input, { target: { value: "one\ntwo\nthree" } });
values.height = 1080;
runFrames();
@@ -1617,7 +1619,7 @@ describe("structured connection: transcript scrolling", () => {
runFrames();
values.top = 180;
scroll.dispatchEvent(new Event("scroll"));
fireEvent.scroll(scroll);
values.height = 1120;
ws.emit({
type: "projection_update",
@@ -1645,7 +1647,7 @@ describe("structured connection: transcript scrolling", () => {
runFrames();
expect(values.top).toBe(180);
conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!.click();
fireEvent.click(conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!);
values.height = 1260;
runFrames();
expect(values.top).toBe(180);
@@ -1661,7 +1663,7 @@ describe("structured connection: transcript scrolling", () => {
runFrames();
values.top = 750;
scroll.dispatchEvent(new Event("scroll"));
fireEvent.scroll(scroll);
values.height = 1100;
ws.emit({
type: "projection_update",
@@ -1686,7 +1688,7 @@ describe("structured connection: transcript scrolling", () => {
runFrames();
values.top = 160;
scroll.dispatchEvent(new Event("scroll"));
fireEvent.scroll(scroll);
first.drop();
vi.advanceTimersByTime(1000);
second.open();
@@ -1717,9 +1719,9 @@ describe("structured connection: transcript scrolling", () => {
conn.onUnmount();
expect(frames.size).toBe(0);
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
Object.defineProperty(input, "scrollHeight", { configurable: true, value: 120 });
input.dispatchEvent(new Event("input"));
fireEvent.input(input);
expect(frames.size).toBe(0);
conn.onMount({ focus: false });
expect(frames.size).toBe(2);
@@ -1753,7 +1755,7 @@ describe("structured connection: pane lifecycle", () => {
it("carries the same off-canvas sessions button as the terminal attach header", () => {
const conn = mount({ sockets: [new FakeSocket()] });
const btn = conn.el.querySelector(".sessions-btn") as HTMLButtonElement;
const btn = within(conn.el).getByRole<HTMLButtonElement>("button", { name: "Show sessions" });
expect(btn).not.toBeNull();
expect(btn.getAttribute("aria-label")).toBe("Show sessions");
expect(btn.type).toBe("button");
@@ -1761,7 +1763,7 @@ describe("structured connection: pane lifecycle", () => {
let toggled = 0;
const onToggle = () => toggled++;
document.addEventListener(TOGGLE_DRAWER_EVENT, onToggle);
btn.click();
fireEvent.click(btn);
document.removeEventListener(TOGGLE_DRAWER_EVENT, onToggle);
expect(toggled).toBe(1);
conn.dispose();
@@ -1774,11 +1776,11 @@ describe("structured connection: pane lifecycle", () => {
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
conn.focusTerminal();
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
expect(document.activeElement).toBe(input);
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
expect(input).toHaveFocus();
ws.emit({ type: "reduced_state", state: idleState });
conn.focusTerminal();
expect(document.activeElement).toBe(input);
expect(input).toHaveFocus();
conn.dispose();
});
@@ -1821,8 +1823,8 @@ describe("structured connection: deferred mount focus", () => {
conn.onUnmount(); // parked again before the frame below runs
runFrames();
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
expect(document.activeElement).not.toBe(input);
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
expect(input).not.toHaveFocus();
conn.dispose();
});
@@ -1838,8 +1840,8 @@ describe("structured connection: deferred mount focus", () => {
conn.onMount({ focus: false }); // the pane that actually won focus, without it
runFrames(); // both frames fire; only the latest should have any say
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
expect(document.activeElement).not.toBe(input);
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
expect(input).not.toHaveFocus();
conn.dispose();
});
@@ -1853,8 +1855,8 @@ describe("structured connection: deferred mount focus", () => {
conn.onMount({ focus: true });
runFrames();
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
expect(document.activeElement).toBe(input);
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
expect(input).toHaveFocus();
conn.dispose();
});
});
+115 -94
View File
@@ -1,5 +1,6 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from "vitest";
import { fireEvent, waitFor, within } from "@testing-library/react";
import {
buildToolGroup,
@@ -23,9 +24,10 @@ describe("buildTranscriptRow: user/agent/thought", () => {
row("user", { content: { type: "text", text: "hi <b>there</b>" } }),
);
expect(el.className).toContain("st-row-user");
expect(el.textContent).toContain("hi <b>there</b>");
expect(within(el).getByText("hi <b>there</b>")).toBeTruthy();
// Neither element exists at all — no accessible text/role to query by, so
// this stays a direct DOM check for "never actual markup from user input".
expect(el.querySelector(".st-who")).toBeNull();
// never actual markup from user input
expect(el.querySelector("b")).toBeNull();
});
@@ -34,7 +36,7 @@ describe("buildTranscriptRow: user/agent/thought", () => {
row("agent_text", { content: { type: "text", text: "one **two** `three`\n\nfour" } }),
);
expect(el.className).toContain("st-row-agent");
const ps = el.querySelectorAll("p");
const ps = within(el).getAllByRole("paragraph");
expect(ps).toHaveLength(2);
expect(ps[0].innerHTML).toContain("<strong>two</strong>");
expect(ps[0].innerHTML).toContain("<code>three</code>");
@@ -48,18 +50,20 @@ describe("buildTranscriptRow: user/agent/thought", () => {
);
expect(el.className).toContain("st-row-thought");
expect(el.textContent).toBe("Planning comply tool commentary update");
expect(el.querySelector("strong")?.textContent).toBe("Planning comply tool commentary update");
expect(
within(el).getByText("Planning comply tool commentary update", { selector: "strong" }),
).toBeTruthy();
});
it("falls back to a labelled placeholder for non-text content, never crashing", () => {
const el = buildTranscriptRow(row("user", { content: { type: "image" } }));
expect(el.textContent).toContain("[image]");
expect(within(el).getByText("[image]")).toBeTruthy();
});
it("never clips text: a very long user message is not truncated", () => {
const long = "x".repeat(5000);
const el = buildTranscriptRow(row("user", { content: { type: "text", text: long } }));
expect(el.textContent).toContain(long);
expect(within(el).getByText(long)).toBeTruthy();
});
it("copies message blocks on click and supports keyboard activation", async () => {
@@ -69,12 +73,10 @@ describe("buildTranscriptRow: user/agent/thought", () => {
row("agent_text", { content: { type: "text", text: "copy **this** exactly" } }),
);
expect(el.getAttribute("role")).toBe("button");
el.click();
await Promise.resolve();
expect(writeText).toHaveBeenCalledWith("copy **this** exactly");
el.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
await Promise.resolve();
expect(writeText).toHaveBeenCalledTimes(2);
fireEvent.click(el);
await waitFor(() => expect(writeText).toHaveBeenCalledWith("copy **this** exactly"));
fireEvent.keyDown(el, { key: "Enter" });
await waitFor(() => expect(writeText).toHaveBeenCalledTimes(2));
});
});
@@ -116,29 +118,30 @@ describe("buildTranscriptRow: tool calls", () => {
status: "completed",
}),
);
const head = el.querySelector(".st-tool-head") as HTMLButtonElement;
expect(head.querySelector(".st-tool-name")!.textContent).toBe("Read");
expect(head.querySelector(".st-tool-arg")!.textContent).toBe("src/lib.rs");
const head = within(el).getByRole("button");
expect(within(head).getByText("Read")).toBeTruthy();
expect(within(head).getByText("src/lib.rs")).toBeTruthy();
// No badge text/role exists at all in this case — nothing accessible to
// query for its absence, so this stays a direct DOM check.
expect(head.querySelector(".st-tool-meta")).toBeNull();
});
it("falls back to the kind label and full title when the title has no Name(arg) shape", () => {
const el = buildTranscriptRow(row("tool_call", { title: "does something odd", kind: "execute" }));
const head = el.querySelector(".st-tool-head")!;
expect(head.querySelector(".st-tool-name")!.textContent).toBe("Execute");
expect(head.querySelector(".st-tool-arg")!.textContent).toBe("does something odd");
const head = within(el).getByRole("button");
expect(within(head).getByText("Execute")).toBeTruthy();
expect(within(head).getByText("does something odd")).toBeTruthy();
});
it("shows a running badge for an in-progress call", () => {
const el = buildTranscriptRow(row("tool_call", { title: "Bash(sleep 1)", status: "in_progress" }));
const meta = el.querySelector(".st-tool-meta")!;
expect(meta.textContent).toBe("running");
const meta = within(el).getByText("running");
expect(meta.className).toContain("st-tool-meta-running");
});
it("shows a failed badge for a failed call", () => {
const el = buildTranscriptRow(row("tool_call", { title: "Bash(false)", status: "failed" }));
expect(el.querySelector(".st-tool-meta")!.textContent).toBe("failed");
expect(within(el).getByText("failed")).toBeTruthy();
});
it("starts collapsed and expands its text output on click", () => {
@@ -149,15 +152,17 @@ describe("buildTranscriptRow: tool calls", () => {
content: [{ type: "content", content: { type: "text", text: "match found" } }],
}),
);
const head = el.querySelector(".st-tool-head") as HTMLButtonElement;
const head = within(el).getByRole("button");
// The disclosure wrapper has no accessible role or text of its own before
// it is expanded, so it's located and inspected via raw DOM throughout.
const out = el.querySelector(".st-tool-out") as HTMLElement;
expect(out.hidden).toBe(true);
expect(head.getAttribute("aria-expanded")).toBe("false");
head.click();
fireEvent.click(head);
expect(out.hidden).toBe(false);
expect(head.getAttribute("aria-expanded")).toBe("true");
expect(out.textContent).toContain("match found");
head.click();
expect(within(out).getByText("match found")).toBeTruthy();
fireEvent.click(head);
expect(out.hidden).toBe(false);
expect(out.getAttribute("aria-hidden")).toBe("true");
expect(out.inert).toBe(true);
@@ -166,8 +171,8 @@ describe("buildTranscriptRow: tool calls", () => {
it("does not expand when there is nothing to show", () => {
const el = buildTranscriptRow(row("tool_call", { title: "Read(x)", status: "completed" }));
const head = el.querySelector(".st-tool-head") as HTMLButtonElement;
head.click();
const head = within(el).getByRole("button");
fireEvent.click(head);
expect(head.getAttribute("aria-expanded")).toBe("false");
});
@@ -179,10 +184,13 @@ describe("buildTranscriptRow: tool calls", () => {
rawOutput: { formatted_output: "ok", exit_code: 0 },
}),
);
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
expect(el.querySelector(".st-tool-detail pre")!.textContent).toBe("printf ok");
expect(el.querySelector(".st-tool-fact")!.textContent).toBe("Exit code0");
const raw = el.querySelector(".st-tool-raw") as HTMLDetailsElement;
fireEvent.click(within(el).getByRole("button"));
expect(within(el).getByText("printf ok")).toBeTruthy();
// "Exit code" and its value are separate elements with no shared text
// node, so getByText can't match "Exit code0" — locate via the label.
const fact = within(el).getByText("Exit code").closest(".st-tool-fact");
expect(fact?.textContent).toBe("Exit code0");
const raw = within(el).getByText("Raw JSON").closest("details") as HTMLDetailsElement;
expect(raw.open).toBe(false);
expect(raw.textContent).toContain('"timeout_ms": 1000');
expect(raw.textContent).toContain('"exit_code": 0');
@@ -196,14 +204,13 @@ describe("buildTranscriptRow: tool calls", () => {
rawOutput: { result: { count: 2 }, error: null },
}),
);
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
const labels = [
...el.querySelectorAll(
".st-tool-out > .st-disclosure-clip > .st-disclosure-inner > .st-tool-detail",
),
].map((node) => node.querySelector(".st-tool-detail-label")?.textContent);
fireEvent.click(within(el).getByRole("button"));
const labels = within(el)
.getAllByText(/^(Arguments|Result)$/)
.map((node) => node.textContent);
expect(labels).toEqual(["Arguments", "Result"]);
expect(el.querySelector(".st-tool-raw")!.textContent).toContain('"server": "github"');
const raw = within(el).getByText("Raw JSON").closest("details");
expect(raw?.textContent).toContain('"server": "github"');
});
it("renders a recognized web search query", () => {
@@ -213,23 +220,26 @@ describe("buildTranscriptRow: tool calls", () => {
rawInput: { type: "webSearch", query: "Agent Client Protocol", action: { type: "search" } },
}),
);
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
expect(el.querySelector(".st-tool-detail-label")!.textContent).toBe("Query");
expect(el.querySelector(".st-tool-detail pre")!.textContent).toBe("Agent Client Protocol");
fireEvent.click(within(el).getByRole("button"));
expect(within(el).getByText("Query")).toBeTruthy();
expect(within(el).getByText("Agent Client Protocol")).toBeTruthy();
});
it("falls back to raw JSON for an unknown provider shape", () => {
const el = buildTranscriptRow(
row("tool_call", { title: "Custom", rawInput: { peculiar: [1, 2] }, rawOutput: "done" }),
);
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
fireEvent.click(within(el).getByRole("button"));
// No provider-specific detail block has any known label to query by when
// none matched — asserting the absence of the whole class stays raw DOM.
expect(
el.querySelectorAll(
".st-tool-out > .st-disclosure-clip > .st-disclosure-inner > .st-tool-detail",
),
).toHaveLength(0);
expect(el.querySelector(".st-tool-raw")!.textContent).toContain('"peculiar"');
expect(el.querySelector(".st-tool-raw")!.textContent).toContain('"done"');
const raw = within(el).getByText("Raw JSON").closest("details");
expect(raw?.textContent).toContain('"peculiar"');
expect(raw?.textContent).toContain('"done"');
});
it("renders standardized content before provider-specific and raw data", () => {
@@ -245,7 +255,9 @@ describe("buildTranscriptRow: tool calls", () => {
rawOutput: { formatted_output: "typed result", exit_code: 0 },
}),
);
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
fireEvent.click(within(el).getByRole("button"));
// Ordering across block kinds has no accessible query of its own — this
// stays a direct DOM structure check.
const inner = el.querySelector(".st-tool-out > .st-disclosure-clip > .st-disclosure-inner")!;
expect([...inner.children].map((node) => node.className)).toEqual([
"st-tool-text st-copyable",
@@ -255,8 +267,8 @@ describe("buildTranscriptRow: tool calls", () => {
"st-tool-fact",
"st-tool-raw",
]);
expect(el.querySelector(".st-tool-resource")!.textContent).toContain("file:///tmp/report");
expect(el.querySelector(".st-tool-terminal")!.textContent).toBe("Terminal · term-1");
expect(within(el).getByText("file:///tmp/report")).toBeTruthy();
expect(within(el).getByText("Terminal · term-1")).toBeTruthy();
});
it("renders safe typed image and audio blocks as media controls", () => {
@@ -269,9 +281,14 @@ describe("buildTranscriptRow: tool calls", () => {
],
}),
);
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
expect((el.querySelector(".st-tool-image") as HTMLImageElement).src).toBe("data:image/png;base64,aW1hZ2U=");
expect((el.querySelector(".st-tool-audio") as HTMLAudioElement).src).toBe("data:audio/mpeg;base64,YXVkaW8=");
fireEvent.click(within(el).getByRole("button"));
expect((within(el).getByRole("img") as HTMLImageElement).src).toBe(
"data:image/png;base64,aW1hZ2U=",
);
// <audio> has no ARIA role mapping, so it isn't reachable via getByRole.
expect((el.querySelector(".st-tool-audio") as HTMLAudioElement).src).toBe(
"data:audio/mpeg;base64,YXVkaW8=",
);
});
});
@@ -284,19 +301,21 @@ describe("buildToolGroup", () => {
const children = rows.map((tool) => buildTranscriptRow(tool));
const onExpanded = vi.fn();
const group = buildToolGroup("turn:u1", rows, children, false, onExpanded);
const head = group.querySelector<HTMLButtonElement>(".st-tool-group-head")!;
const head = within(group).getByRole("button", { name: "2 tool calls" });
// The body has no accessible role/text of its own while collapsed, so its
// disclosure state is inspected via raw DOM.
const body = group.querySelector<HTMLElement>(".st-tool-group-body")!;
expect(head.textContent).toContain("2 tool calls");
expect(body.getAttribute("aria-hidden")).toBe("true");
expect(body.inert).toBe(true);
head.click();
fireEvent.click(head);
expect(body.getAttribute("aria-hidden")).toBe("false");
expect(body.classList.contains("is-expanded")).toBe(true);
expect(onExpanded).toHaveBeenCalledWith(true);
const toolHeads = group.querySelectorAll<HTMLButtonElement>(".st-tool-head");
toolHeads[1].click();
expect(toolHeads[0].getAttribute("aria-expanded")).toBe("false");
expect(toolHeads[1].getAttribute("aria-expanded")).toBe("true");
const firstHead = within(group).getByRole("button", { name: "Read a.ts" });
const secondHead = within(group).getByRole("button", { name: "Bash test" });
fireEvent.click(secondHead);
expect(firstHead.getAttribute("aria-expanded")).toBe("false");
expect(secondHead.getAttribute("aria-expanded")).toBe("true");
});
});
@@ -309,14 +328,10 @@ describe("buildTranscriptRow: tool diffs", () => {
content: [{ type: "diff", path: "/repo/x.rs", oldText: "a\nb\n", newText: "a\nc\n" }],
}),
);
const meta = el.querySelector(".st-tool-meta")!;
expect(meta.textContent).toContain("+1");
expect(meta.textContent).toContain("−1");
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
const diff = el.querySelector(".st-diff") as HTMLElement;
expect(diff).not.toBeNull();
expect(diff.querySelector(".st-ln-add")!.textContent).toBe("+ c");
expect(diff.querySelector(".st-ln-del")!.textContent).toBe("- b");
expect(within(el).getByText("+1 −1")).toBeTruthy();
fireEvent.click(within(el).getByRole("button"));
expect(within(el).getByText("+ c")).toBeTruthy();
expect(within(el).getByText("- b")).toBeTruthy();
});
it("gives the diff its own horizontally-scrolling container", () => {
@@ -326,9 +341,10 @@ describe("buildTranscriptRow: tool diffs", () => {
content: [{ type: "diff", path: "/repo/x.rs", newText: "new file\n" }],
}),
);
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
fireEvent.click(within(el).getByRole("button"));
// jsdom/happy-dom doesn't compute layout CSS, so assert the class the
// stylesheet keys `overflow-x: auto` off, not a computed style.
// stylesheet keys `overflow-x: auto` off, not a computed style. The
// scroller itself has no accessible role/text, hence the raw query.
expect(el.querySelector(".st-diff")!.classList).toContain("st-diff");
});
@@ -339,8 +355,8 @@ describe("buildTranscriptRow: tool diffs", () => {
content: [{ type: "diff", path: "/repo/x.rs", oldText: "same\n", newText: "same" }],
}),
);
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
expect(el.querySelector(".st-ln-eof")!.textContent).toBe("\\ No newline at end of file");
fireEvent.click(within(el).getByRole("button"));
expect(within(el).getByText("\\ No newline at end of file")).toBeTruthy();
});
});
@@ -363,9 +379,8 @@ describe("buildTranscriptRow: approval", () => {
pending: true,
onDecide: () => Promise.resolve(),
});
const buttons = el.querySelectorAll(".st-approval-acts button");
expect(buttons).toHaveLength(3);
expect(el.querySelector(".st-approval-path")!.textContent).toBe("/etc/x");
expect(within(el).getAllByRole("button")).toHaveLength(3);
expect(within(el).getByText("/etc/x")).toBeTruthy();
});
it("renders a compact one-liner once resolved", () => {
@@ -373,7 +388,7 @@ describe("buildTranscriptRow: approval", () => {
pending: false,
onDecide: () => Promise.resolve(),
});
expect(el.querySelector(".st-approval-acts")).toBeNull();
expect(within(el).queryByRole("button")).toBeNull();
expect(el.classList.contains("st-approval-compact")).toBe(true);
expect(el.textContent).toContain("approved");
});
@@ -381,15 +396,16 @@ describe("buildTranscriptRow: approval", () => {
it("invokes onDecide with the clicked decision and disables the buttons", () => {
const onDecide = vi.fn().mockResolvedValue(undefined);
const el = buildTranscriptRow(approvalRow(allThreeOptions), { pending: true, onDecide });
const buttons = el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
buttons[2].click();
fireEvent.click(within(el).getByRole("button", { name: "Deny" }));
expect(onDecide).toHaveBeenCalledWith("deny");
expect(buttons[0].disabled).toBe(true);
expect(within(el).getByRole<HTMLButtonElement>("button", { name: "Allow once" }).disabled).toBe(
true,
);
});
it("treats a row with no approval state as not pending, defensively", () => {
const el = buildTranscriptRow(approvalRow(allThreeOptions));
expect(el.querySelector(".st-approval-acts")).toBeNull();
expect(within(el).queryByRole("button")).toBeNull();
});
it("omits Allow for session when the request offers no allow_always option", () => {
@@ -400,7 +416,9 @@ describe("buildTranscriptRow: approval", () => {
]),
{ pending: true, onDecide: () => Promise.resolve() },
);
const labels = [...el.querySelectorAll(".st-approval-acts button")].map((b) => b.textContent);
const labels = within(el)
.getAllByRole("button")
.map((b) => b.textContent);
expect(labels).toEqual(["Allow once", "Deny"]);
});
@@ -409,9 +427,11 @@ describe("buildTranscriptRow: approval", () => {
approvalRow([{ optionId: "r2", name: "Reject always", kind: "reject_always" }]),
{ pending: true, onDecide: () => Promise.resolve() },
);
expect([...rejectAlwaysOnly.querySelectorAll(".st-approval-acts button")].map((b) => b.textContent)).toEqual([
"Deny",
]);
expect(
within(rejectAlwaysOnly)
.getAllByRole("button")
.map((b) => b.textContent),
).toEqual(["Deny"]);
});
it("shows a 'no decision offered' note instead of an empty action row when nothing matches", () => {
@@ -421,31 +441,32 @@ describe("buildTranscriptRow: approval", () => {
});
// allow_once is offered, so this specific case still shows a button —
// use a kind the client doesn't map to any decision at all.
expect(el.querySelectorAll(".st-approval-acts button")).toHaveLength(1);
expect(within(el).getAllByRole("button")).toHaveLength(1);
const none = buildTranscriptRow(approvalRow([]), { pending: true, onDecide: () => Promise.resolve() });
expect(none.querySelector(".st-approval-acts")).toBeNull();
expect(none.querySelector(".st-approval-resolved")!.textContent).toBe("no decision offered");
expect(within(none).queryByRole("button")).toBeNull();
expect(within(none).getByText("no decision offered")).toBeTruthy();
});
it("re-enables the buttons when onDecide rejects", async () => {
const onDecide = vi.fn().mockRejectedValue(new Error("network down"));
const el = buildTranscriptRow(approvalRow(allThreeOptions), { pending: true, onDecide });
const buttons = el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
buttons[0].click();
expect(buttons[0].disabled).toBe(true);
await Promise.resolve().then(() => Promise.resolve());
expect(buttons[0].disabled).toBe(false);
expect(buttons[1].disabled).toBe(false);
const allowOnce = within(el).getByRole<HTMLButtonElement>("button", { name: "Allow once" });
const allowSession = within(el).getByRole<HTMLButtonElement>("button", {
name: "Allow for session",
});
fireEvent.click(allowOnce);
expect(allowOnce.disabled).toBe(true);
await waitFor(() => expect(allowOnce.disabled).toBe(false));
expect(allowSession.disabled).toBe(false);
});
it("leaves the buttons disabled when onDecide resolves", async () => {
const onDecide = vi.fn().mockResolvedValue(undefined);
const el = buildTranscriptRow(approvalRow(allThreeOptions), { pending: true, onDecide });
const buttons = el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
buttons[0].click();
await Promise.resolve().then(() => Promise.resolve());
expect(buttons[0].disabled).toBe(true);
const allowOnce = within(el).getByRole<HTMLButtonElement>("button", { name: "Allow once" });
fireEvent.click(allowOnce);
await waitFor(() => expect(allowOnce.disabled).toBe(true));
});
});
+2 -2
View File
@@ -1,5 +1,6 @@
// @vitest-environment happy-dom
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
import { fireEvent } from "@testing-library/react";
import {
createPaletteSelect,
@@ -66,8 +67,7 @@ describe("palette selection", () => {
expect([...select.options].map((o) => o.value)).toEqual([...PALETTES]);
expect(select.value).toBe("graphite");
select.value = "periwinkle";
select.dispatchEvent(new Event("change"));
fireEvent.change(select, { target: { value: "periwinkle" } });
expect(document.documentElement.dataset.palette).toBe("periwinkle");
expect(localStorage.getItem("maestro-palette")).toBe("periwinkle");
});
+31 -27
View File
@@ -2,6 +2,7 @@
// Second step of the working-directory picker (#95). See the interaction rules
// block in spawnForm.ts for what each choice means.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, within } from "@testing-library/react";
import { createSpawnForm, type SpawnFormHandle, type SpawnFormValues } from "./spawnForm.js";
import type { Project } from "./types.js";
@@ -86,23 +87,32 @@ describe("spawnForm worktree step (#95)", () => {
return handle.el;
}
// The worktree step lives inside the collapsed "Advanced" section, which
// these tests never expand — `{ hidden: true }` reaches into it, the way
// the plain DOM queries these replaced always did.
const step = (form: HTMLFormElement) => form.querySelector<HTMLElement>(".wt-step")!;
const choices = (form: HTMLFormElement) => [
...form.querySelectorAll<HTMLButtonElement>(".wt-choice"),
];
const choices = (form: HTMLFormElement) =>
within(form)
.getAllByRole("button", { hidden: true })
.filter((b): b is HTMLButtonElement => b.classList.contains("wt-choice"));
const activeChoice = (form: HTMLFormElement) =>
form.querySelector<HTMLButtonElement>(".wt-choice.is-active");
const box = (form: HTMLFormElement, path: string) =>
form.querySelector<HTMLButtonElement>(`.project-box[data-cwd="${path}"]`)!;
const toggle = (form: HTMLFormElement) =>
form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!;
within(form).getByRole("checkbox", {
name: "Create git worktree",
hidden: true,
}) as HTMLInputElement;
const otherBtn = (form: HTMLFormElement) =>
within(form).getByRole("button", { name: "Other directory…" });
it("hides the worktree step until a project is picked", async () => {
const form = await mount([MAESTRO, WT_A]);
expect(step(form).hidden).toBe(true);
expect(choices(form)).toEqual([]);
box(form, MAESTRO.path).click();
fireEvent.click(box(form, MAESTRO.path));
await drain();
expect(step(form).hidden).toBe(false);
@@ -110,7 +120,7 @@ describe("spawnForm worktree step (#95)", () => {
it("keeps the worktree step inside the collapsed Advanced options section", async () => {
const form = await mount([MAESTRO, WT_A]);
box(form, MAESTRO.path).click();
fireEvent.click(box(form, MAESTRO.path));
await drain();
const collapsible = form.querySelector<HTMLElement>(".wt-collapsible")!;
@@ -120,7 +130,7 @@ describe("spawnForm worktree step (#95)", () => {
it("lists '+ new' first and selected by default, then the project's worktrees", async () => {
const form = await mount([MAESTRO, WT_A, WT_B]);
box(form, MAESTRO.path).click();
fireEvent.click(box(form, MAESTRO.path));
await drain();
const labels = choices(form).map((c) => c.querySelector(".wt-choice-name")!.textContent);
@@ -130,24 +140,24 @@ describe("spawnForm worktree step (#95)", () => {
it("taking the '+ new' default runs in the repo with the create toggle on", async () => {
const form = await mount([MAESTRO, WT_A]);
box(form, MAESTRO.path).click();
fireEvent.click(box(form, MAESTRO.path));
await drain();
expect(changes.at(-1)!.cwd).toBe(MAESTRO.path);
expect(changes.at(-1)!.worktreeEnabled).toBe(true);
expect(toggle(form).checked).toBe(true);
expect(form.querySelector<HTMLElement>(".wt-repo")!.textContent).toContain(MAESTRO.path);
expect(within(form).getByText(`repo: ${MAESTRO.path}`)).toBeTruthy();
// The scan already knows the repo — no probe.
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false);
});
it("picking an existing worktree runs in it with the create toggle off (#91)", async () => {
const form = await mount([MAESTRO, WT_A, WT_B]);
box(form, MAESTRO.path).click();
fireEvent.click(box(form, MAESTRO.path));
await drain();
const existing = choices(form).find((c) => c.dataset.cwd === WT_A.path)!;
existing.click();
fireEvent.click(existing);
await drain();
expect(changes.at(-1)!.cwd).toBe(WT_A.path);
@@ -163,16 +173,12 @@ describe("spawnForm worktree step (#95)", () => {
it("switching back to '+ new' re-enables the create toggle", async () => {
const form = await mount([MAESTRO, WT_A]);
box(form, MAESTRO.path).click();
fireEvent.click(box(form, MAESTRO.path));
await drain();
choices(form)
.find((c) => c.dataset.cwd === WT_A.path)!
.click();
fireEvent.click(choices(form).find((c) => c.dataset.cwd === WT_A.path)!);
await drain();
choices(form)
.find((c) => c.dataset.kind === "new")!
.click();
fireEvent.click(choices(form).find((c) => c.dataset.kind === "new")!);
await drain();
expect(changes.at(-1)!.cwd).toBe(MAESTRO.path);
@@ -182,14 +188,12 @@ describe("spawnForm worktree step (#95)", () => {
it("repopulates and resets to '+ new' when another project is picked", async () => {
const form = await mount([MAESTRO, WT_A, OTHER]);
box(form, MAESTRO.path).click();
fireEvent.click(box(form, MAESTRO.path));
await drain();
choices(form)
.find((c) => c.dataset.cwd === WT_A.path)!
.click();
fireEvent.click(choices(form).find((c) => c.dataset.cwd === WT_A.path)!);
await drain();
box(form, OTHER.path).click();
fireEvent.click(box(form, OTHER.path));
await drain();
expect(choices(form).map((c) => c.dataset.kind)).toEqual(["new"]);
@@ -201,7 +205,7 @@ describe("spawnForm worktree step (#95)", () => {
it("defaults to the worktree itself when the box is a linked worktree (#91)", async () => {
// No main checkout in the scan, so the orphan worktree is its own box.
const form = await mount([WT_A]);
box(form, WT_A.path).click();
fireEvent.click(box(form, WT_A.path));
await drain();
expect(activeChoice(form)!.dataset.cwd).toBe(WT_A.path);
@@ -213,11 +217,11 @@ describe("spawnForm worktree step (#95)", () => {
it("hides the worktree step again when the operator takes the raw-input hatch", async () => {
const form = await mount([MAESTRO, WT_A]);
box(form, MAESTRO.path).click();
fireEvent.click(box(form, MAESTRO.path));
await drain();
expect(step(form).hidden).toBe(false);
form.querySelector<HTMLButtonElement>(".project-other")!.click();
fireEvent.click(otherBtn(form));
await drain();
expect(step(form).hidden).toBe(true);
@@ -267,7 +271,7 @@ describe("spawnForm worktree step (#95)", () => {
await drain();
const form = handle.el;
const raw = form.querySelector<HTMLInputElement>(".cwd-raw")!;
const raw = within(form).getByPlaceholderText("/home/user/project") as HTMLInputElement;
expect(form.querySelector<HTMLElement>(".cwd-raw-wrap")!.hidden).toBe(false);
expect(raw.value).toBe("/home/user/gone");
});