feat: start on a blank container — spawn with empty:true and no repo (skip clone, default worker image); Spawn UI enables blank spawn without repo selection

This commit is contained in:
2026-09-02 09:24:54 +00:00
parent c8cde3ff89
commit 0f245c9ee2
6 changed files with 105 additions and 28 deletions
+36 -1
View File
@@ -235,7 +235,11 @@ describe("SpawnView repo picker", () => {
render(tree(makeStore()));
await flush();
expect(screen.getByLabelText("Spawn container")).toBeDisabled();
expect(screen.getByText("select a repo above")).toBeInTheDocument();
expect(
screen.getByText(
"select a repo above — or tick “Empty container” for a blank workspace",
),
).toBeInTheDocument();
});
});
@@ -739,4 +743,35 @@ describe("empty container spawn", () => {
await flush();
expect(bodies[0]).toContain('"empty":true');
});
it("blank container: spawn without selecting a repo", async () => {
const bodies: string[] = [];
mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/api/spawn")) {
bodies.push(String(init.body));
return { sessionId: "e2", imageUsed: "lvmh-worker:latest" };
}
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "a" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
return [];
});
render(tree(makeStore()));
await flush();
// no repo selected: Spawn disabled
const spawnBtn = screen.getByLabelText(
"Spawn container",
) as HTMLButtonElement;
expect(spawnBtn.disabled).toBe(true);
fireEvent.click(screen.getByRole("checkbox"));
expect(spawnBtn.disabled).toBe(false);
fireEvent.click(spawnBtn);
await flush();
expect(bodies[0]).toContain('"empty":true');
expect(bodies[0]).toContain('"repo":""');
expect(bodies[0]).not.toContain('"branch"');
});
});
+18 -23
View File
@@ -41,9 +41,7 @@ function SpawnSteps({ state }: { state: string }): React.ReactNode {
{SPAWN_STEPS.map((step, i) => (
<span
key={step}
className={
idx > i ? "step done" : idx === i ? "step current" : "step"
}
className={idx > i ? "step done" : idx === i ? "step current" : "step"}
title={step}
/>
))}
@@ -167,15 +165,17 @@ export default function SpawnView({ store, pushToast }: Props) {
r.path.toLowerCase().includes(query.toLowerCase()),
);
// spawn is only reachable from the Spawn button, which is disabled until a
// repo is selected.
// spawn is reachable with a repo selected, or with the blank-container
// checkbox and no repo (empty workspace).
const spawn = async (): Promise<void> => {
setBusy(true);
setError("");
try {
const body = {
repo: selected!.path,
...(branch.trim().length > 0 ? { branch: branch.trim() } : {}),
repo: selected?.path ?? "",
...(selected !== null && branch.trim().length > 0
? { branch: branch.trim() }
: {}),
...(model.length > 0 ? { model } : {}),
...(empty ? { empty: true } : {}),
};
@@ -207,8 +207,7 @@ export default function SpawnView({ store, pushToast }: Props) {
const list = await store.refresh();
const s = list.find((x) => x.id === sessionId);
if (s !== undefined && s.online) {
if (timerRef.current !== null)
window.clearInterval(timerRef.current);
if (timerRef.current !== null) window.clearInterval(timerRef.current);
timerRef.current = null;
navigate(`/s/${sessionId}`);
}
@@ -236,7 +235,8 @@ export default function SpawnView({ store, pushToast }: Props) {
job.message !== undefined && job.message.length > 0
? ` (${job.message})`
: "";
return `${job.repo}: ${job.state}${detail}`;
const label = job.repo.length > 0 ? job.repo : "blank container";
return `${label}: ${job.state}${detail}`;
}
return "waiting for session to come online…";
};
@@ -246,9 +246,7 @@ export default function SpawnView({ store, pushToast }: Props) {
<div className="page">
<h1>Spawn</h1>
<p className="empty">
{error.length > 0
? `gitlab status failed: ${error}`
: "checking gitlab…"}
{error.length > 0 ? `gitlab status failed: ${error}` : "checking gitlab…"}
</p>
</div>
);
@@ -338,8 +336,7 @@ export default function SpawnView({ store, pushToast }: Props) {
<div style={{ minWidth: 0, flex: 1 }}>
<div className="repo-path">{r.path}</div>
<div className="repo-meta">
default {r.defaultBranch} ·{" "}
{r.lastActivityAt.slice(0, 10)}
default {r.defaultBranch} · {r.lastActivityAt.slice(0, 10)}
</div>
</div>
{reg !== undefined && (
@@ -364,8 +361,7 @@ export default function SpawnView({ store, pushToast }: Props) {
}}
onKeyDown={(e) => {
// let the native button handle Enter/Space
if (e.key === "Enter" || e.key === " ")
e.stopPropagation();
if (e.key === "Enter" || e.key === " ") e.stopPropagation();
}}
>
Prepare
@@ -390,29 +386,28 @@ export default function SpawnView({ store, pushToast }: Props) {
<button
type="button"
className="btn-primary"
disabled={selected === null || busy}
disabled={(selected === null && !empty) || busy}
aria-label="Spawn container"
onClick={() => void spawn()}
>
{busy ? "Spawning…" : "Spawn"}
</button>
</div>
{selected === null && (
{selected === null && !empty && (
<p className="repo-meta" style={{ marginTop: 8 }}>
select a repo above
select a repo above or tick Empty container for a blank workspace
</p>
)}
{selected !== null && (
<p className="repo-meta" style={{ marginTop: 8 }}>
Will use:{" "}
{registration(selected.path)?.image ?? "base worker image"}
Will use: {registration(selected.path)?.image ?? "base worker image"}
</p>
)}
<div className="row" style={{ marginTop: 8 }}>
<select
aria-label="Initial model"
value={model}
disabled={selected === null}
disabled={selected === null && !empty}
onChange={(e) => setModel(e.target.value)}
>
<option value="">Default model (settings)</option>