Compare commits

..
Author SHA1 Message Date
Miguel PalhasandClaude Opus 4.6 66a568818a fix(maestro): re-read session before sidecar write in mark_session_dead
ci / web (push) Failing after 6s
ci / flake (push) Failing after 55s
ci / rust (push) Successful in 6m7s
mark_session_dead_with_worktree_settlement cloned the session under the
lock, dropped it, did async work, then wrote the stale clone to the
sidecar. A haiku rename (or any other metadata update) that landed
between the clone and the write was silently overwritten, so the sidecar
reverted to name_source: Generated and the next daemon recovery
re-triggered autoname.

Re-read record.session under the lock at write time so the sidecar
always reflects the latest state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-09 15:14:34 +01:00
Miguel Palhas ad71147bd9 Closes #252: port all tests to React Testing Library 2026-09-09 02:07:52 +01:00
Miguel Palhas e9c3882cea Closes #242: port daemon link and restart overlay to React 2026-09-09 02:07:49 +01:00
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 bc87ddee06 feat(web): port daemonLink and restartOverlay to React (#242)
DaemonLink.tsx holds the link state machine as a Zustand store (same
shape as catalogStore/connectionStore) plus a thin controller component
wiring the catalog store's `connected` field to it. RestartOverlay.tsx
renders the blocking modal for down/updating, reusing the existing CSS.

Closes #242
2026-09-09 01:59:49 +01:00
Miguel Palhas 554c9ebb31 Closes #240: add useHashRouter hook 2026-09-09 01:51:02 +01:00
Miguel PalhasandClaude Sonnet 5 af5ad1a227 feat(web): add useHashRouter hook for React shell (#240)
Ports main.ts's parseRoute/dispatch hash parsing into a React hook:
same #/ and #/attach/:id shape, current route plus a navigate function.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 01:43:46 +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 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
67 changed files with 4910 additions and 876 deletions
+12 -12
View File
@@ -2916,17 +2916,16 @@ async fn mark_session_dead_with_worktree_settlement(
// Release anyone attached; the scrollback survives for a later
// attach.
record.bridge.seal();
let session = record.session.clone();
let worktree = record
.worktree_base_repo
.clone()
.zip(record.worktree_path.clone());
Some((session, worktree))
Some(worktree)
}
_ => None,
}
};
let Some((mut session, worktree)) = flipped else {
let Some(worktree) = flipped else {
return false;
};
let stream = inner
@@ -2943,9 +2942,6 @@ async fn mark_session_dead_with_worktree_settlement(
if settle_worktree {
if let Some(wt) = &worktree {
if settle_worktree_on_exit(inner, id, wt).await {
// The tree is settled (removed); a stale "in the way" would
// describe something the session no longer holds.
session.blocked_work = None;
let mut sessions = inner.sessions.lock().await;
if let Some(record) = sessions.get_mut(&id) {
record.session.worktree = None;
@@ -2957,13 +2953,17 @@ async fn mark_session_dead_with_worktree_settlement(
}
}
// Settle has now been applied or refused either way (#115): from here on
// its outcomes are safe to observe.
{
// its outcomes are safe to observe. Re-read the session from the record
// so the sidecar write includes any updates (e.g. a haiku rename) that
// landed between the initial clone and now.
let session = {
let mut sessions = inner.sessions.lock().await;
if let Some(record) = sessions.get_mut(&id) {
record.settled = true;
}
}
let Some(record) = sessions.get_mut(&id) else {
return false;
};
record.settled = true;
record.session.clone()
};
let _ = inner.sidecar.write(&session);
let _ = inner.events.send(CatalogEvent::SessionUpdated { session });
+37 -3
View File
@@ -11,14 +11,14 @@ use common::TmuxFixture;
use maestro_daemon::sidecar::SidecarStore;
use maestro_daemon::worktree::WorktreeManager;
use maestro_daemon::SessionManager;
use maestro_protocol::{display, CatalogEvent, CreateSessionRequest, NameSource};
use maestro_protocol::{display, CatalogEvent, CreateSessionRequest, NameSource, SessionStatus};
use tempfile::TempDir;
use uuid::Uuid;
struct Fixture {
manager: SessionManager,
sidecar: SidecarStore,
_tmux: TmuxFixture,
tmux: TmuxFixture,
_state_dir: TempDir,
}
@@ -35,7 +35,7 @@ fn fixture() -> Fixture {
Fixture {
manager,
sidecar,
_tmux: tmux,
tmux,
_state_dir: state_dir,
}
}
@@ -171,3 +171,37 @@ async fn a_blank_name_changes_nothing() {
let live = fx.sidecar.read(id).unwrap().unwrap();
assert_eq!(display::name_source(&live), NameSource::Generated);
}
/// A session that dies naturally (liveness watcher marks it dead) must not
/// overwrite a haiku rename that landed before the death. Regression test for
/// the stale-clone sidecar write in `mark_session_dead`.
#[tokio::test]
async fn session_death_preserves_the_haiku_name() {
let fx = fixture();
let id = spawn(&fx.manager, None).await;
fx.manager.claim_autoname(id).await.unwrap();
fx.manager.apply_autoname(id, "haiku-name").await.unwrap();
// Verify the sidecar has Haiku before the session dies.
let before = fx.sidecar.read(id).unwrap().unwrap();
assert_eq!(display::name_source(&before), NameSource::Haiku);
// Kill the tmux session directly so the liveness watcher picks it up.
fx.tmux.tmux.kill_session(id).expect("kill tmux directly");
fx.manager
.start_liveness_watcher(std::time::Duration::from_millis(25));
common::wait_for_async(std::time::Duration::from_secs(3), || async {
fx.manager.status(id).await == Some(SessionStatus::Dead)
})
.await;
let after = fx.sidecar.read(id).unwrap().unwrap();
assert_eq!(
display::name_source(&after),
NameSource::Haiku,
"mark_session_dead must not overwrite a haiku rename in the sidecar"
);
assert_eq!(display::custom_name(&after), Some("haiku-name"));
}
+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,215 @@
import { describe, expect, it, vi } from "vitest";
import { GRACE_MS, POLL_MS, compareIdentity, createDaemonLinkStore, formatRelease, type Scheduler } from "./DaemonLink.js";
import type { DaemonInfo } from "../../types.js";
function info(commit: string, instance: string, date = "2026-09-02T14:03:11+01:00"): DaemonInfo {
return {
hostname: "konishi",
version: "0.1.0",
presets: [],
build: { commit, commit_date: date, dirty: false },
instance,
};
}
/** Scheduler whose timers only fire when the test says so, recording the
* delay each was scheduled with. */
function manual(): Scheduler & { run(): void; pending(): number; delays: number[] } {
let next = 1;
const timers = new Map<number, () => void>();
const delays: number[] = [];
return {
delays,
setTimer(fn, ms) {
const h = next++;
timers.set(h, fn);
delays.push(ms);
return h;
},
clearTimer(h) {
timers.delete(h);
},
run() {
const due = [...timers.entries()];
timers.clear();
for (const [, fn] of due) fn();
},
pending: () => timers.size,
};
}
describe("compareIdentity", () => {
it("reads a changed commit as an upgrade", () => {
expect(compareIdentity(info("aaa", "i1"), info("bbb", "i2"))).toBe("upgraded");
});
it("reads a changed instance on the same commit as a plain restart", () => {
expect(compareIdentity(info("aaa", "i1"), info("aaa", "i2"))).toBe("restarted");
});
it("reads an unchanged process as no event at all", () => {
expect(compareIdentity(info("aaa", "i1"), info("aaa", "i1"))).toBe("same");
});
it("falls back to the instance when the build is unknown", () => {
// A daemon built outside a checkout reports empty commits; the instance is
// still enough to see the restart, and never claims a phantom upgrade.
expect(compareIdentity(info("", "i1"), info("", "i2"))).toBe("restarted");
expect(compareIdentity(info("", "i1"), info("", "i1"))).toBe("same");
});
});
describe("formatRelease", () => {
it("pairs the short hash with a readable date", () => {
expect(formatRelease({ commit: "f89be4a716c7", commit_date: "2026-09-02T14:03:11Z", dirty: false })).toBe(
"f89be4a716c7 · 2026-09-02",
);
});
it("marks a dirty build", () => {
expect(formatRelease({ commit: "f89be4a", commit_date: "", dirty: true })).toBe("f89be4a (dirty)");
});
it("says so when the release is unknown", () => {
expect(formatRelease(undefined)).toBe("unknown release");
});
});
describe("daemon link store", () => {
it("starts in the connecting state until the first probe lands", async () => {
const store = createDaemonLinkStore({
fetchInfo: async () => info("aaa", "i1"),
scheduler: manual(),
reload: () => {},
});
expect(store.getState().state).toBe("connecting");
await store.getState().probe();
expect(store.getState().state).toBe("online");
expect(store.getState().build?.commit).toBe("aaa");
});
it("holds the modal back during the grace period, then blocks", async () => {
const sched = manual();
const store = createDaemonLinkStore({
fetchInfo: async () => {
throw new Error("down");
},
scheduler: sched,
reload: () => {},
graceMs: 1000,
});
store.getState().noteDisconnected();
// Still inside the grace window: the dot goes amber, nothing blocks.
expect(store.getState().state).toBe("reconnecting");
sched.run();
await Promise.resolve();
expect(store.getState().state).toBe("down");
});
it("unlocks without reloading when the same build comes back", async () => {
const sched = manual();
const reload = vi.fn();
let reply: DaemonInfo | null = info("aaa", "i1");
const store = createDaemonLinkStore({
fetchInfo: async () => {
if (!reply) throw new Error("down");
return reply;
},
scheduler: sched,
reload,
});
await store.getState().probe();
reply = null;
store.getState().noteDisconnected();
sched.run(); // grace expires → blocked, poll scheduled
await Promise.resolve();
expect(store.getState().state).toBe("down");
// Same commit, new process: a restart, not an upgrade — the page stays.
reply = info("aaa", "i2");
await store.getState().probe();
expect(reload).not.toHaveBeenCalled();
expect(store.getState().state).toBe("online");
});
it("reloads the page when the daemon comes back on a new commit", async () => {
const sched = manual();
const reload = vi.fn();
let reply = info("aaa", "i1");
const store = createDaemonLinkStore({
fetchInfo: async () => reply,
scheduler: sched,
reload,
});
await store.getState().probe();
store.getState().noteDisconnected();
reply = info("bbb", "i2");
await store.getState().probe();
expect(reload).toHaveBeenCalledTimes(1);
// Left blocked deliberately: the reload is what ends this page's life, and
// flashing an unlocked UI in between would let the user act on stale code.
expect(store.getState().state).toBe("updating");
});
it("keeps polling on a scheduled cadence while the daemon is unreachable", async () => {
const sched = manual();
let attempts = 0;
const store = createDaemonLinkStore({
fetchInfo: async () => {
attempts += 1;
throw new Error("down");
},
scheduler: sched,
reload: () => {},
});
store.getState().noteDisconnected();
sched.run(); // grace → first poll
await Promise.resolve();
await Promise.resolve();
const first = attempts;
expect(first).toBeGreaterThan(0);
sched.run(); // next poll tick
await Promise.resolve();
await Promise.resolve();
expect(attempts).toBeGreaterThan(first);
store.getState().stop();
});
it("notifies subscribers on every state change", async () => {
const sched = manual();
const seen: string[] = [];
const store = createDaemonLinkStore({
fetchInfo: async () => info("aaa", "i1"),
scheduler: sched,
reload: () => {},
});
store.subscribe((s) => seen.push(s.state));
await store.getState().probe();
store.getState().noteDisconnected();
sched.run();
await Promise.resolve();
expect(seen).toContain("reconnecting");
});
it("schedules the grace timer and poll at the documented defaults", async () => {
const sched = manual();
const store = createDaemonLinkStore({
fetchInfo: async () => {
throw new Error("down");
},
scheduler: sched,
reload: () => {},
});
expect(GRACE_MS).toBe(1200);
expect(POLL_MS).toBe(700);
store.getState().noteDisconnected();
expect(sched.delays).toEqual([GRACE_MS]);
sched.run(); // grace fires → schedules the first poll
await Promise.resolve();
await Promise.resolve();
expect(sched.delays).toEqual([GRACE_MS, POLL_MS]);
});
});
@@ -0,0 +1,71 @@
// @vitest-environment happy-dom
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createDaemonLinkStore } from "./DaemonLink.js";
import { DaemonLink } from "./DaemonLink.js";
afterEach(() => cleanup());
describe("DaemonLink", () => {
it("probes once on mount, regardless of the initial connected value", async () => {
const fetchInfo = vi.fn().mockResolvedValue({ hostname: "h", version: "1", presets: [] });
const store = createDaemonLinkStore({ fetchInfo, scheduler: { setTimer: () => 0, clearTimer: () => {} } });
render(<DaemonLink store={store} connected={false} />);
await Promise.resolve();
expect(fetchInfo).toHaveBeenCalledTimes(1);
});
it("does not treat the initial connected value as a transition", async () => {
const store = createDaemonLinkStore({
fetchInfo: async () => ({ hostname: "h", version: "1", presets: [] }),
scheduler: { setTimer: () => 0, clearTimer: () => {} },
});
render(<DaemonLink store={store} connected={true} />);
await Promise.resolve();
// Only the mount probe ran — no extra noteConnected() probe on top of it.
expect(store.getState().state).toBe("online");
});
it("forwards a later connected drop to noteDisconnected", async () => {
const store = createDaemonLinkStore({
fetchInfo: async () => ({ hostname: "h", version: "1", presets: [] }),
scheduler: { setTimer: () => 0, clearTimer: () => {} },
});
const { rerender } = render(<DaemonLink store={store} connected={true} />);
await Promise.resolve();
expect(store.getState().state).toBe("online");
rerender(<DaemonLink store={store} connected={false} />);
expect(store.getState().state).toBe("reconnecting");
});
it("forwards a later connected recovery to noteConnected", async () => {
const fetchInfo = vi.fn().mockResolvedValue({ hostname: "h", version: "1", presets: [] });
const store = createDaemonLinkStore({ fetchInfo, scheduler: { setTimer: () => 0, clearTimer: () => {} } });
const { rerender } = render(<DaemonLink store={store} connected={true} />);
await Promise.resolve();
expect(store.getState().state).toBe("online");
// The socket dropped: noteDisconnected() moves off "online" so the coming
// recovery isn't a no-op.
rerender(<DaemonLink store={store} connected={false} />);
expect(store.getState().state).toBe("reconnecting");
fetchInfo.mockClear();
rerender(<DaemonLink store={store} connected={true} />);
await Promise.resolve();
expect(fetchInfo).toHaveBeenCalledTimes(1);
expect(store.getState().state).toBe("online");
});
it("stops the store's polling on unmount", () => {
const store = createDaemonLinkStore({
fetchInfo: async () => ({ hostname: "h", version: "1", presets: [] }),
});
const stop = vi.spyOn(store.getState(), "stop");
const { unmount } = render(<DaemonLink store={store} connected={false} />);
unmount();
expect(stop).toHaveBeenCalledTimes(1);
});
});
+234
View File
@@ -0,0 +1,234 @@
// React port of ../../daemonLink.ts. The link to the daemon behind this page:
// is it up, and which release is it? `RestartOverlay` (./RestartOverlay.tsx)
// renders what this reports. See docs/RESTART.md for the restart contract.
import { useEffect, useRef } from "react";
import { create } from "zustand";
import { getDaemonInfo } from "../../api.js";
import type { BuildInfo, DaemonInfo } from "../../types.js";
/**
* - `connecting` — first probe hasn't landed yet.
* - `online` — the daemon answered and is the same process, or a resolved new one.
* - `reconnecting` — the socket dropped, but within the grace window; a blip
* shouldn't throw a modal in the user's face.
* - `down` — unreachable past the grace window: the overlay blocks the UI and
* the poll runs until it answers.
* - `updating` — it answered on a different commit; the page is reloading and
* stays blocked until it does.
*/
export type LinkState = "connecting" | "online" | "reconnecting" | "down" | "updating";
/** What happened between two `/api/info` replies. */
export type RestartKind = "same" | "restarted" | "upgraded";
/** How long a dropped socket may stay dropped before the UI blocks. Long enough
* to ride out a wifi hiccup, short enough that a real restart is visible. */
export const GRACE_MS = 1200;
/** Cadence of the `/api/info` poll while the daemon is unreachable. */
export const POLL_MS = 700;
/**
* Compare two daemon replies.
*
* The commit decides first: a different one means new code, which for a browser
* means new assets. The per-process `instance` catches the rest — a restart of
* the same build, which needs no reload. A daemon built outside a git checkout
* reports an empty commit, so an empty-vs-empty pair never counts as an
* upgrade; the instance still reports the restart.
*/
export function compareIdentity(before: DaemonInfo, after: DaemonInfo): RestartKind {
const a = before.build?.commit ?? "";
const b = after.build?.commit ?? "";
if (a && b && a !== b) return "upgraded";
if (before.instance && after.instance && before.instance !== after.instance) return "restarted";
return "same";
}
/** One line naming a release: `f89be4a716c7 · 2026-09-02`, with `(dirty)` when
* the build carried uncommitted changes. Empty builds say so rather than
* rendering a blank tooltip. */
export function formatRelease(build: BuildInfo | undefined): string {
if (!build?.commit) return "unknown release";
const day = build.commit_date ? build.commit_date.slice(0, 10) : "";
const parts = [build.commit];
if (day) parts.push(`· ${day}`);
if (build.dirty) parts.push("(dirty)");
return parts.join(" ");
}
/** Schedules a probe/grace timer; returns a handle `clearTimer` understands. */
export interface Scheduler {
setTimer(fn: () => void, ms: number): number;
clearTimer(handle: number): void;
}
const browserScheduler: Scheduler = {
setTimer: (fn, ms) => window.setTimeout(fn, ms),
clearTimer: (h) => window.clearTimeout(h),
};
export interface DaemonLinkState {
state: LinkState;
/** The release the daemon last reported. Undefined until the first probe,
* and on a daemon too old to carry one. */
build?: BuildInfo;
/** Hostname the daemon reports, for the indicator's tooltip. */
hostname?: string;
/** How many probes have failed in the current outage — the overlay counts
* attempts so a long restart doesn't look frozen. */
attempts: number;
}
export interface DaemonLinkActions {
/** One `/api/info` round-trip. Resolves `true` when the daemon answered. */
probe(): Promise<boolean>;
/** The events socket dropped. Starts the grace timer and the poll. */
noteDisconnected(): void;
/** The events socket came back. It carries no identity of its own, so the
* state only clears once a probe confirms *which* daemon answered — a
* reconnect to an upgraded daemon must reload, not resume. */
noteConnected(): void;
/** Stop polling. */
stop(): void;
}
export type DaemonLinkStore = DaemonLinkState & DaemonLinkActions;
export interface DaemonLinkOptions {
/** Injectable for tests; defaults to `GET /api/info`. */
fetchInfo?: () => Promise<DaemonInfo>;
scheduler?: Scheduler;
/** What an upgrade does. Injectable so tests don't navigate. */
reload?: () => void;
graceMs?: number;
pollMs?: number;
}
/** Builds an isolated daemon-link store — same shape as
* {@link ../../stores/catalogStore.js!createCatalogStore}: both a React hook
* and a plain object (`getState`/`subscribe`), so it drives the overlay
* whether or not React is the one calling `noteConnected`/`noteDisconnected`. */
export function createDaemonLinkStore(opts: DaemonLinkOptions = {}) {
const fetchInfo = opts.fetchInfo ?? getDaemonInfo;
const scheduler = opts.scheduler ?? browserScheduler;
const reload = opts.reload ?? (() => window.location.reload());
const graceMs = opts.graceMs ?? GRACE_MS;
const pollMs = opts.pollMs ?? POLL_MS;
let identity: DaemonInfo | null = null;
let graceTimer: number | null = null;
let pollTimer: number | null = null;
return create<DaemonLinkStore>((set, get) => {
function clearTimers(): void {
if (graceTimer !== null) {
scheduler.clearTimer(graceTimer);
graceTimer = null;
}
if (pollTimer !== null) {
scheduler.clearTimer(pollTimer);
pollTimer = null;
}
}
async function probe(): Promise<boolean> {
let info: DaemonInfo;
try {
info = await fetchInfo();
} catch {
set((s) => ({ attempts: s.attempts + 1 }));
return false;
}
const before = identity;
identity = info;
if (before && compareIdentity(before, info) === "upgraded") {
// Stay blocked through the reload: an unlocked frame here would let the
// user drive new server code from the bundle it just replaced.
set({ state: "updating", build: info.build, hostname: info.hostname });
reload();
return true;
}
clearTimers();
set({ state: "online", build: info.build, hostname: info.hostname, attempts: 0 });
return true;
}
async function poll(): Promise<void> {
const state = get().state;
if (state === "online" || state === "updating") return;
const ok = await probe();
if (ok || pollTimer !== null) return;
pollTimer = scheduler.setTimer(() => {
pollTimer = null;
void poll();
}, pollMs);
}
return {
state: "connecting",
build: undefined,
hostname: undefined,
attempts: 0,
probe,
noteDisconnected() {
const state = get().state;
if (state === "down" || state === "updating") return;
set({ state: "reconnecting", attempts: 0 });
if (graceTimer === null) {
graceTimer = scheduler.setTimer(() => {
graceTimer = null;
if (get().state === "reconnecting") set({ state: "down" });
void poll();
}, graceMs);
}
},
noteConnected() {
const state = get().state;
if (state === "online" || state === "updating") return;
void probe();
},
stop: clearTimers,
};
});
}
export type DaemonLinkStoreApi = ReturnType<typeof createDaemonLinkStore>;
export interface DaemonLinkProps {
/** The link store this instance drives — `RestartOverlay` (and, later, the
* sidebar's status indicator) read the same one. */
store: DaemonLinkStoreApi;
/** The catalog store's `connected` field (docs/RESTART.md): the events
* socket dying is the earliest signal a restart happened, ahead of any
* poll. Pass `useStore(catalogStore, (s) => s.connected)`. */
connected: boolean;
}
/**
* Renders nothing — the controller that wires a `DaemonLinkStore` to the
* catalog's socket state. Mount once near the app root; `RestartOverlay`
* reads the store it drives.
*/
export function DaemonLink({ store, connected }: DaemonLinkProps): null {
const mounted = useRef(false);
useEffect(() => {
if (!mounted.current) {
mounted.current = true;
void store.getState().probe();
return;
}
if (connected) store.getState().noteConnected();
else store.getState().noteDisconnected();
}, [store, connected]);
useEffect(() => () => store.getState().stop(), [store]);
return null;
}
@@ -0,0 +1,78 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createDaemonLinkStore, type DaemonLinkStoreApi } from "./DaemonLink.js";
import { RestartOverlay } from "./RestartOverlay.js";
afterEach(() => cleanup());
function storeAt(state: "connecting" | "online" | "reconnecting" | "down" | "updating"): DaemonLinkStoreApi {
const store = createDaemonLinkStore({ scheduler: { setTimer: () => 0, clearTimer: () => {} } });
store.setState({ state });
return store;
}
describe("RestartOverlay", () => {
it("renders nothing while connecting, online, or reconnecting", () => {
for (const state of ["connecting", "online", "reconnecting"] as const) {
const { unmount } = render(<RestartOverlay store={storeAt(state)} />);
expect(screen.queryByRole("alertdialog")).toBeNull();
unmount();
}
});
it("blocks with a restarting message when down", () => {
render(<RestartOverlay store={storeAt("down")} />);
const dialog = screen.getByRole("alertdialog");
expect(dialog.textContent).toContain("Maestro is restarting");
expect(dialog.textContent).toContain("Waiting for the daemon to come back…");
});
it("counts attempts once more than one probe has failed", () => {
const store = storeAt("down");
store.setState({ attempts: 3 });
render(<RestartOverlay store={store} />);
expect(screen.getByRole("alertdialog").textContent).toContain("(3 attempts)");
});
it("says it is reloading when updating", () => {
render(<RestartOverlay store={storeAt("updating")} />);
const dialog = screen.getByRole("alertdialog");
expect(dialog.textContent).toContain("Maestro was updated");
expect(dialog.textContent).toContain("Reloading this page onto the new release…");
});
it("names the release from the store's build", () => {
const store = storeAt("down");
store.setState({ build: { commit: "f89be4a", commit_date: "2026-09-02T00:00:00Z", dirty: false } });
render(<RestartOverlay store={store} />);
expect(screen.getByRole("alertdialog").textContent).toContain("f89be4a · 2026-09-02");
});
it("clears the moment the store reports the link is back", () => {
const store = storeAt("down");
const { rerender } = render(<RestartOverlay store={store} />);
expect(screen.queryByRole("alertdialog")).not.toBeNull();
store.setState({ state: "online" });
rerender(<RestartOverlay store={store} />);
expect(screen.queryByRole("alertdialog")).toBeNull();
});
it("swallows keyboard shortcuts while blocking, but not once cleared", () => {
const store = storeAt("down");
const handler = vi.fn();
window.addEventListener("keydown", handler);
const { rerender } = render(<RestartOverlay store={store} />);
fireEvent.keyDown(window, { key: "n" });
expect(handler).not.toHaveBeenCalled();
store.setState({ state: "online" });
rerender(<RestartOverlay store={store} />);
fireEvent.keyDown(window, { key: "n" });
expect(handler).toHaveBeenCalledTimes(1);
window.removeEventListener("keydown", handler);
});
});
@@ -0,0 +1,62 @@
// React port of ../../restartOverlay.ts. Blocking overlay for a daemon that
// went away — see docs/RESTART.md.
//
// Rendered while the link is `down` or `updating`, gone the moment it is
// back. It owns no polling of its own: `DaemonLink` polls, this only renders
// what the store reports.
import { useEffect } from "react";
import { useStore } from "zustand";
import { formatRelease, type DaemonLinkStoreApi } from "./DaemonLink.js";
export interface RestartOverlayProps {
/** The same store a sibling `DaemonLink` drives. */
store: DaemonLinkStoreApi;
}
/** The poll runs while this is up; saying how many tries have gone by is what
* separates "still restarting" from "wedged" for someone staring at it. */
function attemptText(attempts: number): string {
if (attempts <= 1) return "Waiting for the daemon to come back…";
return `Waiting for the daemon to come back… (${attempts} attempts)`;
}
function swallow(e: KeyboardEvent): void {
e.stopImmediatePropagation();
}
export function RestartOverlay({ store }: RestartOverlayProps) {
const state = useStore(store, (s) => s.state);
const build = useStore(store, (s) => s.build);
const attempts = useStore(store, (s) => s.attempts);
const blocking = state === "down" || state === "updating";
// The app's shortcuts live on `window`, so hiding the UI behind a backdrop
// does not stop them: `n` would still open a spawn form against a daemon
// that is not there. Swallow keys in the capture phase until the link is
// back, without `preventDefault` so the browser's own chords still work.
useEffect(() => {
if (!blocking) return;
window.addEventListener("keydown", swallow, true);
return () => window.removeEventListener("keydown", swallow, true);
}, [blocking]);
if (!blocking) return null;
const updating = state === "updating";
return (
<div className="modal-backdrop restart-backdrop">
<div className="modal restart-modal" role="alertdialog" aria-modal="true" aria-labelledby="restart-title">
<div className="restart-spinner" aria-hidden="true" />
<h2 id="restart-title">{updating ? "Maestro was updated" : "Maestro is restarting"}</h2>
<p className="restart-detail">
{updating ? "Reloading this page onto the new release…" : attemptText(attempts)}
</p>
<p className="restart-release">{formatRelease(build)}</p>
</div>
</div>
);
}
@@ -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>
);
}
+57 -1
View File
@@ -1,5 +1,5 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { leaves } from "../../layout.js";
@@ -93,4 +93,60 @@ describe("MultiView", () => {
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"]);
});
});
});
+42 -8
View File
@@ -1,5 +1,5 @@
// React port of multiview.ts's tab + split tree (issue #249). Preview mode
// (docs/PREVIEW.md) is issue #251; sidebar drag-to-dock and the mobile
// 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.
@@ -8,6 +8,7 @@ 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";
@@ -26,6 +27,8 @@ export interface MultiViewProps {
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(() => {
@@ -37,9 +40,17 @@ export function MultiView({ store, sessions, onDisconnect, emptyState }: MultiVi
return (
<div className="mv flex h-full min-h-0 flex-col">
<TabBar store={store} />
<div className="mv-canvas min-h-0 flex-1">
<div className="mv-canvas relative min-h-0 flex-1">
{tab.root ? (
<SplitNode node={tab.root} tab={tab} store={store} sessions={sessions} onDisconnect={onDisconnect} />
<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">
@@ -47,6 +58,14 @@ export function MultiView({ store, sessions, onDisconnect, emptyState }: MultiVi
</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>
);
@@ -57,6 +76,8 @@ interface TreeProps {
store: LayoutStoreApi;
sessions?: ReadonlyMap<Uuid, Session>;
onDisconnect?: (sessionId: Uuid) => void;
previewedSessionId: Uuid | null;
commitPreview: (sessionId: Uuid) => void;
}
function SplitNode({ node, ...rest }: TreeProps & { node: Node }) {
@@ -127,21 +148,34 @@ function Pane({
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 ? "border-accent" : "border-border-strong")
(isFocused && !isPreview ? "border-accent" : "border-border-strong")
}
data-leaf-id={leaf.id}
data-session-id={leaf.sessionId ?? undefined}
onPointerDownCapture={() => store.getState().focusLeaf(leaf.id)}
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 bg-surface px-sp-2 py-sp-1 text-fs-12">
<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} />
) : (
@@ -189,7 +223,7 @@ function Pane({
<PaneContainer
sessionId={leaf.sessionId}
session={session}
focus={isFocused}
focus={isFocused && previewedSessionId === null}
onClosed={() => store.getState().closeSession(leaf.sessionId!)}
/>
) : (
+14 -3
View File
@@ -13,6 +13,7 @@ export interface TabBarProps {
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);
@@ -22,6 +23,9 @@ export function TabBar({ store }: TabBarProps) {
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}
@@ -32,7 +36,11 @@ export function TabBar({ store }: TabBarProps) {
data-tab-id={tab.id}
className={
"mv-tab flex items-center gap-sp-1 rounded-t-r-1 px-sp-2 py-sp-1 " +
(isActive ? "bg-surface-2 text-ink" : "text-faint hover:text-ink") +
(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) => {
@@ -57,13 +65,16 @@ export function TabBar({ store }: TabBarProps) {
setDraggingId(null);
setOverId(null);
}}
onClick={() => store.getState().selectTab(tab.id)}
onClick={() => {
if (isPreviewTab) return;
store.getState().selectTab(tab.id);
}}
>
<span>
{tab.title}
{count ? ` · ${count}` : ""}
</span>
{layout.tabs.length > 1 && (
{layout.tabs.length > 1 && !isPreviewTab && (
<span
className="mv-tab-close text-faint hover:text-ink"
title="Close tab"
+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);
});
});
+82
View File
@@ -0,0 +1,82 @@
// @vitest-environment happy-dom
import { act, cleanup, render, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { useHashRouter, type Route, type UseHashRouterResult } from "./useHashRouter.js";
let controls: UseHashRouterResult;
function Probe() {
controls = useHashRouter();
return <div>{controls.route.kind === "attach" ? `attach:${controls.route.sessionId}` : "catalog"}</div>;
}
beforeEach(() => {
location.hash = "";
});
afterEach(cleanup);
describe("useHashRouter", () => {
it("starts on catalog for an empty hash", () => {
const view = render(<Probe />);
expect(view.container.textContent).toBe("catalog");
});
it("resolves #/attach/:id to the attach route", () => {
location.hash = "#/attach/s1";
const view = render(<Probe />);
expect(view.container.textContent).toBe("attach:s1");
});
it("decodes an encoded session id", () => {
location.hash = `#/attach/${encodeURIComponent("weird id/x")}`;
const view = render(<Probe />);
expect(view.container.textContent).toBe("attach:weird id/x");
});
it("falls back to catalog for an unknown route", () => {
location.hash = "#/nonsense";
const view = render(<Probe />);
expect(view.container.textContent).toBe("catalog");
});
it("follows a hashchange fired outside the hook", async () => {
const view = render(<Probe />);
// happy-dom dispatches `hashchange` on a real timer, like a browser — not
// synchronously with the assignment.
location.hash = "#/attach/s9";
await waitFor(() => expect(view.container.textContent).toBe("attach:s9"));
});
it("navigate updates the hash and the route", async () => {
const view = render(<Probe />);
act(() => controls.navigate({ kind: "attach", sessionId: "s2" } satisfies Route));
expect(location.hash).toBe("#/attach/s2");
await waitFor(() => expect(view.container.textContent).toBe("attach:s2"));
});
it("navigate back to catalog sets the root hash", async () => {
location.hash = "#/attach/s1";
const view = render(<Probe />);
act(() => controls.navigate({ kind: "catalog" }));
expect(location.hash).toBe("#/");
await waitFor(() => expect(view.container.textContent).toBe("catalog"));
});
it("navigating to the already-current route still updates state", () => {
location.hash = "#/attach/s1";
const view = render(<Probe />);
act(() => controls.navigate({ kind: "attach", sessionId: "s1" } satisfies Route));
expect(view.container.textContent).toBe("attach:s1");
});
it("stops listening after unmount", async () => {
const view = render(<Probe />);
view.unmount();
location.hash = "#/attach/s3";
// No assertion beyond "doesn't throw" — a leaked listener would only ever
// surface as noise in a later test's act() warnings.
await new Promise((r) => setTimeout(r, 0));
});
});
+47
View File
@@ -0,0 +1,47 @@
// React port of main.ts's `parseRoute`/`dispatch` pair (issue #240). Same two
// routes, same hash shape (`#/`, `#/attach/:id`) — kept so a deep link or a
// bookmark from the vanilla shell still resolves.
import { useCallback, useEffect, useState } from "react";
export type Route = { kind: "catalog" } | { kind: "attach"; sessionId: string };
function parseHash(hash: string): Route {
const raw = hash.replace(/^#/, "");
if (raw === "" || raw === "/") return { kind: "catalog" };
const m = raw.match(/^\/attach\/([^/?#]+)$/);
if (m) return { kind: "attach", sessionId: decodeURIComponent(m[1]) };
return { kind: "catalog" };
}
function routeToHash(route: Route): string {
return route.kind === "catalog" ? "#/" : `#/attach/${encodeURIComponent(route.sessionId)}`;
}
export interface UseHashRouterResult {
route: Route;
navigate(route: Route): void;
}
export function useHashRouter(): UseHashRouterResult {
const [route, setRoute] = useState<Route>(() => parseHash(location.hash));
useEffect(() => {
const onHashChange = () => setRoute(parseHash(location.hash));
window.addEventListener("hashchange", onHashChange);
return () => window.removeEventListener("hashchange", onHashChange);
}, []);
const navigate = useCallback((next: Route) => {
const hash = routeToHash(next);
// Setting an unchanged hash never fires `hashchange`, so the state update
// that would otherwise ride on that event has to happen here instead.
if (location.hash === hash) {
setRoute(next);
} else {
location.hash = hash;
}
}, []);
return { route, navigate };
}
+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();
});
});
+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();
});
});
+129
View File
@@ -159,4 +159,133 @@ describe("layoutStore", () => {
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();
});
});
});
+116 -1
View File
@@ -11,12 +11,14 @@ import {
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";
@@ -35,13 +37,29 @@ export interface LayoutStoreOptions {
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. */
* 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. */
@@ -66,6 +84,15 @@ export interface LayoutActions {
* 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;
@@ -107,10 +134,51 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
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) {
@@ -130,6 +198,7 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
},
openSessionBeside(sessionId, placement) {
restorePreview();
mutate((layout) => {
const loc = locateSession(layout, sessionId);
if (loc) {
@@ -158,6 +227,7 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
},
closeSession(sessionId) {
restorePreview();
mutate((layout) => {
const loc = locateSession(layout, sessionId);
if (!loc) return;
@@ -170,6 +240,7 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
},
splitFocused(leafId, dir) {
restorePreview();
mutate((layout) => {
const tab = activeTab(layout);
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
@@ -178,6 +249,7 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
},
closePane(leafId) {
restorePreview();
mutate((layout) => {
const tab = activeTab(layout);
removeLeaf(tab, leafId);
@@ -196,24 +268,28 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
},
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);
@@ -248,6 +324,45 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
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");
});