Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66a568818a | ||
|
|
ad71147bd9 | ||
|
|
e9c3882cea | ||
|
|
c9562ab5eb | ||
|
|
bc87ddee06 | ||
|
|
554c9ebb31 | ||
|
|
af5ad1a227 | ||
|
|
8304ef9a49 | ||
|
|
cdba6d07e3 | ||
|
|
ea8c42bfb2 | ||
|
|
e0edbc09d2 | ||
|
|
e9e98e0c49 | ||
|
|
02f1844650 | ||
|
|
ee61cea64f | ||
|
|
7e21371e47 |
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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
@@ -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$/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 "{name}"?</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,
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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!)}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user