diff --git a/web/src/components/spawn/BranchPicker.test.tsx b/web/src/components/spawn/BranchPicker.test.tsx new file mode 100644 index 0000000..6badb5e --- /dev/null +++ b/web/src/components/spawn/BranchPicker.test.tsx @@ -0,0 +1,79 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { BranchInfo } from "../../types.js"; +import { BranchPicker, type BranchPickerProps } from "./BranchPicker.js"; + +afterEach(cleanup); + +function props(over: Partial = {}): BranchPickerProps { + return { + enabled: true, + onToggleEnabled: vi.fn(), + hint: null, + baseRepo: "/repo", + mode: "new", + onModeChange: vi.fn(), + newBranch: "", + onNewBranchChange: vi.fn(), + branches: undefined, + existingBranch: "", + onExistingBranchChange: vi.fn(), + ...over, + }; +} + +describe("BranchPicker", () => { + it("shows the hint instead of the body while unresolved", () => { + render(); + expect(screen.getByText("Choose a working directory first.")).not.toBeNull(); + expect(screen.queryByText("New branch name")).toBeNull(); + }); + + it("shows the new-branch field by default and the repo line once resolved", () => { + render(); + expect(screen.getByText("repo: /repo")).not.toBeNull(); + expect(screen.getByPlaceholderText("feat/foo")).not.toBeNull(); + expect(screen.queryByText("Pick a branch")).toBeNull(); + }); + + it("switches to the existing-branch select and reports the mode change", () => { + const onModeChange = vi.fn(); + render(); + + fireEvent.click(screen.getByLabelText("Existing branch")); + + expect(onModeChange).toHaveBeenCalledWith("existing"); + }); + + it("disables a branch already held by another worktree, and the main checkout's HEAD", () => { + const branches: BranchInfo[] = [ + { name: "main", is_head: true }, + { name: "feat/a", is_head: false, worktree: "/repos/a" }, + { name: "feat/b", is_head: false }, + ]; + render(); + + const main = screen.getByRole("option", { name: /main — held by the main checkout/ }) as HTMLOptionElement; + const a = screen.getByRole("option", { name: /feat\/a — held by \/repos\/a/ }) as HTMLOptionElement; + const b = screen.getByRole("option", { name: "feat/b" }) as HTMLOptionElement; + expect(main.disabled).toBe(true); + expect(a.disabled).toBe(true); + expect(b.disabled).toBe(false); + }); + + it("shows a placeholder option when no local branches were found", () => { + render(); + expect(screen.getByText("No local branches found.")).not.toBeNull(); + }); + + it("toggles the worktree checkbox", () => { + const onToggleEnabled = vi.fn(); + render(); + + fireEvent.click(screen.getByLabelText("Create git worktree")); + + expect(onToggleEnabled).toHaveBeenCalledWith(false); + }); +}); diff --git a/web/src/components/spawn/BranchPicker.tsx b/web/src/components/spawn/BranchPicker.tsx new file mode 100644 index 0000000..bdc3fa3 --- /dev/null +++ b/web/src/components/spawn/BranchPicker.tsx @@ -0,0 +1,119 @@ +// The spawn form's worktree section (#61): create-worktree toggle, then +// (once a base repo is known) new-branch-vs-existing-branch, mirroring +// spawnForm.ts's `.adv-group`/`.wt-body`/`.wt-mode` markup. + +import type { BranchInfo } from "../../types.js"; +import type { WorktreeBranchMode } from "../../spawnForm.js"; + +export interface BranchPickerProps { + enabled: boolean; + onToggleEnabled(enabled: boolean): void; + /** Status line shown in place of the body — "Checking…", "not a repo", + * "choose a directory first". `null` once the body itself can render. */ + hint: string | null; + /** The repo a worktree would branch from, once resolved. */ + baseRepo: string | null; + mode: WorktreeBranchMode; + onModeChange(mode: WorktreeBranchMode): void; + newBranch: string; + onNewBranchChange(value: string): void; + /** This cwd's local branches, or `undefined` while unknown/not yet loaded. */ + branches: BranchInfo[] | undefined; + existingBranch: string; + onExistingBranchChange(value: string): void; +} + +export function BranchPicker(props: BranchPickerProps) { + return ( +
+ + + {props.hint &&

{props.hint}

} + + {props.enabled && !props.hint && ( +
+ {props.baseRepo && ( +

repo: {props.baseRepo}

+ )} +
+ + +
+ + {props.mode === "new" ? ( + + ) : ( + + )} +
+ )} +
+ ); +} + +function branchOptions(branches: BranchInfo[] | undefined) { + if (!branches || branches.length === 0) { + return ; + } + return branches.map((b) => { + // A branch already checked out anywhere stays visible but unpickable — + // git refuses a second checkout. That includes the main checkout's own + // HEAD, which is commonly `main` and would otherwise be the default pick, + // making every existing-branch spawn fail (#54 review). + const held = b.worktree ?? (b.is_head ? "the main checkout" : null); + return ( + + ); + }); +} diff --git a/web/src/components/spawn/DirAutocomplete.test.tsx b/web/src/components/spawn/DirAutocomplete.test.tsx new file mode 100644 index 0000000..fa60a90 --- /dev/null +++ b/web/src/components/spawn/DirAutocomplete.test.tsx @@ -0,0 +1,109 @@ +// @vitest-environment happy-dom +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { useState } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { DirAutocomplete } from "./DirAutocomplete.js"; + +vi.mock("../../api.js", () => ({ + listDir: vi.fn(), +})); + +import { listDir } from "../../api.js"; + +const mockListDir = vi.mocked(listDir); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +function Harness({ initial = "" }: { initial?: string }) { + const [value, setValue] = useState(initial); + return ; +} + +// `fireEvent.focus`/`.blur` dispatch the event without moving the browser's +// real focus, so `document.activeElement` (which the dropdown's fetch guards +// against) never changes — calling the DOM method directly does both. +function focus(el: HTMLElement): void { + act(() => el.focus()); +} +function blur(el: HTMLElement): void { + act(() => el.blur()); +} + +describe("DirAutocomplete", () => { + it("lists entries matching the trailing segment on focus", async () => { + mockListDir.mockResolvedValue({ path: "/home/n", entries: ["alpha", "beta"] }); + render(); + + focus(screen.getByPlaceholderText("cwd")); + + expect(await screen.findByRole("option", { name: "alpha/" })).not.toBeNull(); + expect(screen.queryByRole("option", { name: "beta/" })).toBeNull(); + }); + + it("Tab commits the highlighted entry and does not close the field", async () => { + mockListDir.mockResolvedValue({ path: "/home/n", entries: ["alpha"] }); + render(); + const input = screen.getByPlaceholderText("cwd") as HTMLInputElement; + focus(input); + await screen.findByRole("option", { name: "alpha/" }); + + fireEvent.keyDown(input, { key: "Tab" }); + + expect(input.value).toBe("/home/n/alpha/"); + }); + + it("arrows move the highlight and Enter accepts it without submitting", async () => { + mockListDir.mockResolvedValue({ path: "/home/n", entries: ["alpha", "beta"] }); + render(); + const input = screen.getByPlaceholderText("cwd") as HTMLInputElement; + focus(input); + await screen.findByRole("option", { name: "alpha/" }); + + fireEvent.keyDown(input, { key: "ArrowDown" }); + const notPrevented = fireEvent.keyDown(input, { key: "Enter" }); + + expect(input.value).toBe("/home/n/beta/"); + expect(notPrevented).toBe(false); // preventDefault called -> event "processed" + }); + + it("Escape closes the dropdown and stops the key from bubbling", async () => { + mockListDir.mockResolvedValue({ path: "/home/n", entries: ["alpha"] }); + const onKeyDown = vi.fn(); + render( +
+ +
, + ); + const input = screen.getByPlaceholderText("cwd"); + focus(input); + await screen.findByRole("option", { name: "alpha/" }); + + fireEvent.keyDown(input, { key: "Escape" }); + + await waitFor(() => expect(screen.queryByRole("option")).toBeNull()); + expect(onKeyDown).not.toHaveBeenCalled(); + }); + + it("does not reopen on refocus until the operator types again", async () => { + mockListDir.mockResolvedValue({ path: "/home/n", entries: ["alpha"] }); + render(); + const input = screen.getByPlaceholderText("cwd"); + focus(input); + await screen.findByRole("option", { name: "alpha/" }); + fireEvent.keyDown(input, { key: "Escape" }); + await waitFor(() => expect(screen.queryByRole("option")).toBeNull()); + + blur(input); + focus(input); + + await new Promise((r) => setTimeout(r, 10)); + expect(screen.queryByRole("option")).toBeNull(); + + fireEvent.change(input, { target: { value: "/home/n/al" } }); + expect(await screen.findByRole("option", { name: "alpha/" })).not.toBeNull(); + }); +}); diff --git a/web/src/components/spawn/DirAutocomplete.tsx b/web/src/components/spawn/DirAutocomplete.tsx new file mode 100644 index 0000000..17e1055 --- /dev/null +++ b/web/src/components/spawn/DirAutocomplete.tsx @@ -0,0 +1,256 @@ +// React port of ../../dirAutocomplete.ts: shell-style directory autocomplete +// on a path . Lists one directory at a time over `GET /api/dirs`, +// caches per directory, and filters the trailing path segment locally. +// +// Keyboard model mirrors `cd dir/sub`: +// Tab commit the highlighted entry and let focus move on; with nothing +// highlighted, plain tab navigation +// ↑/↓ move the highlight +// Enter accept the highlighted entry (and suppress form submit); with +// nothing highlighted, bubble to the form unprevented +// Esc close the dropdown, refocus the input, and stop the key from +// bubbling — so dismissing the popup can't also close a parent modal +// +// Once dismissed (Esc or blur) the popup stays closed until the operator +// types again — refocusing alone doesn't reopen it. + +import { useCallback, useEffect, useRef, useState, type InputHTMLAttributes, type Ref } from "react"; +import { createPortal } from "react-dom"; + +import { listDir } from "../../api.js"; +import { splitPath } from "../../paths.js"; + +/** Most entries rendered at once — a long list is unhelpful and slow. */ +const MAX_VISIBLE = 60; +/** Debounce before a network fetch when the input crosses into a new directory. */ +const FETCH_DEBOUNCE_MS = 90; + +export interface DirAutocompleteProps + extends Omit, "value" | "onChange"> { + value: string; + onChange(value: string): void; + /** Access to the underlying `` — e.g. to focus/select it. */ + inputRef?: Ref; +} + +function assignRef(ref: Ref | undefined, el: HTMLInputElement | null): void { + if (!ref) return; + if (typeof ref === "function") ref(el); + else (ref as { current: HTMLInputElement | null }).current = el; +} + +export function DirAutocomplete({ value, onChange, inputRef, ...inputProps }: DirAutocompleteProps) { + const localRef = useRef(null); + const cache = useRef(new Map()); + const fetchSeq = useRef(0); + const debounceTimer = useRef(undefined); + const dismissedRef = useRef(false); + + const [filtered, setFiltered] = useState([]); + const [highlight, setHighlight] = useState(-1); + const [open, setOpen] = useState(false); + const [rect, setRect] = useState<{ left: number; top: number; width: number } | null>(null); + + const setInputRef = useCallback( + (el: HTMLInputElement | null) => { + localRef.current = el; + assignRef(inputRef, el); + }, + [inputRef], + ); + + const reposition = useCallback(() => { + const el = localRef.current; + if (!el) return; + const r = el.getBoundingClientRect(); + setRect({ left: r.left, top: r.bottom + 4, width: r.width }); + }, []); + + const close = useCallback(() => { + setOpen(false); + setFiltered([]); + setHighlight(-1); + }, []); + + const dismiss = useCallback(() => { + dismissedRef.current = true; + close(); + }, [close]); + + const applyFilter = useCallback( + (entries: string[], prefix: string) => { + // A fetch can outlive the dismissal it was meant to serve (Tab-commit + // re-arms a refresh, then blur dismisses 120ms later). Showing it now + // would anchor a dropdown under an unfocused input nothing closes. + if (dismissedRef.current || document.activeElement !== localRef.current) { + close(); + return; + } + const lower = prefix.toLowerCase(); + const next = entries.filter((e) => e.toLowerCase().startsWith(lower)); + setFiltered(next); + setHighlight(next.length > 0 ? 0 : -1); + if (next.length === 0) { + close(); + return; + } + setOpen(true); + reposition(); + }, + [close, reposition], + ); + + const refresh = useCallback( + (raw: string) => { + const { dir, prefix } = splitPath(raw); + const cached = cache.current.get(dir); + if (cached) { + applyFilter(cached, prefix); + return; + } + const seq = ++fetchSeq.current; + listDir(dir) + .then((listing) => { + cache.current.set(dir, listing.entries); + if (seq !== fetchSeq.current) return; // a newer fetch superseded this one + const cur = splitPath(localRef.current?.value ?? raw); + if (cur.dir === dir) applyFilter(listing.entries, cur.prefix); + }) + .catch(() => { + if (seq === fetchSeq.current) close(); + }); + }, + [applyFilter, close], + ); + + const scheduleRefresh = useCallback( + (raw: string) => { + window.clearTimeout(debounceTimer.current); + debounceTimer.current = window.setTimeout(() => refresh(raw), FETCH_DEBOUNCE_MS); + }, + [refresh], + ); + + /** Complete `name` as the next path segment and append `/` so the child + * directory's listing loads on the next interaction — the drill-in + * gesture. Unlike `accept`, doesn't steal focus: Tab uses this to commit + * and move to the next field in one gesture. */ + const commit = useCallback( + (name: string) => { + const { dir } = splitPath(value); + onChange(`${dir}${name}/`); + }, + [value, onChange], + ); + + const accept = useCallback( + (name: string) => { + commit(name); + localRef.current?.focus(); + }, + [commit], + ); + + useEffect(() => { + if (!open) return; + window.addEventListener("scroll", reposition, true); + window.addEventListener("resize", reposition); + return () => { + window.removeEventListener("scroll", reposition, true); + window.removeEventListener("resize", reposition); + }; + }, [open, reposition]); + + useEffect(() => () => window.clearTimeout(debounceTimer.current), []); + + return ( + <> + { + dismissedRef.current = false; + const next = e.currentTarget.value; + onChange(next); + scheduleRefresh(next); + }} + onFocus={(e) => { + inputProps.onFocus?.(e); + if (dismissedRef.current) return; + refresh(value); + }} + onBlur={(e) => { + inputProps.onBlur?.(e); + // Delay so a dropdown mousedown can complete first. + window.setTimeout(dismiss, 120); + }} + onKeyDown={(e) => { + inputProps.onKeyDown?.(e); + if (e.defaultPrevented) return; + if (e.key === "Escape") { + if (open) { + e.preventDefault(); + e.stopPropagation(); + dismiss(); + localRef.current?.focus(); + } + return; + } + if (e.key === "Tab") { + if (!open || highlight < 0 || highlight >= filtered.length) return; // plain tab navigation + commit(filtered[highlight]); // no preventDefault — focus moves to the next field + return; + } + if (!open) return; + if (e.key === "ArrowDown") { + e.preventDefault(); + setHighlight((h) => Math.min(h + 1, filtered.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setHighlight((h) => Math.max(h - 1, 0)); + } else if (e.key === "Enter" && highlight >= 0 && highlight < filtered.length) { + e.preventDefault(); // accept the entry instead of submitting the form + accept(filtered[highlight]); + } + }} + /> + {open && + rect && + createPortal( +
    + {filtered.slice(0, MAX_VISIBLE).map((name, i) => ( +
  • { + e.preventDefault(); // accept before the input's blur fires + accept(name); + }} + > + {name}/ +
  • + ))} + {filtered.length > MAX_VISIBLE && ( +
  • + +{filtered.length - MAX_VISIBLE} more — keep typing to narrow +
  • + )} +
, + document.body, + )} + + ); +} diff --git a/web/src/components/spawn/SpawnForm.test.tsx b/web/src/components/spawn/SpawnForm.test.tsx new file mode 100644 index 0000000..66d8b02 --- /dev/null +++ b/web/src/components/spawn/SpawnForm.test.tsx @@ -0,0 +1,383 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { BranchInfo, DaemonInfo, Project, Session, SpawnPreset } from "../../types.js"; +import { SpawnForm } from "./SpawnForm.js"; + +vi.mock("../../api.js", () => ({ + createSession: vi.fn(), + fetchBranches: vi.fn(), + fetchNames: vi.fn(), + getDaemonInfo: vi.fn(), + gitRoot: vi.fn(), + listDir: vi.fn(), + projects: vi.fn(), +})); + +import { + createSession, + fetchBranches, + fetchNames, + getDaemonInfo, + gitRoot, + listDir, + projects as fetchProjects, +} from "../../api.js"; + +const mockCreateSession = vi.mocked(createSession); +const mockFetchBranches = vi.mocked(fetchBranches); +const mockFetchNames = vi.mocked(fetchNames); +const mockGetDaemonInfo = vi.mocked(getDaemonInfo); +const mockGitRoot = vi.mocked(gitRoot); +const mockListDir = vi.mocked(listDir); +const mockFetchProjects = vi.mocked(fetchProjects); + +const presets: SpawnPreset[] = [ + { id: "claude", label: "Claude", command: "claude" }, + { id: "opencode", label: "opencode", command: "opencode" }, + { id: "zsh", label: "Shell", command: "zsh" }, + { id: "custom", label: "Custom…", command: "" }, +]; + +function info(over: Partial = {}): DaemonInfo { + return { hostname: "h", version: "1", home_dir: "/home/n", presets, ...over }; +} + +function project(over: Partial = {}): Project { + return { path: "/repos/p", name: "p", last_activity: new Date().toISOString(), ...over }; +} + +function session(over: Partial = {}): Session { + return { + id: "s1", + cwd: "/repos/p", + command: "claude", + env: {}, + metadata: {}, + status: "Running", + created_at: "2026-01-01T00:00:00Z", + ...over, + }; +} + +/** Defaults every test starts from: one known project, no branches, a fresh + * generated handle. Individual tests override what they care about. */ +function stub(over: { projects?: Project[]; info?: DaemonInfo; names?: string[]; branches?: BranchInfo[] } = {}) { + mockGetDaemonInfo.mockResolvedValue(over.info ?? info()); + mockFetchProjects.mockResolvedValue(over.projects ?? [project()]); + mockFetchNames.mockResolvedValue({ names: over.names ?? ["amber-fox"] }); + mockFetchBranches.mockResolvedValue(over.branches ?? []); + mockGitRoot.mockResolvedValue({}); + mockListDir.mockResolvedValue({ path: "", entries: [] }); +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +async function settle() { + await waitFor(() => expect(screen.queryByText("Loading projects…")).toBeNull()); +} + +/** Everything worktree/name/structured-related lives behind the "Advanced" + * disclosure, collapsed by default (#93) — mirrors the vanilla form exactly. */ +function openAdvanced() { + fireEvent.click(screen.getByRole("button", { name: /Advanced/ })); +} + +describe("SpawnForm harness picker", () => { + it("selects the first non-custom preset by default and reveals the custom input on Custom…", async () => { + stub(); + render(); + await settle(); + + expect(screen.getByRole("button", { name: /^Claude/ }).className).toContain("border-accent"); + fireEvent.click(screen.getByRole("button", { name: "Custom…" })); + + expect(screen.getByPlaceholderText("bash -l").hidden).toBe(false); + }); + + it("resolves a seeded command to its matching preset", async () => { + stub(); + render(); + await settle(); + + const opencode = screen.getByRole("button", { name: /opencode/ }); + expect(opencode.className).toContain("border-accent"); + }); + + it("drops an unmatched seeded command into the custom field", async () => { + stub(); + render(); + await settle(); + + const raw = screen.getByPlaceholderText("bash -l") as HTMLInputElement; + expect(raw.hidden).toBe(false); + expect(raw.value).toBe("hx src/"); + }); +}); + +describe("SpawnForm project picker", () => { + it("lists discovered projects and reveals the worktree step on pick", async () => { + stub({ projects: [project({ path: "/repos/a", name: "a" }), project({ path: "/repos/b", name: "b" })] }); + render(); + await settle(); + openAdvanced(); + + expect(screen.queryByText("Worktree")).toBeNull(); + fireEvent.click(screen.getByText("a")); + + expect(screen.getByText("Worktree")).not.toBeNull(); + expect(screen.getByText("+ new").closest("button")?.getAttribute("data-active")).toBe("true"); + }); + + it("filters the project grid by name", async () => { + stub({ projects: [project({ path: "/repos/alpha", name: "alpha" }), project({ path: "/repos/beta", name: "beta" })] }); + render(); + await settle(); + + fireEvent.change(screen.getByPlaceholderText("Filter projects…"), { target: { value: "alp" } }); + + expect(screen.getByText("alpha")).not.toBeNull(); + expect(screen.queryByText("beta")).toBeNull(); + }); + + it("falls back to the raw directory input when no projects are discovered", async () => { + stub({ projects: [] }); + render(); + await settle(); + + expect((screen.getByPlaceholderText("/home/user/project") as HTMLInputElement).value).toBe("/home/n"); + }); + + it("Other directory… clears the picker and opens the raw input", async () => { + stub({ projects: [project({ path: "/repos/a", name: "a" })] }); + render(); + await settle(); + fireEvent.click(screen.getByText("a")); + expect(screen.getByText("a").closest("button")?.getAttribute("data-active")).toBe("true"); + + fireEvent.click(screen.getByText("Other directory…")); + + expect(screen.getByText("a").closest("button")?.getAttribute("data-active")).toBeNull(); + expect((screen.getByPlaceholderText("/home/user/project") as HTMLInputElement).value).toBe("/repos/a"); + }); +}); + +describe("SpawnForm worktree defaults", () => { + it("defaults the toggle on for a picked project and infers the repo without a gitroot probe", async () => { + stub({ projects: [project({ path: "/repos/a", name: "a" })] }); + render(); + await settle(); + openAdvanced(); + + fireEvent.click(screen.getByText("a")); + + expect(screen.getByText("repo: /repos/a")).not.toBeNull(); + expect(mockGitRoot).not.toHaveBeenCalled(); + }); + + it("defaults the toggle off for a project that is itself a worktree (#91)", async () => { + stub({ + projects: [ + project({ path: "/repos/main", name: "main" }), + project({ path: "/repos/wt", name: "wt", is_worktree: true, base_repo: "/repos/main" }), + ], + }); + render(); + await settle(); + openAdvanced(); + + fireEvent.click(screen.getByText("main")); + fireEvent.click(screen.getByText("wt")); + + expect((screen.getByLabelText("Create git worktree") as HTMLInputElement).checked).toBe(false); + }); + + it("still probes gitroot for a free-text directory", async () => { + stub(); + mockGitRoot.mockResolvedValue({ root: "/repos/found" }); + render(); + await settle(); + openAdvanced(); + fireEvent.click(screen.getByText("Other directory…")); + + fireEvent.change(screen.getByPlaceholderText("/home/user/project"), { + target: { value: "/repos/found/sub" }, + }); + + await waitFor(() => expect(mockGitRoot).toHaveBeenCalledWith("/repos/found/sub")); + expect(await screen.findByText("repo: /repos/found")).not.toBeNull(); + }); + + it("populates existing branches, disabling the one held by another worktree", async () => { + stub({ + projects: [project({ path: "/repos/a", name: "a" })], + branches: [ + { name: "main", is_head: true }, + { name: "feat/x", is_head: false, worktree: "/repos/other" }, + { name: "feat/y", is_head: false }, + ], + }); + render(); + await settle(); + openAdvanced(); + fireEvent.click(screen.getByText("a")); + + fireEvent.click(screen.getByLabelText("Existing branch")); + + const y = await screen.findByRole("option", { name: "feat/y" }); + expect((y as HTMLOptionElement).disabled).toBe(false); + expect((screen.getByRole("option", { name: /main — held by the main checkout/ }) as HTMLOptionElement).disabled).toBe( + true, + ); + }); + + it("submits the first pickable branch when the operator never opens the select", async () => { + stub({ + projects: [project({ path: "/repos/a", name: "a" })], + branches: [ + { name: "main", is_head: true }, + { name: "feat/y", is_head: false }, + ], + }); + mockCreateSession.mockResolvedValue(session()); + const onSuccess = vi.fn(); + render(); + await settle(); + openAdvanced(); + fireEvent.click(screen.getByText("a")); + fireEvent.click(screen.getByLabelText("Existing branch")); + await screen.findByRole("option", { name: "feat/y" }); + + fireEvent.click(screen.getByRole("button", { name: "Spawn" })); + + await waitFor(() => expect(onSuccess).toHaveBeenCalled()); + expect(mockCreateSession).toHaveBeenCalledWith( + expect.objectContaining({ worktree: expect.objectContaining({ branch: "feat/y", create_branch: false }) }), + ); + }); +}); + +describe("SpawnForm structured view", () => { + it("defaults on for a resolved Claude Code command and off for an unsupported one", async () => { + stub(); + render(); + await settle(); + openAdvanced(); + + expect((screen.getByLabelText("Structured view") as HTMLInputElement).checked).toBe(true); + + fireEvent.click(screen.getByRole("button", { name: /^Shell/ })); + + expect(screen.queryByLabelText("Structured view")).toBeNull(); + }); + + it("keeps an unchecked box unchecked across a command change between two supported tools", async () => { + stub(); + render(); + await settle(); + openAdvanced(); + fireEvent.click(screen.getByLabelText("Structured view")); + + fireEvent.click(screen.getByRole("button", { name: /opencode/ })); + fireEvent.click(screen.getByRole("button", { name: /^Claude/ })); + + expect((screen.getByLabelText("Structured view") as HTMLInputElement).checked).toBe(false); + }); +}); + +describe("SpawnForm submit", () => { + it("builds and posts the request, reporting the created session", async () => { + stub({ projects: [project({ path: "/repos/a", name: "a" })] }); + const created = session(); + mockCreateSession.mockResolvedValue(created); + const onSuccess = vi.fn(); + render(); + await settle(); + fireEvent.click(screen.getByText("a")); + + fireEvent.click(screen.getByRole("button", { name: "Spawn" })); + + await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(created)); + expect(mockCreateSession).toHaveBeenCalledWith( + expect.objectContaining({ cwd: "/repos/a", command: "claude" }), + ); + }); + + it("shows the daemon's rejection and leaves the form open", async () => { + stub({ projects: [project({ path: "/repos/a", name: "a" })] }); + mockCreateSession.mockRejectedValue(new Error("cwd is not a directory")); + render(); + await settle(); + fireEvent.click(screen.getByText("a")); + + fireEvent.click(screen.getByRole("button", { name: "Spawn" })); + + expect(await screen.findByText("cwd is not a directory")).not.toBeNull(); + }); + + it("rejects an existing-branch worktree with nothing picked", async () => { + stub({ projects: [project({ path: "/repos/a", name: "a" })], branches: [] }); + render(); + await settle(); + openAdvanced(); + fireEvent.click(screen.getByText("a")); + fireEvent.click(screen.getByLabelText("Existing branch")); + await screen.findByText("No local branches found."); + + fireEvent.click(screen.getByRole("button", { name: "Spawn" })); + + expect(await screen.findByText("Pick an existing branch.")).not.toBeNull(); + expect(mockCreateSession).not.toHaveBeenCalled(); + }); +}); + +describe("SpawnForm Enter-to-submit (#97)", () => { + it("submits from the session name field", async () => { + stub({ projects: [project({ path: "/repos/a", name: "a" })] }); + mockCreateSession.mockResolvedValue(session()); + const onSuccess = vi.fn(); + render(); + await settle(); + fireEvent.click(screen.getByText("a")); + openAdvanced(); + + fireEvent.keyDown(screen.getByPlaceholderText("named after your first prompt"), { key: "Enter" }); + + await waitFor(() => expect(onSuccess).toHaveBeenCalled()); + }); + + it("first Enter picks a not-yet-active project box, the second submits", async () => { + stub({ projects: [project({ path: "/repos/a", name: "a" }), project({ path: "/repos/b", name: "b" })] }); + mockCreateSession.mockResolvedValue(session()); + const onSuccess = vi.fn(); + render(); + await settle(); + + const boxB = screen.getByText("b").closest("button")!; + fireEvent.keyDown(boxB, { key: "Enter" }); + expect(onSuccess).not.toHaveBeenCalled(); + expect(boxB.getAttribute("data-active")).toBe("true"); + + fireEvent.keyDown(boxB, { key: "Enter" }); + await waitFor(() => expect(onSuccess).toHaveBeenCalled()); + }); + + it("toggles the worktree checkbox on Enter instead of submitting", async () => { + stub({ projects: [project({ path: "/repos/a", name: "a" })] }); + render(); + await settle(); + openAdvanced(); + fireEvent.click(screen.getByText("a")); + const checkbox = screen.getByLabelText("Create git worktree") as HTMLInputElement; + expect(checkbox.checked).toBe(true); + + fireEvent.keyDown(checkbox, { key: "Enter" }); + + expect(checkbox.checked).toBe(false); + expect(mockCreateSession).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/components/spawn/SpawnForm.tsx b/web/src/components/spawn/SpawnForm.tsx new file mode 100644 index 0000000..9e01c6e --- /dev/null +++ b/web/src/components/spawn/SpawnForm.tsx @@ -0,0 +1,853 @@ +// React port of ../../spawnForm.ts: the spawn form itself — preset tiles, +// two-step project picker, name, structured-view checkbox, worktree. Mounted +// inside SpawnModal. +// +// Working-directory picker (#95), unchanged from the vanilla form: +// 1. Step one is one box per repository (name, then its parent directory). +// A worktree is a box only when its main checkout is missing from the +// scan. +// 2. The filter keeps a repository when the query matches it or any of its +// worktrees. +// 3. Picking a box reveals step two: "+ new", then that repository's +// worktrees newest-first. "Other directory…" clears both steps. +// 4. "+ new" is the default selection — except on a box that is itself a +// linked worktree, which defaults to that worktree. +// 5. "+ new" means create: cwd is the repository, create-worktree on. An +// existing worktree means run in it: cwd is that worktree, create-worktree +// off (the daemon never adopts, docs/WORKTREES.md). +// 6. Neither step probes /api/gitroot; only the free-text hatch does. + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { + createSession, + fetchBranches, + fetchNames, + getDaemonInfo, + gitRoot, + projects as fetchProjects, +} from "../../api.js"; +import { parentDir } from "../../paths.js"; +import { + filterProjects, + groupProjects, + isStale, + sortProjects, + type ProjectGroup, +} from "../../projectList.js"; +import { + buildSpawnRequest, + FALLBACK_PRESETS, + supportsStructuredView, + type SpawnFormSeed, + type SpawnFormValues, + type WorktreeBranchMode, +} from "../../spawnForm.js"; +import { splitCommand, toolIconSvg, TOOL_LABELS } from "../../toolIcon.js"; +import { abbreviateCwd } from "../../types.js"; +import type { BranchInfo, Project, Session, SpawnPreset } from "../../types.js"; +import { BranchPicker } from "./BranchPicker.js"; +import { DirAutocomplete } from "./DirAutocomplete.js"; + +/** Quiet period after raw-path typing before `/api/gitroot` fires (#74). Must + * stay equal to `GITROOT_PROBE_DEBOUNCE_MS` in crates/maestro-tui/src/draft.rs + * and to ../../spawnForm.ts's own constant, so every client debounces alike. */ +const GITROOT_PROBE_DEBOUNCE_MS = 250; + +/** "new" (create a worktree) or the path of an existing worktree to run in. */ +type WorktreeChoice = "new" | string; + +type BaseRepoResolution = + | { kind: "disabled" } + | { kind: "no-cwd" } + | { kind: "found"; repo: string } + | { kind: "probe" }; + +function resolveBaseRepoFor(cwd: string, wtEnabled: boolean, discovered: Project[]): BaseRepoResolution { + if (!wtEnabled) return { kind: "disabled" }; + if (!cwd) return { kind: "no-cwd" }; + const project = discovered.find((p) => p.path === cwd); + if (project) return { kind: "found", repo: project.base_repo ?? project.path }; + return { kind: "probe" }; +} + +/** Every worktree of a group, including the group's own box when the scan + * found no main checkout to nest it under. */ +function worktreesOf(group: ProjectGroup): Project[] { + return [group.main, ...group.worktrees].filter((p) => p.is_worktree); +} + +/** Resolve a preset id to a tool icon id — the preset's command is the source + * of truth, the same way the sidebar derives one. The "Custom…" preset (empty + * command) has no tool to show. */ +function presetToolId(p: SpawnPreset): string | null { + if (!p.command) return null; + return splitCommand(p.command).tool; +} + +function harnessIcon(toolId: string): string | null { + const small = toolIconSvg(toolId); + if (!small) return null; + // toolIconSvg renders at 13x13 (viewBox 0 0 16 16) for the sidebar; the + // harness card wants the same mark at 24x24 so it breathes. + return small.replace(/width="13"/g, 'width="24"').replace(/height="13"/g, 'height="24"'); +} + +function pickerButtonClass(active: boolean): string { + return `flex min-h-[32px] items-baseline justify-between gap-sp-3 rounded-r-2 border px-sp-3 py-1.5 text-left font-mono ${ + active ? "border-accent bg-accent-tint" : "border-border bg-surface-2 hover:border-border-strong" + }`; +} + +export interface SpawnFormProps { + seed?: SpawnFormSeed; + /** Fired whenever any value changes, once the form's initial seed settles. */ + onChange?(values: SpawnFormValues): void; + onSuccess(session: Session): void; +} + +export function SpawnForm({ seed, onChange, onSuccess }: SpawnFormProps) { + const prefill = useRef(seed ?? {}).current; + + const formRef = useRef(null); + const filterInputRef = useRef(null); + const customCommandRef = useRef(null); + const rawCwdRef = useRef(null); + const destroyedRef = useRef(false); + useEffect(() => () => { + destroyedRef.current = true; + }, []); + + // ---- harness / command ------------------------------------------------- + const [homeDir, setHomeDir] = useState(""); + const [infoLoading, setInfoLoading] = useState(true); + const [presets, setPresets] = useState(FALLBACK_PRESETS); + const [selectedPresetId, setSelectedPresetId] = useState(""); + const [command, setCommand] = useState(""); + const [customCommand, setCustomCommand] = useState(""); + const [commandTouched, setCommandTouched] = useState(false); + const [structuredView, setStructuredView] = useState(false); + const [structuredTouched, setStructuredTouched] = useState(false); + + const activePreset = useMemo(() => presets.find((p) => p.id === selectedPresetId), [presets, selectedPresetId]); + const isCustomSelected = !activePreset || activePreset.command === ""; + + useEffect(() => { + if (commandTouched && isCustomSelected) customCommandRef.current?.focus(); + }, [isCustomSelected, commandTouched]); + + useEffect(() => { + let cancelled = false; + getDaemonInfo() + .then((info) => { + if (cancelled) return; + setHomeDir(info.home_dir ?? ""); + if (info.presets?.length) setPresets(info.presets); + }) + .catch(() => { + // keep fallbacks + }) + .finally(() => { + if (!cancelled) setInfoLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + // Pick a sensible starting command whenever the preset list changes and the + // operator hasn't touched it yet: a seed's command (matched to a preset, or + // dropped into custom), else the first non-custom preset. + useEffect(() => { + if (commandTouched) return; + const wanted = prefill.command; + if (wanted) { + const match = presets.find((p) => p.command === wanted && p.command !== ""); + if (match) { + setSelectedPresetId(match.id); + setCommand(match.command); + return; + } + const custom = presets.find((p) => p.command === ""); + setCustomCommand(wanted); + setSelectedPresetId(custom ? custom.id : "custom"); + setCommand(wanted); + return; + } + const first = presets.find((p) => p.command !== ""); + if (first) { + setSelectedPresetId(first.id); + setCommand(first.command); + } + // prefill is captured once at construction, not a reactive dependency. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [presets, commandTouched]); + + const structuredSupported = useMemo(() => supportsStructuredView(command), [command]); + useEffect(() => { + if (!structuredSupported) setStructuredView(false); + else if (!structuredTouched) setStructuredView(true); + }, [structuredSupported, structuredTouched]); + + // ---- project picker ------------------------------------------------------ + const [discovered, setDiscovered] = useState([]); + const [projectsLoading, setProjectsLoading] = useState(true); + const [filterQuery, setFilterQuery] = useState(""); + const [cwd, setCwd] = useState(""); + const [cwdRawVisible, setCwdRawVisible] = useState(false); + const [rawFocusToken, setRawFocusToken] = useState(0); + const [selectedGroupPath, setSelectedGroupPath] = useState(null); + const [worktreeChoice, setWorktreeChoice] = useState("new"); + + const groups = useMemo(() => groupProjects(sortProjects(discovered, new Date())), [discovered]); + const visibleGroups = useMemo( + () => groups.filter((g) => filterProjects([g.main, ...g.worktrees], filterQuery).length > 0), + [groups, filterQuery], + ); + const selectedGroup = useMemo( + () => groups.find((g) => g.main.path === selectedGroupPath) ?? null, + [groups, selectedGroupPath], + ); + + + useEffect(() => { + let cancelled = false; + fetchProjects() + .then((list) => { + if (!cancelled) setDiscovered(list); + }) + .catch(() => { + if (!cancelled) setDiscovered([]); + }) + .finally(() => { + if (!cancelled) setProjectsLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + // ---- worktree (branch-only; base_repo inferred via scan or /gitroot) ---- + const [wtEnabled, setWtEnabled] = useState(prefill.worktreeEnabled ?? true); + const [inferredBaseRepo, setInferredBaseRepo] = useState(null); + const [wtHint, setWtHint] = useState(null); + const [name, setName] = useState(prefill.name ?? ""); + const [branchEdited, setBranchEdited] = useState(Boolean(prefill.worktreeBranchEdited)); + const [branchMode, setBranchMode] = useState(prefill.worktreeBranchMode ?? "new"); + const [newBranch, setNewBranch] = useState(prefill.worktreeBranch ?? ""); + const [existingBranch, setExistingBranch] = useState(""); + const [branchCache, setBranchCache] = useState<{ cwd: string; branches: BranchInfo[] } | null>(null); + + const [advancedCollapsed, setAdvancedCollapsed] = useState(true); + const [error, setError] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [ready, setReady] = useState(false); + + const probeTokenRef = useRef(0); + const probeTimerRef = useRef(undefined); + + const applyBaseRepoResolution = useCallback((res: BaseRepoResolution) => { + switch (res.kind) { + case "disabled": + setInferredBaseRepo(null); + setWtHint(null); + return; + case "no-cwd": + setInferredBaseRepo(null); + setWtHint("Choose a working directory first."); + return; + case "found": + setInferredBaseRepo(res.repo); + setWtHint(null); + setBranchCache(null); + setExistingBranch(""); + return; + case "probe": + return; // caller schedules the probe + } + }, []); + + const probeGitRoot = useCallback(async (path: string) => { + const token = ++probeTokenRef.current; + if (!path) { + setInferredBaseRepo(null); + setWtHint("Choose a working directory first."); + return; + } + setWtHint("Checking for a git repository…"); + let root: string | undefined; + try { + root = (await gitRoot(path)).root; + } catch { + root = undefined; + } + if (token !== probeTokenRef.current || destroyedRef.current) return; // a newer probe superseded us + if (root) { + setInferredBaseRepo(root); + setWtHint(null); + setBranchCache(null); + setExistingBranch(""); + } else { + setInferredBaseRepo(null); + // Publish the untick too, or a later submit fails with an inferred repo + // that no longer matches an unticked box. + setWtEnabled(false); + setWtHint("Not inside a git repository — pick a dir in a repo to use a worktree."); + } + }, []); + + const scheduleGitRootProbe = useCallback( + (path: string) => { + window.clearTimeout(probeTimerRef.current); + probeTimerRef.current = window.setTimeout(() => { + probeTimerRef.current = undefined; + void probeGitRoot(path); + }, GITROOT_PROBE_DEBOUNCE_MS); + }, + [probeGitRoot], + ); + useEffect(() => () => window.clearTimeout(probeTimerRef.current), []); + + // Seed the form once the initial project scan and daemon info have both + // settled — mirrors createSpawnForm's `loadProjects()`. Computed from local + // values rather than reading state back, since the setState calls below + // won't have committed yet within this same effect. + const seededRef = useRef(false); + useEffect(() => { + if (infoLoading || projectsLoading || seededRef.current) return; + seededRef.current = true; + + const seedCwd = prefill.cwd ?? null; + let finalCwd = ""; + let finalCwdRawVisible = false; + let finalGroupPath: string | null = null; + let finalChoice: WorktreeChoice = "new"; + + const restore = (path: string): boolean => { + for (const group of groups) { + const wt = worktreesOf(group).find((p) => p.path === path); + const choice: WorktreeChoice | null = wt ? wt.path : group.main.path === path ? "new" : null; + if (choice === null) continue; + finalGroupPath = group.main.path; + finalChoice = choice; + return true; + } + return false; + }; + + if (seedCwd) { + if (prefill.cwdRawVisible === false) { + finalCwd = seedCwd; + if (!restore(seedCwd)) finalCwdRawVisible = true; + } else { + finalCwd = seedCwd; + finalCwdRawVisible = true; + } + } else if (discovered.length === 0) { + finalCwd = homeDir; + finalCwdRawVisible = true; + } + + setCwd(finalCwd); + setCwdRawVisible(finalCwdRawVisible); + setSelectedGroupPath(finalGroupPath); + setWorktreeChoice(finalChoice); + + const res = resolveBaseRepoFor(finalCwd, wtEnabled, discovered); + applyBaseRepoResolution(res); + if (res.kind === "probe") scheduleGitRootProbe(finalCwd); + + setReady(true); + // groups/wtEnabled/discovered/homeDir are read for this one settle pass; + // re-running on their later changes would re-seed a form the operator + // has since edited. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [infoLoading, projectsLoading]); + + // ---- name (also seeds the worktree branch) ------------------------------ + const skipInitialBranchSync = useRef(Boolean(prefill.worktreeBranch)); + useEffect(() => { + // One-time construction seeding, mirroring spawnForm.ts precedence: an + // explicit seed wins outright; otherwise the prefilled name; otherwise a + // freshly generated handle (#53). + if (prefill.worktreeBranch) return; + if (!branchEdited && branchMode === "new" && prefill.name) return; // handled below, mount-only + if (!branchEdited && branchMode === "new") { + void fetchNames(1) + .then((batch) => { + const handle = batch.names[0]; + if (destroyedRef.current || !handle) return; + setNewBranch((cur) => cur || handle); + }) + .catch(() => { + // the operator types their own branch + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!skipInitialBranchSync.current && !branchEdited && branchMode === "new" && name) { + setNewBranch(name); + } + skipInitialBranchSync.current = false; + // A cleared name leaves the branch on its last value; only `name` changes + // (typed or the mount-time prefill) drive this sync. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [name]); + + // ---- existing-branch picker (#61): fetch once per cwd ------------------- + useEffect(() => { + if (branchMode !== "existing" || !cwd || !inferredBaseRepo) return; + if (branchCache?.cwd === cwd) return; + let cancelled = false; + const target = cwd; + fetchBranches(target) + .then((branches) => { + if (cancelled || destroyedRef.current) return; + setBranchCache({ cwd: target, branches }); + }) + .catch(() => { + // the picker just stays empty; "new" mode still works + }); + return () => { + cancelled = true; + }; + }, [branchMode, cwd, inferredBaseRepo, branchCache]); + + const branchesForCwd = branchCache?.cwd === cwd ? branchCache.branches : undefined; + + // A native { + const v = e.currentTarget.value; + setCustomCommand(v); + if (isCustomSelected) { + setCommandTouched(true); + setCommand(v.trim()); + } + }} + /> + + +
+ Project + setFilterQuery(e.currentTarget.value)} + /> + {!ready ? ( +

Loading projects…

+ ) : ( +
+ {visibleGroups.map((group) => { + const active = selectedGroup?.main.path === group.main.path; + return ( + + ); + })} + {discovered.length > 0 && visibleGroups.length === 0 && ( +

No projects match.

+ )} +
+ )} + + {cwdRawVisible && ( + + )} +
+ +
+ + {!advancedCollapsed && ( +
+ + + {structuredSupported && ( + + )} + + {selectedGroup && ( +
+ Worktree +
+ + {worktreesOf(selectedGroup).map((wt) => ( + + ))} +
+
+ )} + + { + setBranchEdited(true); + setNewBranch(v); + }} + branches={branchesForCwd} + existingBranch={effectiveExistingBranch} + onExistingBranchChange={setExistingBranch} + /> +
+ )} +
+ +

+ {error} +

+
+ +
+ + ); +} diff --git a/web/src/components/spawn/SpawnModal.test.tsx b/web/src/components/spawn/SpawnModal.test.tsx new file mode 100644 index 0000000..6cc0360 --- /dev/null +++ b/web/src/components/spawn/SpawnModal.test.tsx @@ -0,0 +1,108 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { DaemonInfo, Project, Session } from "../../types.js"; +import { SpawnModal } from "./SpawnModal.js"; + +vi.mock("../../api.js", () => ({ + createSession: vi.fn(), + fetchBranches: vi.fn(), + fetchNames: vi.fn(), + getDaemonInfo: vi.fn(), + gitRoot: vi.fn(), + listDir: vi.fn(), + projects: vi.fn(), +})); + +import { createSession, fetchNames, getDaemonInfo, listDir, projects as fetchProjects } from "../../api.js"; + +function info(): DaemonInfo { + return { hostname: "h", version: "1", home_dir: "/home/n", presets: [{ id: "claude", label: "Claude", command: "claude" }] }; +} + +function project(over: Partial = {}): Project { + return { path: "/repos/p", name: "p", ...over }; +} + +beforeEach(() => { + vi.mocked(getDaemonInfo).mockResolvedValue(info()); + vi.mocked(fetchProjects).mockResolvedValue([project()]); + vi.mocked(fetchNames).mockResolvedValue({ names: ["amber-fox"] }); + vi.mocked(listDir).mockResolvedValue({ path: "", entries: [] }); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +async function settle() { + await waitFor(() => expect(screen.queryByText("Loading projects…")).toBeNull()); +} + +describe("SpawnModal", () => { + it("mounts the spawn form inside a dialog", async () => { + render(); + await settle(); + + expect(screen.getByRole("dialog")).not.toBeNull(); + expect(screen.getByRole("button", { name: "Spawn" })).not.toBeNull(); + }); + + it("titles a seeded open as a clone", async () => { + render(); + await settle(); + + expect(screen.getByText("Spawn similar")).not.toBeNull(); + }); + + it("titles a plain open as a new session", async () => { + render(); + await settle(); + + expect(screen.getByText("Spawn session")).not.toBeNull(); + }); + + it("closes on Escape, on Cancel and on a backdrop click", async () => { + const onClose = vi.fn(); + const { unmount } = render(); + await settle(); + + fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" }); + expect(onClose).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(onClose).toHaveBeenCalledTimes(2); + unmount(); + + render(); + await settle(); + fireEvent.click(document.querySelector(".modal-backdrop")!); + expect(onClose).toHaveBeenCalledTimes(3); + }); + + it("closes before handing the created session to its caller", async () => { + const created: Session = { + id: "s1", + cwd: "/repos/p", + command: "claude", + env: {}, + metadata: {}, + status: "Running", + created_at: "2026-01-01T00:00:00Z", + }; + vi.mocked(createSession).mockResolvedValue(created); + const calls: string[] = []; + const onClose = vi.fn(() => calls.push("close")); + const onSuccess = vi.fn(() => calls.push("success")); + render(); + await settle(); + fireEvent.click(screen.getByText("p")); + + fireEvent.click(screen.getByRole("button", { name: "Spawn" })); + + await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(created)); + expect(calls).toEqual(["close", "success"]); + }); +}); diff --git a/web/src/components/spawn/SpawnModal.tsx b/web/src/components/spawn/SpawnModal.tsx new file mode 100644 index 0000000..696d529 --- /dev/null +++ b/web/src/components/spawn/SpawnModal.tsx @@ -0,0 +1,82 @@ +// React port of ../../spawnModal.ts: dialog chrome around SpawnForm. Escape, +// the ✕ and a click on the backdrop all close it; SpawnForm's own DirAutocomplete +// swallows Escape first, so dismissing a dropdown never also discards the form. + +import { useEffect, useRef } from "react"; +import { createPortal } from "react-dom"; + +import type { Session } from "../../types.js"; +import { SpawnForm, type SpawnFormProps } from "./SpawnForm.js"; + +export interface SpawnModalProps { + seed?: SpawnFormProps["seed"]; + /** Called with the created session after a successful submit — the modal + * has already closed by then, so the caller decides where it lands. */ + onSuccess(session: Session): void; + onClose(): void; +} + +export function SpawnModal({ seed, onSuccess, onClose }: SpawnModalProps) { + const filterInputRef = useRef(null); + + const handleSuccess = (session: Session) => { + onClose(); + onSuccess(session); + }; + + // The project filter owns the keyboard on open: typing narrows the list and + // Tab from here reaches the harness tiles, matching the TUI's draft pane. + useEffect(() => { + const host = filterInputRef.current; + host?.querySelector("input[placeholder='Filter projects…']")?.focus(); + }, []); + + return createPortal( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
{ + if (e.key === "Escape" && !e.defaultPrevented) { + e.preventDefault(); + onClose(); + } + }} + > +
+

+ {seed ? "Spawn similar" : "Spawn session"} +

+ +
+
+ +
+
+ +
+
+
, + document.body, + ); +} diff --git a/web/src/components/spawn/index.ts b/web/src/components/spawn/index.ts new file mode 100644 index 0000000..04e802c --- /dev/null +++ b/web/src/components/spawn/index.ts @@ -0,0 +1,8 @@ +export { SpawnModal, type SpawnModalProps } from "./SpawnModal.js"; +export { SpawnForm, type SpawnFormProps } from "./SpawnForm.js"; +export { DirAutocomplete, type DirAutocompleteProps } from "./DirAutocomplete.js"; +export { BranchPicker, type BranchPickerProps } from "./BranchPicker.js"; +export { useFastForward, type UseFastForwardOptions } from "./useFastForward.js"; +// spawnSimilar.ts is pure seed-building logic with no DOM dependency — reused +// as-is rather than re-implemented for React (#248). +export { spawnSimilarSeed, spawnSimilarProjectSeed } from "../../spawnSimilar.js"; diff --git a/web/src/components/spawn/useFastForward.test.ts b/web/src/components/spawn/useFastForward.test.ts new file mode 100644 index 0000000..dcdfc14 --- /dev/null +++ b/web/src/components/spawn/useFastForward.test.ts @@ -0,0 +1,114 @@ +// @vitest-environment happy-dom +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { Project, Session, SpawnPreset } from "../../types.js"; +import { useFastForward } from "./useFastForward.js"; + +vi.mock("../../api.js", () => ({ + createSession: vi.fn(), + fetchNames: vi.fn(), + getDaemonInfo: vi.fn(), + projects: vi.fn(), +})); + +import { createSession, fetchNames, getDaemonInfo, projects as fetchProjects } from "../../api.js"; + +const mockCreateSession = vi.mocked(createSession); +const mockFetchNames = vi.mocked(fetchNames); +const mockGetDaemonInfo = vi.mocked(getDaemonInfo); +const mockFetchProjects = vi.mocked(fetchProjects); + +const presets: SpawnPreset[] = [{ id: "claude", label: "Claude", command: "claude" }]; + +function project(over: Partial = {}): Project { + return { path: "/repos/p", name: "p", base_repo: "/repos/p", last_command: "claude", ...over }; +} + +function session(): Session { + return { + id: "s1", + cwd: "/repos/p", + command: "claude", + env: {}, + metadata: {}, + status: "Running", + created_at: "2026-01-01T00:00:00Z", + }; +} + +afterEach(() => vi.clearAllMocks()); + +describe("useFastForward", () => { + it("spawns immediately once every default resolves", async () => { + mockFetchProjects.mockResolvedValue([project()]); + mockFetchNames.mockResolvedValue({ names: ["amber-fox"] }); + mockGetDaemonInfo.mockResolvedValue({ hostname: "h", version: "1", presets }); + const created = session(); + mockCreateSession.mockResolvedValue(created); + const onSpawned = vi.fn(); + const onFallback = vi.fn(); + + const { result } = renderHook(() => useFastForward({ onSpawned, onFallback })); + await act(() => result.current()); + + expect(mockCreateSession).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: "/repos/p", + command: "claude", + worktree: { base_repo: "/repos/p", branch: "amber-fox", create_branch: true }, + }), + ); + expect(onSpawned).toHaveBeenCalledWith(created); + expect(onFallback).not.toHaveBeenCalled(); + }); + + it("falls back to a pre-filled seed when the daemon rejects the spawn", async () => { + mockFetchProjects.mockResolvedValue([project()]); + mockFetchNames.mockResolvedValue({ names: ["amber-fox"] }); + mockGetDaemonInfo.mockResolvedValue({ hostname: "h", version: "1", presets }); + mockCreateSession.mockRejectedValue(new Error("boom")); + const onSpawned = vi.fn(); + const onFallback = vi.fn(); + + const { result } = renderHook(() => useFastForward({ onSpawned, onFallback })); + await act(() => result.current()); + + expect(onSpawned).not.toHaveBeenCalled(); + expect(onFallback).toHaveBeenCalledWith( + expect.objectContaining({ cwd: "/repos/p", command: "claude", worktreeBranch: "amber-fox" }), + ); + }); + + it("falls back when nothing is known yet (no projects, no handle)", async () => { + mockFetchProjects.mockResolvedValue([]); + mockFetchNames.mockRejectedValue(new Error("exhausted")); + mockGetDaemonInfo.mockResolvedValue({ hostname: "h", version: "1", presets }); + const onSpawned = vi.fn(); + const onFallback = vi.fn(); + + const { result } = renderHook(() => useFastForward({ onSpawned, onFallback })); + await act(() => result.current()); + + expect(mockCreateSession).not.toHaveBeenCalled(); + expect(onFallback).toHaveBeenCalledWith(expect.objectContaining({ cwd: "" })); + }); + + it("prefers the focused session's project over the freshest one", async () => { + mockFetchProjects.mockResolvedValue([ + project({ path: "/repos/fresh", name: "fresh" }), + project({ path: "/repos/current", name: "current", last_activity: "2020-01-01T00:00:00Z" }), + ]); + mockFetchNames.mockResolvedValue({ names: ["h"] }); + mockGetDaemonInfo.mockResolvedValue({ hostname: "h", version: "1", presets }); + mockCreateSession.mockResolvedValue(session()); + const onSpawned = vi.fn(); + + const { result } = renderHook(() => + useFastForward({ focusedCwd: "/repos/current", onSpawned, onFallback: vi.fn() }), + ); + await act(() => result.current()); + + expect(mockCreateSession).toHaveBeenCalledWith(expect.objectContaining({ cwd: "/repos/current" })); + }); +}); diff --git a/web/src/components/spawn/useFastForward.ts b/web/src/components/spawn/useFastForward.ts new file mode 100644 index 0000000..e530831 --- /dev/null +++ b/web/src/components/spawn/useFastForward.ts @@ -0,0 +1,57 @@ +// React port of main.ts's `requestFastForward` (#67): one keystroke, every +// spawn default resolved — most recent project (or the one the operator is +// on), its last command, a fresh generated branch handle, worktree on with a +// new branch. A failed POST, or any missing default, falls back to the same +// pre-filled form `onFallback` opens, rather than a dead keystroke. + +import { useCallback } from "react"; + +import { createSession, fetchNames, getDaemonInfo, projects as fetchProjects } from "../../api.js"; +import { buildFastForwardRequest, fastForwardSeed, resolveFastForward } from "../../fastForward.js"; +import { FALLBACK_PRESETS, type SpawnFormSeed } from "../../spawnForm.js"; +import type { Project, Session, SpawnPreset } from "../../types.js"; + +export interface UseFastForwardOptions { + /** The project of the session the operator is currently on, if any — its + * base repo for a worktree session, else its cwd. Same rule the plain + * spawn modal's seed uses, so `N` lands where the operator currently is. */ + focusedCwd?: string; + onSpawned(session: Session): void; + onFallback(seed: SpawnFormSeed): void; +} + +async function resolvePresets(): Promise { + try { + const info = await getDaemonInfo(); + if (info.presets?.length) return info.presets; + } catch { + // keep fallbacks + } + return FALLBACK_PRESETS; +} + +/** Returns a function to call on the fast-forward keystroke. */ +export function useFastForward(opts: UseFastForwardOptions): () => Promise { + const { focusedCwd, onSpawned, onFallback } = opts; + + return useCallback(async () => { + const [discovered, handle] = await Promise.all([ + fetchProjects().catch(() => [] as Project[]), + fetchNames(1) + .then((batch) => batch.names[0]) + .catch(() => undefined), + ]); + const presets = await resolvePresets(); + const resolved = resolveFastForward(discovered, presets, handle, focusedCwd); + const req = buildFastForwardRequest(resolved); + if (req) { + try { + onSpawned(await createSession(req)); + return; + } catch { + // degrades to the pre-filled form below, never a silent dead key + } + } + onFallback(fastForwardSeed(resolved)); + }, [focusedCwd, onSpawned, onFallback]); +} diff --git a/web/src/spawnForm.ts b/web/src/spawnForm.ts index 180bcb6..76c708f 100644 --- a/web/src/spawnForm.ts +++ b/web/src/spawnForm.ts @@ -15,9 +15,9 @@ import { splitCommand, toolIconSvg, TOOL_LABELS } from "./toolIcon.js"; import { abbreviateCwd } from "./types.js"; import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js"; -const STRUCTURED_VIEW_TOOLS = new Set(["claude", "codex"]); +export const STRUCTURED_VIEW_TOOLS = new Set(["claude", "codex"]); -function supportsStructuredView(command: string): boolean { +export function supportsStructuredView(command: string): boolean { const tool = splitCommand(command).tool; return tool !== null && STRUCTURED_VIEW_TOOLS.has(tool); }