Post-React-migration regressions: spawn submit button moved to modal footer, sidebar footer restored (help/theme/palette/status/spawn), session card stripped of redundant Tailwind, action buttons use proper leaf-act/leaf-actions CSS structure for hover-only visibility. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
858 lines
31 KiB
TypeScript
858 lines
31 KiB
TypeScript
// 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<HTMLFormElement>(null);
|
|
const filterInputRef = useRef<HTMLInputElement>(null);
|
|
const customCommandRef = useRef<HTMLInputElement>(null);
|
|
const rawCwdRef = useRef<HTMLInputElement>(null);
|
|
const destroyedRef = useRef(false);
|
|
useEffect(() => () => {
|
|
destroyedRef.current = true;
|
|
}, []);
|
|
|
|
// ---- harness / command -------------------------------------------------
|
|
const [homeDir, setHomeDir] = useState("");
|
|
const [infoLoading, setInfoLoading] = useState(true);
|
|
const [presets, setPresets] = useState<SpawnPreset[]>(FALLBACK_PRESETS);
|
|
const [selectedPresetId, setSelectedPresetId] = useState("");
|
|
const [command, setCommand] = useState("");
|
|
const [customCommand, setCustomCommand] = useState("");
|
|
const [commandTouched, setCommandTouched] = useState(false);
|
|
const [structuredView, setStructuredView] = useState(false);
|
|
const [structuredTouched, setStructuredTouched] = useState(false);
|
|
|
|
const activePreset = useMemo(() => presets.find((p) => p.id === selectedPresetId), [presets, selectedPresetId]);
|
|
const isCustomSelected = !activePreset || activePreset.command === "";
|
|
|
|
useEffect(() => {
|
|
if (commandTouched && isCustomSelected) customCommandRef.current?.focus();
|
|
}, [isCustomSelected, commandTouched]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
getDaemonInfo()
|
|
.then((info) => {
|
|
if (cancelled) return;
|
|
setHomeDir(info.home_dir ?? "");
|
|
if (info.presets?.length) setPresets(info.presets);
|
|
})
|
|
.catch(() => {
|
|
// keep fallbacks
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setInfoLoading(false);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
// Pick a sensible starting command whenever the preset list changes and the
|
|
// operator hasn't touched it yet: a seed's command (matched to a preset, or
|
|
// dropped into custom), else the first non-custom preset.
|
|
useEffect(() => {
|
|
if (commandTouched) return;
|
|
const wanted = prefill.command;
|
|
if (wanted) {
|
|
const match = presets.find((p) => p.command === wanted && p.command !== "");
|
|
if (match) {
|
|
setSelectedPresetId(match.id);
|
|
setCommand(match.command);
|
|
return;
|
|
}
|
|
const custom = presets.find((p) => p.command === "");
|
|
setCustomCommand(wanted);
|
|
setSelectedPresetId(custom ? custom.id : "custom");
|
|
setCommand(wanted);
|
|
return;
|
|
}
|
|
const first = presets.find((p) => p.command !== "");
|
|
if (first) {
|
|
setSelectedPresetId(first.id);
|
|
setCommand(first.command);
|
|
}
|
|
// prefill is captured once at construction, not a reactive dependency.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [presets, commandTouched]);
|
|
|
|
const structuredSupported = useMemo(() => supportsStructuredView(command), [command]);
|
|
useEffect(() => {
|
|
if (!structuredSupported) setStructuredView(false);
|
|
else if (!structuredTouched) setStructuredView(true);
|
|
}, [structuredSupported, structuredTouched]);
|
|
|
|
// ---- project picker ------------------------------------------------------
|
|
const [discovered, setDiscovered] = useState<Project[]>([]);
|
|
const [projectsLoading, setProjectsLoading] = useState(true);
|
|
const [filterQuery, setFilterQuery] = useState("");
|
|
const [cwd, setCwd] = useState("");
|
|
const [cwdRawVisible, setCwdRawVisible] = useState(false);
|
|
const [rawFocusToken, setRawFocusToken] = useState(0);
|
|
const [selectedGroupPath, setSelectedGroupPath] = useState<string | null>(null);
|
|
const [worktreeChoice, setWorktreeChoice] = useState<WorktreeChoice>("new");
|
|
|
|
const groups = useMemo(() => groupProjects(sortProjects(discovered, new Date())), [discovered]);
|
|
const visibleGroups = useMemo(
|
|
() => groups.filter((g) => filterProjects([g.main, ...g.worktrees], filterQuery).length > 0),
|
|
[groups, filterQuery],
|
|
);
|
|
const selectedGroup = useMemo(
|
|
() => groups.find((g) => g.main.path === selectedGroupPath) ?? null,
|
|
[groups, selectedGroupPath],
|
|
);
|
|
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
fetchProjects()
|
|
.then((list) => {
|
|
if (!cancelled) setDiscovered(list);
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setDiscovered([]);
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setProjectsLoading(false);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
// ---- worktree (branch-only; base_repo inferred via scan or /gitroot) ----
|
|
const [wtEnabled, setWtEnabled] = useState(prefill.worktreeEnabled ?? true);
|
|
const [inferredBaseRepo, setInferredBaseRepo] = useState<string | null>(null);
|
|
const [wtHint, setWtHint] = useState<string | null>(null);
|
|
const [name, setName] = useState(prefill.name ?? "");
|
|
const [branchEdited, setBranchEdited] = useState(Boolean(prefill.worktreeBranchEdited));
|
|
const [branchMode, setBranchMode] = useState<WorktreeBranchMode>(prefill.worktreeBranchMode ?? "new");
|
|
const [newBranch, setNewBranch] = useState(prefill.worktreeBranch ?? "");
|
|
const [existingBranch, setExistingBranch] = useState("");
|
|
const [branchCache, setBranchCache] = useState<{ cwd: string; branches: BranchInfo[] } | null>(null);
|
|
|
|
const [advancedCollapsed, setAdvancedCollapsed] = useState(true);
|
|
const [error, setError] = useState("");
|
|
const [, setSubmitting] = useState(false);
|
|
const [ready, setReady] = useState(false);
|
|
|
|
const probeTokenRef = useRef(0);
|
|
const probeTimerRef = useRef<number | undefined>(undefined);
|
|
|
|
const applyBaseRepoResolution = useCallback((res: BaseRepoResolution) => {
|
|
switch (res.kind) {
|
|
case "disabled":
|
|
setInferredBaseRepo(null);
|
|
setWtHint(null);
|
|
return;
|
|
case "no-cwd":
|
|
setInferredBaseRepo(null);
|
|
setWtHint("Choose a working directory first.");
|
|
return;
|
|
case "found":
|
|
setInferredBaseRepo(res.repo);
|
|
setWtHint(null);
|
|
setBranchCache(null);
|
|
setExistingBranch("");
|
|
return;
|
|
case "probe":
|
|
return; // caller schedules the probe
|
|
}
|
|
}, []);
|
|
|
|
const probeGitRoot = useCallback(async (path: string) => {
|
|
const token = ++probeTokenRef.current;
|
|
if (!path) {
|
|
setInferredBaseRepo(null);
|
|
setWtHint("Choose a working directory first.");
|
|
return;
|
|
}
|
|
setWtHint("Checking for a git repository…");
|
|
let root: string | undefined;
|
|
try {
|
|
root = (await gitRoot(path)).root;
|
|
} catch {
|
|
root = undefined;
|
|
}
|
|
if (token !== probeTokenRef.current || destroyedRef.current) return; // a newer probe superseded us
|
|
if (root) {
|
|
setInferredBaseRepo(root);
|
|
setWtHint(null);
|
|
setBranchCache(null);
|
|
setExistingBranch("");
|
|
} else {
|
|
setInferredBaseRepo(null);
|
|
// Publish the untick too, or a later submit fails with an inferred repo
|
|
// that no longer matches an unticked box.
|
|
setWtEnabled(false);
|
|
setWtHint("Not inside a git repository — pick a dir in a repo to use a worktree.");
|
|
}
|
|
}, []);
|
|
|
|
const scheduleGitRootProbe = useCallback(
|
|
(path: string) => {
|
|
window.clearTimeout(probeTimerRef.current);
|
|
probeTimerRef.current = window.setTimeout(() => {
|
|
probeTimerRef.current = undefined;
|
|
void probeGitRoot(path);
|
|
}, GITROOT_PROBE_DEBOUNCE_MS);
|
|
},
|
|
[probeGitRoot],
|
|
);
|
|
useEffect(() => () => window.clearTimeout(probeTimerRef.current), []);
|
|
|
|
// Seed the form once the initial project scan and daemon info have both
|
|
// settled — mirrors createSpawnForm's `loadProjects()`. Computed from local
|
|
// values rather than reading state back, since the setState calls below
|
|
// won't have committed yet within this same effect.
|
|
const seededRef = useRef(false);
|
|
useEffect(() => {
|
|
if (infoLoading || projectsLoading || seededRef.current) return;
|
|
seededRef.current = true;
|
|
|
|
const seedCwd = prefill.cwd ?? null;
|
|
let finalCwd = "";
|
|
let finalCwdRawVisible = false;
|
|
let finalGroupPath: string | null = null;
|
|
let finalChoice: WorktreeChoice = "new";
|
|
|
|
const restore = (path: string): boolean => {
|
|
for (const group of groups) {
|
|
const wt = worktreesOf(group).find((p) => p.path === path);
|
|
const choice: WorktreeChoice | null = wt ? wt.path : group.main.path === path ? "new" : null;
|
|
if (choice === null) continue;
|
|
finalGroupPath = group.main.path;
|
|
finalChoice = choice;
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
if (seedCwd) {
|
|
if (prefill.cwdRawVisible === false) {
|
|
finalCwd = seedCwd;
|
|
if (!restore(seedCwd)) finalCwdRawVisible = true;
|
|
} else {
|
|
finalCwd = seedCwd;
|
|
finalCwdRawVisible = true;
|
|
}
|
|
} else if (discovered.length === 0) {
|
|
finalCwd = homeDir;
|
|
finalCwdRawVisible = true;
|
|
}
|
|
|
|
setCwd(finalCwd);
|
|
setCwdRawVisible(finalCwdRawVisible);
|
|
setSelectedGroupPath(finalGroupPath);
|
|
setWorktreeChoice(finalChoice);
|
|
|
|
const res = resolveBaseRepoFor(finalCwd, wtEnabled, discovered);
|
|
applyBaseRepoResolution(res);
|
|
if (res.kind === "probe") scheduleGitRootProbe(finalCwd);
|
|
|
|
setReady(true);
|
|
// groups/wtEnabled/discovered/homeDir are read for this one settle pass;
|
|
// re-running on their later changes would re-seed a form the operator
|
|
// has since edited.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [infoLoading, projectsLoading]);
|
|
|
|
// ---- name (also seeds the worktree branch) ------------------------------
|
|
const skipInitialBranchSync = useRef(Boolean(prefill.worktreeBranch));
|
|
useEffect(() => {
|
|
// One-time construction seeding, mirroring spawnForm.ts precedence: an
|
|
// explicit seed wins outright; otherwise the prefilled name; otherwise a
|
|
// freshly generated handle (#53).
|
|
if (prefill.worktreeBranch) return;
|
|
if (!branchEdited && branchMode === "new" && prefill.name) return; // handled below, mount-only
|
|
if (!branchEdited && branchMode === "new") {
|
|
void fetchNames(1)
|
|
.then((batch) => {
|
|
const handle = batch.names[0];
|
|
if (destroyedRef.current || !handle) return;
|
|
setNewBranch((cur) => cur || handle);
|
|
})
|
|
.catch(() => {
|
|
// the operator types their own branch
|
|
});
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!skipInitialBranchSync.current && !branchEdited && branchMode === "new" && name) {
|
|
setNewBranch(name);
|
|
}
|
|
skipInitialBranchSync.current = false;
|
|
// A cleared name leaves the branch on its last value; only `name` changes
|
|
// (typed or the mount-time prefill) drive this sync.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [name]);
|
|
|
|
// ---- existing-branch picker (#61): fetch once per cwd -------------------
|
|
useEffect(() => {
|
|
if (branchMode !== "existing" || !cwd || !inferredBaseRepo) return;
|
|
if (branchCache?.cwd === cwd) return;
|
|
let cancelled = false;
|
|
const target = cwd;
|
|
fetchBranches(target)
|
|
.then((branches) => {
|
|
if (cancelled || destroyedRef.current) return;
|
|
setBranchCache({ cwd: target, branches });
|
|
})
|
|
.catch(() => {
|
|
// the picker just stays empty; "new" mode still works
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [branchMode, cwd, inferredBaseRepo, branchCache]);
|
|
|
|
const branchesForCwd = branchCache?.cwd === cwd ? branchCache.branches : undefined;
|
|
|
|
// A native <select> defaults its displayed (and reported) value to the
|
|
// first enabled option once nothing is explicitly selected. Derived at
|
|
// render/submit time rather than synced via an effect, so there is no
|
|
// window where the control shows one branch while state still holds "".
|
|
const effectiveExistingBranch =
|
|
existingBranch || branchesForCwd?.find((b) => !b.worktree && !b.is_head)?.name || "";
|
|
|
|
// ---- emit (#observability parity with spawnForm.ts's onChange) ---------
|
|
useEffect(() => {
|
|
if (!ready) return;
|
|
onChange?.({
|
|
command,
|
|
cwd,
|
|
name,
|
|
structuredView,
|
|
autoApprove: activePreset?.auto_approve ?? false,
|
|
worktreeEnabled: wtEnabled,
|
|
worktreeBranch: branchMode === "existing" ? effectiveExistingBranch : newBranch,
|
|
worktreeBranchEdited: branchEdited,
|
|
worktreeBranchMode: branchMode,
|
|
cwdRawVisible,
|
|
});
|
|
}, [
|
|
ready,
|
|
command,
|
|
cwd,
|
|
name,
|
|
structuredView,
|
|
activePreset,
|
|
wtEnabled,
|
|
branchMode,
|
|
effectiveExistingBranch,
|
|
newBranch,
|
|
branchEdited,
|
|
cwdRawVisible,
|
|
onChange,
|
|
]);
|
|
|
|
// ---- handlers -------------------------------------------------------------
|
|
const selectPreset = useCallback(
|
|
(id: string) => {
|
|
setCommandTouched(true);
|
|
setSelectedPresetId(id);
|
|
const preset = presets.find((p) => p.id === id);
|
|
if (!preset || preset.command === "") setCommand(customCommand.trim());
|
|
else setCommand(preset.command);
|
|
},
|
|
[presets, customCommand],
|
|
);
|
|
|
|
const clearProjectSelection = useCallback(() => {
|
|
setSelectedGroupPath(null);
|
|
setWorktreeChoice("new");
|
|
}, []);
|
|
|
|
const showRawCwd = useCallback(
|
|
(prefillValue?: string, focus = true) => {
|
|
setCwdRawVisible(true);
|
|
if (prefillValue != null) setCwd(prefillValue);
|
|
clearProjectSelection();
|
|
if (focus) setRawFocusToken((t) => t + 1);
|
|
},
|
|
[clearProjectSelection],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (rawFocusToken === 0) return;
|
|
rawCwdRef.current?.focus();
|
|
rawCwdRef.current?.select();
|
|
}, [rawFocusToken]);
|
|
|
|
const applyWorktreeChoice = useCallback(
|
|
(group: ProjectGroup, choice: WorktreeChoice) => {
|
|
const target = choice === "new" ? group.main : worktreesOf(group).find((p) => p.path === choice);
|
|
if (!target) return;
|
|
setWorktreeChoice(choice);
|
|
setCwd(target.path);
|
|
setBranchCache(null);
|
|
setExistingBranch("");
|
|
if (choice === "new") {
|
|
setWtEnabled(true);
|
|
applyBaseRepoResolution({ kind: "found", repo: group.main.base_repo ?? group.main.path });
|
|
} else {
|
|
setWtEnabled(false);
|
|
applyBaseRepoResolution({ kind: "disabled" });
|
|
}
|
|
},
|
|
[applyBaseRepoResolution],
|
|
);
|
|
|
|
const selectProject = useCallback(
|
|
(group: ProjectGroup) => {
|
|
setSelectedGroupPath(group.main.path);
|
|
setCwdRawVisible(false);
|
|
// Rule 4: a box that is itself a linked worktree defaults to running there.
|
|
applyWorktreeChoice(group, group.main.is_worktree ? group.main.path : "new");
|
|
},
|
|
[applyWorktreeChoice],
|
|
);
|
|
|
|
const onWtEnabledChange = useCallback(
|
|
(next: boolean) => {
|
|
setWtEnabled(next);
|
|
const res = resolveBaseRepoFor(cwd, next, discovered);
|
|
applyBaseRepoResolution(res);
|
|
if (res.kind === "probe") scheduleGitRootProbe(cwd);
|
|
},
|
|
[cwd, discovered, applyBaseRepoResolution, scheduleGitRootProbe],
|
|
);
|
|
|
|
const onRawCwdChange = useCallback(
|
|
(v: string) => {
|
|
setCwd(v);
|
|
setBranchCache(null);
|
|
setExistingBranch("");
|
|
if (wtEnabled) scheduleGitRootProbe(v);
|
|
},
|
|
[wtEnabled, scheduleGitRootProbe],
|
|
);
|
|
|
|
const onSubmit = useCallback(
|
|
async (e: React.FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
setError("");
|
|
const values: SpawnFormValues = {
|
|
command,
|
|
cwd,
|
|
name,
|
|
structuredView,
|
|
autoApprove: activePreset?.auto_approve ?? false,
|
|
worktreeEnabled: wtEnabled,
|
|
worktreeBranch: branchMode === "existing" ? effectiveExistingBranch : newBranch,
|
|
worktreeBranchEdited: branchEdited,
|
|
worktreeBranchMode: branchMode,
|
|
};
|
|
const built = buildSpawnRequest(values, inferredBaseRepo);
|
|
if ("error" in built) {
|
|
setError(built.error);
|
|
return;
|
|
}
|
|
setSubmitting(true);
|
|
try {
|
|
const session = await createSession(built.req);
|
|
onSuccess(session);
|
|
} catch (err) {
|
|
setError((err as Error).message);
|
|
setSubmitting(false);
|
|
}
|
|
},
|
|
[
|
|
command,
|
|
cwd,
|
|
name,
|
|
structuredView,
|
|
activePreset,
|
|
wtEnabled,
|
|
branchMode,
|
|
effectiveExistingBranch,
|
|
newBranch,
|
|
branchEdited,
|
|
inferredBaseRepo,
|
|
onSuccess,
|
|
],
|
|
);
|
|
|
|
// ---- Enter submits from anywhere (#97) ------------------------------------
|
|
const onFormKeyDown = useCallback(
|
|
(e: React.KeyboardEvent<HTMLFormElement>) => {
|
|
if (e.key !== "Enter" || e.defaultPrevented) return;
|
|
const target = e.target as HTMLElement;
|
|
|
|
if (target.dataset.role === "toggle") {
|
|
e.preventDefault();
|
|
(target as HTMLInputElement).click();
|
|
return;
|
|
}
|
|
|
|
if (target instanceof HTMLButtonElement && target.dataset.picker) {
|
|
e.preventDefault();
|
|
if (target.dataset.active === "true") {
|
|
formRef.current?.requestSubmit();
|
|
} else {
|
|
target.click();
|
|
}
|
|
return;
|
|
}
|
|
|
|
e.preventDefault();
|
|
if (target === filterInputRef.current) {
|
|
// Resolve the filter to a project only when the current pick isn't
|
|
// one of the matches: a filter typed past it means the operator
|
|
// moved on, while a pick that still matches keeps its worktree choice.
|
|
const stillMatches =
|
|
selectedGroup != null && visibleGroups.some((g) => g.main.path === selectedGroup.main.path);
|
|
if (!stillMatches && visibleGroups[0]) selectProject(visibleGroups[0]);
|
|
}
|
|
formRef.current?.requestSubmit();
|
|
},
|
|
[selectedGroup, visibleGroups, selectProject],
|
|
);
|
|
|
|
return (
|
|
<form
|
|
id="spawn-form"
|
|
ref={formRef}
|
|
onSubmit={onSubmit}
|
|
onKeyDown={onFormKeyDown}
|
|
className="mx-auto flex w-full max-w-[60ch] flex-col gap-sp-2 overflow-auto p-sp-3"
|
|
>
|
|
<div className="flex flex-col gap-sp-2">
|
|
<span className="text-fs-13 text-ink">Harness</span>
|
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-sp-2">
|
|
{presets.map((p) => {
|
|
const isCustom = p.command === "";
|
|
const toolId = presetToolId(p);
|
|
const icon = isCustom ? null : toolId ? harnessIcon(toolId) : harnessIcon("shell");
|
|
const label = TOOL_LABELS[toolId ?? ""] ?? p.label;
|
|
const active = p.id === selectedPresetId;
|
|
return (
|
|
<button
|
|
key={p.id}
|
|
type="button"
|
|
className={`flex flex-col items-center gap-sp-2 rounded-r-2 border px-sp-2 py-sp-3 text-center ${
|
|
active
|
|
? "border-accent bg-accent-tint"
|
|
: isCustom
|
|
? "border-dashed border-border text-muted"
|
|
: "border-border bg-surface-2 hover:border-border-strong"
|
|
}`}
|
|
onClick={() => selectPreset(p.id)}
|
|
>
|
|
<span
|
|
className={`flex h-8 w-8 items-center justify-center ${active ? "text-ink" : "text-muted"}`}
|
|
aria-hidden={icon ? undefined : true}
|
|
dangerouslySetInnerHTML={icon ? { __html: icon } : undefined}
|
|
>
|
|
{icon ? undefined : "…"}
|
|
</span>
|
|
<span className="max-w-full truncate text-fs-13 font-semibold">
|
|
{isCustom ? "Custom…" : label}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<input
|
|
ref={customCommandRef}
|
|
className="w-full rounded-r-2 border border-border bg-surface-2 px-sp-3 py-2.5 font-mono text-fs-15 text-ink"
|
|
name="command_raw"
|
|
type="text"
|
|
hidden={!isCustomSelected}
|
|
placeholder="bash -l"
|
|
autoComplete="off"
|
|
value={customCommand}
|
|
onChange={(e) => {
|
|
const v = e.currentTarget.value;
|
|
setCustomCommand(v);
|
|
if (isCustomSelected) {
|
|
setCommandTouched(true);
|
|
setCommand(v.trim());
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-sp-2">
|
|
<span className="text-fs-13 text-ink">Project</span>
|
|
<input
|
|
ref={filterInputRef}
|
|
className="w-full rounded-r-2 border border-border bg-surface-2 px-sp-3 py-2.5 font-mono text-fs-15 text-ink"
|
|
type="text"
|
|
placeholder="Filter projects…"
|
|
autoComplete="off"
|
|
value={filterQuery}
|
|
onChange={(e) => setFilterQuery(e.currentTarget.value)}
|
|
/>
|
|
{!ready ? (
|
|
<p className="m-0 px-sp-3 py-1.5 text-fs-13 text-faint">Loading projects…</p>
|
|
) : (
|
|
<div className="grid max-h-[300px] grid-cols-2 gap-sp-2 overflow-y-auto p-px">
|
|
{visibleGroups.map((group) => {
|
|
const active = selectedGroup?.main.path === group.main.path;
|
|
return (
|
|
<button
|
|
key={group.main.path}
|
|
type="button"
|
|
data-picker="project"
|
|
data-active={active || undefined}
|
|
className={`flex min-w-0 flex-col gap-0.5 rounded-r-2 border px-sp-3 py-sp-2 text-left ${
|
|
active ? "border-accent bg-accent-tint" : "border-border bg-surface-2 hover:border-border-strong"
|
|
} ${isStale(group.main, new Date()) ? "opacity-55" : ""}`}
|
|
onClick={() => selectProject(group)}
|
|
>
|
|
<span className="truncate text-fs-13 font-semibold text-ink">{group.main.name}</span>
|
|
<span className="break-words font-mono text-fs-12 text-muted">
|
|
{abbreviateCwd(parentDir(group.main.path), homeDir)}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
{discovered.length > 0 && visibleGroups.length === 0 && (
|
|
<p className="col-span-2 m-0 px-sp-3 py-1.5 text-fs-13 text-faint">No projects match.</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="self-start bg-transparent px-0 py-1 text-fs-13 italic text-muted hover:text-ink"
|
|
onClick={() => showRawCwd(cwd || homeDir)}
|
|
>
|
|
Other directory…
|
|
</button>
|
|
{cwdRawVisible && (
|
|
<DirAutocomplete
|
|
inputRef={rawCwdRef}
|
|
className="w-full rounded-r-2 border border-border bg-surface-2 px-sp-3 py-2.5 font-mono text-fs-15 text-ink"
|
|
name="cwd_raw"
|
|
type="text"
|
|
placeholder="/home/user/project"
|
|
value={cwd}
|
|
onChange={onRawCwdChange}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-col border-t border-border pt-sp-2">
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-center gap-sp-1 py-sp-2 text-left text-fs-11 text-muted"
|
|
aria-expanded={!advancedCollapsed}
|
|
onClick={() => setAdvancedCollapsed((c) => !c)}
|
|
>
|
|
<span className={`inline-block transition-transform ${advancedCollapsed ? "-rotate-90" : ""}`}>▾</span>
|
|
<span className="font-semibold">Advanced</span>
|
|
<span className="ml-auto text-fs-12 text-muted">{wtEnabled ? "creates a worktree" : "no worktree"}</span>
|
|
</button>
|
|
{!advancedCollapsed && (
|
|
<div className="flex flex-col gap-sp-3 py-sp-2">
|
|
<label className="flex flex-col gap-1 text-fs-12 text-muted">
|
|
Session name
|
|
<input
|
|
name="name"
|
|
type="text"
|
|
autoComplete="off"
|
|
placeholder="named after your first prompt"
|
|
className="w-full rounded-r-2 border border-border bg-surface-2 px-sp-3 py-2.5 font-mono text-fs-15 text-ink"
|
|
value={name}
|
|
onChange={(e) => setName(e.currentTarget.value)}
|
|
/>
|
|
</label>
|
|
|
|
{structuredSupported && (
|
|
<label className="flex cursor-pointer items-center gap-sp-2 text-fs-14 text-ink">
|
|
<input
|
|
type="checkbox"
|
|
data-role="toggle"
|
|
className="size-[18px] accent-accent"
|
|
checked={structuredView}
|
|
onChange={(e) => {
|
|
setStructuredTouched(true);
|
|
setStructuredView(e.currentTarget.checked);
|
|
}}
|
|
/>
|
|
Structured view
|
|
</label>
|
|
)}
|
|
|
|
{selectedGroup && (
|
|
<div className="flex flex-col gap-sp-2">
|
|
<span className="text-fs-13 text-ink">Worktree</span>
|
|
<div className="flex max-h-[200px] flex-col gap-sp-1 overflow-y-auto">
|
|
<button
|
|
type="button"
|
|
data-picker="worktree"
|
|
data-active={worktreeChoice === "new" || undefined}
|
|
className={pickerButtonClass(worktreeChoice === "new")}
|
|
onClick={() => applyWorktreeChoice(selectedGroup, "new")}
|
|
>
|
|
<span className="text-fs-13 font-semibold text-accent">+ new</span>
|
|
<span className="text-right text-fs-12 text-muted">
|
|
new worktree of {selectedGroup.main.name}
|
|
</span>
|
|
</button>
|
|
{worktreesOf(selectedGroup).map((wt) => (
|
|
<button
|
|
key={wt.path}
|
|
type="button"
|
|
data-picker="worktree"
|
|
data-active={worktreeChoice === wt.path || undefined}
|
|
className={pickerButtonClass(worktreeChoice === wt.path)}
|
|
onClick={() => applyWorktreeChoice(selectedGroup, wt.path)}
|
|
>
|
|
<span className="text-fs-13 font-semibold text-ink">{wt.name}</span>
|
|
<span className="text-right text-fs-12 text-muted">{wt.path}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<BranchPicker
|
|
enabled={wtEnabled}
|
|
onToggleEnabled={onWtEnabledChange}
|
|
hint={wtHint}
|
|
baseRepo={inferredBaseRepo}
|
|
mode={branchMode}
|
|
onModeChange={setBranchMode}
|
|
newBranch={newBranch}
|
|
onNewBranchChange={(v) => {
|
|
setBranchEdited(true);
|
|
setNewBranch(v);
|
|
}}
|
|
branches={branchesForCwd}
|
|
existingBranch={effectiveExistingBranch}
|
|
onExistingBranchChange={setExistingBranch}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<p role="alert" aria-live="polite" className={`m-0 text-fs-13 text-danger ${error ? "rounded-r-2 bg-danger-tint px-sp-3 py-sp-2" : "min-h-[1.2em]"}`}>
|
|
{error}
|
|
</p>
|
|
{!externalSubmit && (
|
|
<div className="flex justify-end">
|
|
<button
|
|
type="submit"
|
|
className="btn btn-primary rounded-r-1 px-sp-4 py-sp-1 text-fs-13 font-semibold"
|
|
>
|
|
Spawn
|
|
</button>
|
|
</div>
|
|
)}
|
|
</form>
|
|
);
|
|
}
|