// 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 "./model.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; /** When true, hides the built-in submit button (the modal provides its own). */ externalSubmit?: boolean; } export function SpawnForm({ seed, onChange, onSuccess, externalSubmit }: 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 [, 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}

{!externalSubmit && (
)} ); }