This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/web/src/spawnForm.ts
T

790 lines
29 KiB
TypeScript

import { escapeHtml } from "./html.js";
import {
createSession,
fetchBranches,
fetchNames,
getDaemonInfo,
projects as fetchProjects,
gitRoot,
} from "./api.js";
import { attachDirAutocomplete } from "./dirAutocomplete.js";
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
import { abbreviateCwd, abbreviateFolderName } from "./types.js";
/** Where a worktree's branch comes from: freshly created (default) or picked
* from the repository's existing branches (#61). */
export type WorktreeBranchMode = "new" | "existing";
/** Seed values for the spawn form — used by "clone" to open a draft with the
* same cwd, command, and name as an existing session, and by draft panes to
* restore their own half-filled state after a re-render. */
export interface SpawnFormSeed {
command?: string;
cwd?: string;
name?: string;
worktreeEnabled?: boolean;
worktreeBranch?: string;
/** Whether the operator typed the branch themselves, rather than it having
* been auto-synced from the session name. Carried separately because the
* value alone cannot distinguish the two. */
worktreeBranchEdited?: boolean;
worktreeBranchMode?: WorktreeBranchMode;
cwdRawVisible?: boolean;
}
/** The form's current values, reported on every change so a caller (the draft
* pane) can hang them off its layout leaf. */
export interface SpawnFormValues {
command: string;
cwd: string;
name: string;
worktreeEnabled: boolean;
worktreeBranch: string;
worktreeBranchEdited: boolean;
/** "new" creates the branch; "existing" checks out a picked one (#61). */
worktreeBranchMode: WorktreeBranchMode;
/** Whether the cwd lives in the raw text input (vs a picked recent row). */
cwdRawVisible?: boolean;
}
export function buildSpawnRequest(
values: SpawnFormValues,
baseRepo: string | null,
): { req: CreateSessionRequest } | { error: string } {
if (!values.cwd) return { error: "Pick a working directory." };
if (!values.command) return { error: "Pick a command (or fill the custom command)." };
const metadata: Record<string, unknown> = {};
const name = values.name.trim();
if (name) metadata.name = name;
const req: CreateSessionRequest = {
cwd: values.cwd,
command: values.command,
metadata,
};
if (values.worktreeEnabled) {
const mode = values.worktreeBranchMode ?? "new";
const branch = values.worktreeBranch.trim();
if (!baseRepo) return { error: "Worktree needs a directory inside a git repository." };
if (!branch) {
return { error: mode === "existing" ? "Pick an existing branch." : "Worktree requires a branch name." };
}
req.worktree = {
base_repo: baseRepo,
branch,
// Existing-mode picks must check out as-is; the daemon creates
// anything else with `worktree add -b` (#56/#61).
create_branch: mode !== "existing",
};
}
return { req };
}
/** Built-ins used when the daemon's `/api/info` presets can't be fetched
* (older daemon, transient error). Mirrors `maestro_config::builtin_presets`.
* Exported for the fast-forward spawn's preset fallback (#67). */
export const FALLBACK_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: "" },
];
/** 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 so both clients debounce alike. */
const GITROOT_PROBE_DEBOUNCE_MS = 250;
export interface SpawnFormHandle {
el: HTMLFormElement;
/** Remove global listeners (dir autocomplete) — call when unmounting. */
destroy(): void;
}
export interface SpawnFormOptions {
seed?: SpawnFormSeed;
/** Extra class(es) for the <form> root (e.g. `mv-draft` in a draft pane). */
class?: string;
/** Fired whenever any value changes (input, tile pick, checkbox). */
onChange?: (values: SpawnFormValues) => void;
/** Called with the created session after a successful submit. */
onSuccess: (session: Session) => void;
}
/** The spawn form itself — preset tiles, project picker, name, worktree.
* Mounted inline by draft panes (issue #57). */
export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
const prefill = opts.seed ?? {};
const form = document.createElement("form");
form.className = `spawn-form${opts.class ? ` ${opts.class}` : ""}`;
form.innerHTML = `
<div class="field">
<span class="field-label">Command</span>
<div class="tile-row cmd-tiles"></div>
<input class="raw-input cmd-raw" name="command_raw" type="text" hidden
placeholder="bash -l" autocomplete="off" />
</div>
<div class="field">
<span class="field-label">Working directory</span>
<input class="raw-input project-filter" type="text" placeholder="Filter projects…"
autocomplete="off" />
<div class="picker projects"></div>
<div class="raw-wrap cwd-raw-wrap" hidden>
<input class="raw-input cwd-raw" name="cwd_raw" type="text"
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
</div>
</div>
<div class="field">
<span class="field-label">Name</span>
<div class="name-row">
<input name="name" type="text" autocomplete="off" />
<button type="button" class="btn btn-icon reroll" aria-label="Generate a new name" title="New name">↻</button>
</div>
</div>
<fieldset class="worktree">
<legend>
<label class="row-inline">
<input type="checkbox" name="worktree_enabled" />
Create git worktree
</label>
</legend>
<p class="wt-hint" hidden></p>
<div class="wt-body" hidden>
<p class="wt-repo"></p>
<div class="wt-mode" role="radiogroup" aria-label="Branch source">
<label class="row-inline">
<input type="radio" name="worktree_branch_mode" value="new" />
New branch
</label>
<label class="row-inline">
<input type="radio" name="worktree_branch_mode" value="existing" />
Existing branch
</label>
</div>
<label class="wt-new-wrap">
Branch
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
</label>
<label class="wt-exist-wrap" hidden>
Branch
<select class="branch-select"></select>
</label>
</div>
</fieldset>
<p class="form-error" role="alert" aria-live="polite"></p>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Spawn</button>
</div>
`;
// Field queries are scoped to this form instance — several forms can be
// mounted at once (one per draft pane), so ids would collide.
const errEl = form.querySelector(".form-error") as HTMLElement;
const cmdTiles = form.querySelector(".cmd-tiles") as HTMLElement;
const cmdRaw = form.querySelector(".cmd-raw") as HTMLInputElement;
const recentsEl = form.querySelector(".projects") as HTMLElement;
const filterInput = form.querySelector(".project-filter") as HTMLInputElement;
const cwdRawWrap = form.querySelector(".cwd-raw-wrap") as HTMLElement;
const cwdRaw = form.querySelector(".cwd-raw") as HTMLInputElement;
const wtEnabled = form.querySelector("input[name=worktree_enabled]") as HTMLInputElement;
const wtHint = form.querySelector(".wt-hint") as HTMLElement;
const wtBody = form.querySelector(".wt-body") as HTMLElement;
const wtRepo = form.querySelector(".wt-repo") as HTMLElement;
const modeRadios = [
...form.querySelectorAll<HTMLInputElement>("input[name=worktree_branch_mode]"),
];
const newWrap = form.querySelector(".wt-new-wrap") as HTMLElement;
const existWrap = form.querySelector(".wt-exist-wrap") as HTMLElement;
const branchInput = form.querySelector("input[name=worktree_branch]") as HTMLInputElement;
const branchSelect = form.querySelector(".branch-select") as HTMLSelectElement;
const nameInput = form.querySelector("input[name=name]") as HTMLInputElement;
// ---- form state ------------------------------------------------------
let homeDir = "";
let presets: SpawnPreset[] = FALLBACK_PRESETS;
let selectedPresetId = "";
let command = ""; // resolved command (preset's, or the raw custom input)
let cwd = ""; // chosen working directory
let inferredBaseRepo: string | null = null; // from the gitroot probe or the project scan
let probeToken = 0; // guards async gitroot probes against races
// A seed's cwd, applied once the (async) projects load for the initial host
// settles — so the fetch can't race past and clobber the seeded directory.
let seedCwd: string | null = prefill.cwd ?? null;
let branchMode: WorktreeBranchMode = prefill.worktreeBranchMode ?? "new";
let branchToken = 0; // guards async branch-list loads against races
// Branch list for the current cwd — refetched whenever the target changes.
let branchCache: { cwd: string; branches: BranchInfo[] } | null = null;
// `emit` stays silent until the initial load settles and goes silent again
// once the form is destroyed. Construction runs with `cwd === ""` and the
// seed is only restored two fetches later, so emitting in between would
// blank the leaf's draft; and `multiview.render()` rebuilds forms while the
// old one's fetches are still in flight, so a destroyed form emitting would
// clobber the live one's leaf (#57 review).
let ready = false;
let destroyed = false;
const branchValue = () => (branchMode === "existing" ? branchSelect.value : branchInput.value);
const emit = () => {
if (!ready || destroyed) return;
opts.onChange?.({
command,
cwd,
name: nameInput.value,
worktreeEnabled: wtEnabled.checked,
worktreeBranch: branchValue(),
worktreeBranchEdited: branchEdited,
worktreeBranchMode: branchMode,
cwdRawVisible: !cwdRawWrap.hidden,
});
};
// ---- name (also seeds the worktree branch) ---------------------------
// The worktree branch defaults to the session name — including a clone's
// prefilled name — and tracks it on reroll/edit until the user types their
// own branch. Only in "new" mode: an existing-branch pick is not a name.
// True only once the operator types their own branch. It cannot be inferred
// from the seed: in "new" mode `emit()` publishes the auto-synced session
// name, so seeding from it froze the branch after any form rebuild
// (#67 review). `worktreeBranchEdited` is carried explicitly instead.
let branchEdited = Boolean(prefill.worktreeBranchEdited);
const syncBranchToName = () => {
if (!branchEdited && branchMode === "new") branchInput.value = nameInput.value;
};
// Handles come from `GET /api/names` in batches (#53): the first one seeds
// the field, rerolls consume the rest locally so they stay instant, and a
// new batch is fetched when (and only when) the queue runs dry. One refill
// at a time keeps rapid rerolls from storming the daemon.
const NAME_BATCH_SIZE = 20;
let nameQueue: string[] = [];
let nameFetch: Promise<void> | null = null;
// Guards against a late batch clobbering something the operator already
// typed or rerolled.
let nameTouched = Boolean(prefill.name);
const refillNames = (): Promise<void> => {
if (!nameFetch) {
nameFetch = fetchNames(NAME_BATCH_SIZE)
.then((batch) => {
if (!destroyed) nameQueue.push(...batch.names);
})
.catch(() => {
/* reroll just stays on the current name */
})
.finally(() => {
nameFetch = null;
});
}
return nameFetch;
};
nameInput.value = prefill.name ?? "";
if (!nameTouched) {
void refillNames().then(() => {
if (destroyed || nameTouched || nameInput.value) return;
const next = nameQueue.shift();
if (next !== undefined) {
nameInput.value = next;
syncBranchToName();
emit();
}
});
}
nameInput.addEventListener("input", () => {
nameTouched = true;
syncBranchToName();
emit();
});
branchInput.addEventListener("input", () => {
branchEdited = true;
emit();
});
(form.querySelector(".reroll") as HTMLButtonElement).addEventListener("click", () => {
nameTouched = true;
const apply = () => {
if (destroyed) return;
const next = nameQueue.shift();
if (next === undefined) return;
nameInput.value = next;
syncBranchToName();
emit();
};
// Only hit the daemon when the local batch is exhausted.
if (nameQueue.length === 0) {
void refillNames().then(apply);
} else {
apply();
}
});
syncBranchToName(); // seed the branch from the initial name
// ---- command tiles ---------------------------------------------------
// `focus` is false for the two initial applications (construction, and again
// when `/api/info` resolves): a draft pane must not grab the caret out from
// under the operator seconds after it mounts.
function selectPreset(id: string, focus = true): void {
selectedPresetId = id;
const preset = presets.find((p) => p.id === id);
const isCustom = !preset || preset.command === "";
cmdRaw.hidden = !isCustom;
if (isCustom) {
command = cmdRaw.value.trim();
if (focus) cmdRaw.focus();
} else {
command = preset!.command;
}
for (const tile of cmdTiles.querySelectorAll(".tile")) {
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === id);
}
emit();
}
/** Re-highlight the operator's already-picked tile after `renderCommandTiles`
* rebuilds the row from scratch (new tiles carry no `is-active` class) —
* without touching `command`, since that was already settled. */
function reapplyActiveTile(): void {
for (const tile of cmdTiles.querySelectorAll(".tile")) {
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === selectedPresetId);
}
}
function renderCommandTiles(): void {
cmdTiles.replaceChildren();
for (const p of presets) {
const tile = document.createElement("button");
tile.type = "button";
tile.className = "tile";
tile.dataset.id = p.id;
tile.innerHTML = `<span class="tile-label">${escapeHtml(p.label)}</span>`;
tile.addEventListener("click", () => {
commandTouched = true;
selectPreset(p.id);
});
cmdTiles.appendChild(tile);
}
}
// Set once the operator picks a tile or types a custom command — guards
// `applyInitialCommand()` the same way `nameTouched` guards a late name
// batch: a late `/api/info` resolution must never overwrite a field the
// operator has already touched (#76).
let commandTouched = false;
cmdRaw.addEventListener("input", () => {
if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") {
commandTouched = true;
command = cmdRaw.value.trim();
emit();
}
});
// Presets ride `/api/info`; render fallbacks immediately, then refine.
renderCommandTiles();
applyInitialCommand();
const infoLoad = getDaemonInfo()
.then((info) => {
homeDir = info.home_dir ?? "";
if (info.presets?.length) {
presets = info.presets;
renderCommandTiles();
if (commandTouched) {
reapplyActiveTile();
} else {
applyInitialCommand();
}
}
})
.catch(() => {
/* keep fallbacks */
});
// Pick a sensible starting command: a seed's command (matched to a preset or
// dropped into custom), else the first non-custom preset.
function applyInitialCommand(): void {
const wanted = prefill.command;
if (wanted) {
const match = presets.find((p) => p.command === wanted && p.command !== "");
if (match) {
selectPreset(match.id, false);
} else {
const custom = presets.find((p) => p.command === "");
cmdRaw.value = wanted;
selectPreset(custom ? custom.id : "custom", false);
command = wanted;
}
return;
}
const first = presets.find((p) => p.command !== "");
if (first) selectPreset(first.id, false);
}
// ---- working-directory picker ---------------------------------------
function showRawCwd(prefillValue?: string, focus = true): void {
cwdRawWrap.hidden = false;
if (prefillValue != null) cwd = prefillValue;
cwdRaw.value = cwd;
markActiveProject(null);
if (focus) {
cwdRaw.focus();
cwdRaw.select();
}
// Both the visibility and the directory are draft state: without this a
// render before the operator types snaps back to the project picker.
emit();
}
cwdRaw.addEventListener("input", () => {
cwd = cwdRaw.value.trim();
branchCache = null;
emit();
if (wtEnabled.checked) scheduleGitRootProbe();
});
function markActiveProject(activeCwd: string | null): void {
for (const row of recentsEl.querySelectorAll(".project-row")) {
row.classList.toggle(
"is-active",
(row as HTMLElement).dataset.cwd === activeCwd,
);
}
}
function selectProject(project: Project): void {
cwd = project.path;
cwdRawWrap.hidden = true;
markActiveProject(project.path);
// The scan already knows this directory is inside a repo (#61): the
// toggle defaults back on and no /api/gitroot round trip is needed —
// unless the directory *is* a linked worktree itself (#91): spawning
// in place is the only thing that works there, so default the toggle
// off. Turning it back on by hand still tries (and fails with git's
// own message), which is deliberate (#91 non-goal).
wtEnabled.checked = !project.is_worktree;
if (project.is_worktree) {
inferredBaseRepo = null;
wtBody.hidden = true;
wtHint.hidden = true;
} else {
applyProjectRepo(project.base_repo ?? project.path);
}
emit();
// Picking a project pre-selects the command it was last used with.
const match = presets.find((p) => p.command === project.last_command && p.command !== "");
if (match) selectPreset(match.id);
}
let discovered: Project[] = []; // daemon snapshot, daemon-sorted
function renderProjects(): void {
recentsEl.replaceChildren();
const sorted = sortProjects(filterProjects(discovered, filterInput.value), new Date());
for (const group of groupProjects(sorted)) {
recentsEl.appendChild(projectRow(group.main));
for (const wt of group.worktrees) recentsEl.appendChild(projectRow(wt));
}
if (discovered.length > 0 && sorted.length === 0) {
const empty = document.createElement("p");
empty.className = "picker-empty";
empty.textContent = "No projects match.";
recentsEl.appendChild(empty);
}
recentsEl.appendChild(otherDirRow());
markActiveProject(cwdRawWrap.hidden ? cwd : null);
}
filterInput.addEventListener("input", renderProjects);
async function loadProjects(): Promise<void> {
recentsEl.replaceChildren(loadingRow());
try {
discovered = await fetchProjects();
} catch {
discovered = [];
}
if (destroyed) return;
renderProjects();
if (seedCwd) {
// A seed sets the directory verbatim, regardless of what the scan found.
// `cwdRawVisible === false` marks a draft restore whose cwd came from a
// picked row: keep the picker and don't steal focus. Otherwise land
// focused in the raw input (clone / no-match flows).
if (prefill.cwdRawVisible === false) {
cwd = seedCwd;
cwdRaw.value = seedCwd;
markActiveProject(cwd);
} else {
showRawCwd(seedCwd, false);
}
seedCwd = null;
} else if (discovered.length === 0) {
// Nothing discovered yet: default to raw input prefilled with home.
showRawCwd(homeDir, false);
} else {
cwdRawWrap.hidden = true;
}
// Everything seeded: the form may publish from here on.
ready = true;
resolveBaseRepo();
emit();
}
function projectRow(project: Project): HTMLElement {
const row = document.createElement("button");
row.type = "button";
row.className = `project-row${project.is_worktree ? " is-worktree" : ""}${
isStale(project, new Date()) ? " is-stale" : ""
}`;
row.dataset.cwd = project.path;
// Abbreviate exactly like the sidebar: home → `~`, then collapse leading
// segments to initials (`~/g/n/maestro`). The full path stays in the tooltip
// and `dataset.cwd` (what's actually submitted).
const display = abbreviateFolderName(abbreviateCwd(project.path, homeDir));
row.innerHTML = `
<span class="project-info">
<span class="project-name">${escapeHtml(project.name)}</span>
<span class="recent-dir" title="${escapeHtml(project.path)}">${display}</span>
</span>
<span class="recent-cmd">${escapeHtml(project.last_command ?? "")}</span>`;
row.addEventListener("click", () => selectProject(project));
return row;
}
function otherDirRow(): HTMLElement {
const row = document.createElement("button");
row.type = "button";
row.className = "project-row project-other";
row.innerHTML = `<span class="project-info"><span class="recent-dir">Other directory…</span></span>`;
row.addEventListener("click", () => showRawCwd(cwd || homeDir));
return row;
}
function loadingRow(): HTMLElement {
const p = document.createElement("p");
p.className = "picker-loading";
p.textContent = "Loading projects…";
return p;
}
// ---- worktree (branch-only; base_repo inferred via scan or /gitroot) --
// The project scan already reports repo membership, so a picked project
// resolves its base repo locally (#61); only the free-text escape hatch
// pays for a /api/gitroot probe.
function applyProjectRepo(repo: string): void {
inferredBaseRepo = repo;
branchCache = null;
wtHint.hidden = true;
wtBody.hidden = false;
wtRepo.textContent = `repo: ${repo}`;
if (branchMode === "existing") void loadBranches();
}
/** Decide how the worktree section describes the current target. Called
* once the initial load has settled `cwd`. */
function resolveBaseRepo(): void {
// Nothing to resolve when the operator turned the worktree off — the
// section would otherwise rebuild expanded under an unchecked box, and
// fire a `/api/gitroot` probe for a worktree nobody asked for
// (#67 review).
if (!wtEnabled.checked) {
wtBody.hidden = true;
wtHint.hidden = true;
inferredBaseRepo = null;
return;
}
if (!cwd) {
inferredBaseRepo = null;
wtBody.hidden = true;
wtHint.hidden = false;
wtHint.textContent = "Choose a working directory first.";
return;
}
const project = discovered.find((p) => p.path === cwd);
if (project) {
applyProjectRepo(project.base_repo ?? project.path);
return;
}
scheduleGitRootProbe();
}
// Every keystroke resets this timer, so a burst of typing issues one
// request once the operator pauses. `probeToken` inside probeGitRoot still
// owns ordering: a scheduled probe that fires after a newer one started
// loses the token race and is dropped.
let probeTimer: number | undefined;
function scheduleGitRootProbe(): void {
window.clearTimeout(probeTimer);
probeTimer = window.setTimeout(() => {
probeTimer = undefined;
void probeGitRoot();
}, GITROOT_PROBE_DEBOUNCE_MS);
}
async function probeGitRoot(): Promise<void> {
const token = ++probeToken;
if (!cwd) {
inferredBaseRepo = null;
wtBody.hidden = true;
wtHint.hidden = false;
wtHint.textContent = "Choose a working directory first.";
return;
}
wtHint.hidden = false;
wtHint.textContent = "Checking for a git repository…";
wtBody.hidden = true;
let root: string | undefined;
try {
root = (await gitRoot(cwd)).root;
} catch {
root = undefined;
}
if (token !== probeToken) return; // a newer probe superseded us
if (root) {
applyProjectRepo(root);
} else {
inferredBaseRepo = null;
// Publish the untick too, or the next render seeds the box checked with
// no inferred repo and submitting fails.
wtEnabled.checked = false;
emit();
wtBody.hidden = true;
wtHint.hidden = false;
wtHint.textContent = "Not inside a git repository — pick a dir in a repo to use a worktree.";
}
}
// ---- existing-branch picker (#61) -------------------------------------
function setBranchMode(mode: WorktreeBranchMode): void {
branchMode = mode;
for (const radio of modeRadios) radio.checked = radio.value === mode;
newWrap.hidden = mode !== "new";
existWrap.hidden = mode !== "existing";
if (mode === "existing") void loadBranches();
emit();
}
/** Fetch the target repo's branches once per cwd. Like every async load in
* this form: token-guarded, and nothing touches the DOM (or emits) after
* destroy() — a late response into a rebuilt form would corrupt that
* form's draft (#57 review). */
async function loadBranches(): Promise<void> {
if (!cwd || !inferredBaseRepo) return;
const token = ++branchToken;
if (branchCache?.cwd !== cwd) {
const target = cwd;
let branches: BranchInfo[];
try {
branches = await fetchBranches(target);
} catch {
return; // picker just stays empty; "new" mode still works
}
if (destroyed || token !== branchToken || cwd !== target) return;
branchCache = { cwd: target, branches };
}
renderBranchOptions();
}
function renderBranchOptions(): void {
if (destroyed) return;
const branches = branchCache?.cwd === cwd ? branchCache.branches : [];
branchSelect.replaceChildren();
const opt = (label: string, value: string) => {
const o = document.createElement("option");
o.textContent = label;
o.value = value;
return o;
};
if (branches.length === 0) {
branchSelect.appendChild(opt("No local branches found.", ""));
return;
}
for (const b of branches) {
// A branch that is 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);
const o = opt(held ? `${b.name} — held by ${held}` : b.name, b.name);
o.disabled = held !== null;
o.title = held ?? "";
branchSelect.appendChild(o);
}
}
for (const radio of modeRadios) {
radio.addEventListener("change", () => {
if (radio.checked) setBranchMode(radio.value as WorktreeBranchMode);
});
}
branchSelect.addEventListener("change", () => emit());
wtEnabled.addEventListener("change", () => {
emit();
if (wtEnabled.checked) {
resolveBaseRepo();
} else {
inferredBaseRepo = null;
wtBody.hidden = true;
wtHint.hidden = true;
}
});
// The toggle defaults ON (#61): the operator almost always wants a
// worktree, and every non-repo target unticks it via the gitroot probe.
wtEnabled.checked = prefill.worktreeEnabled ?? true;
if (prefill.worktreeBranch) branchInput.value = prefill.worktreeBranch;
setBranchMode(branchMode);
// Seed ordering: the raw-cwd application races the async info load (which
// provides homeDir for the projects' abbreviations), so projects wait for it.
void infoLoad.then(() => loadProjects());
// ---- live dir autocomplete on the raw cwd input (NAP-329) ------------
const autocomplete = attachDirAutocomplete(cwdRaw);
// ---- submit ----------------------------------------------------------
form.addEventListener("submit", async (e) => {
e.preventDefault();
errEl.textContent = "";
const built = buildSpawnRequest(
{
command,
cwd,
name: nameInput.value,
worktreeEnabled: wtEnabled.checked,
worktreeBranch: branchValue(),
worktreeBranchEdited: branchEdited,
worktreeBranchMode: branchMode,
},
inferredBaseRepo,
);
if ("error" in built) {
errEl.textContent = built.error;
return;
}
const submitBtn = form.querySelector("button[type=submit]") as HTMLButtonElement;
submitBtn.disabled = true;
try {
const session = await createSession(built.req);
opts.onSuccess(session);
} catch (err) {
errEl.textContent = (err as Error).message;
submitBtn.disabled = false;
}
});
return {
el: form,
destroy() {
destroyed = true;
window.clearTimeout(probeTimer);
autocomplete.destroy();
form.remove();
},
};
}