tabs: the pane opens on the click, not on the machine

Starting a cloud machine took tens of seconds and drew nothing for all of
them: `launch` awaited `onLaunch` before it called `open`, so the only sign
the click had landed was a word change on the button that caused it. The
workspace itself sat unchanged, which reads as a dead button.

The pane is opened by the CLICK and settled by the ANSWER. A binding can now
say `starting` — a machine is being made for this box, and here is the loader
— and `failed`, which keeps the reason in the space the machine was for.
`open` answers with the pane's id so `launch` can settle that one box, and
`settle` no-ops on a pane that was closed while its machine was coming: an
answer arriving late must not reopen a box someone shut.

Neither state survives a reload. The promise died with the page, so a restored
`starting` would spin for a machine nobody is waiting on and a restored
`failed` would state a reason about a moment that has passed; `restore` brings
both back as the pane that ASKS, by which time the machine may well be in the
list. `gone` still survives — tmux is holding that session, so that pane is
still about something.

With the wait and the failure both in the pane, the button needs neither, so
`Launch` collapses into the plain button `New shell` already was and all four
share it.
This commit is contained in:
zooqueen
2026-08-11 10:10:45 -07:00
parent e67bc8cfa4
commit 4fd4a9dfc4
3 changed files with 152 additions and 60 deletions
+78 -60
View File
@@ -34,7 +34,7 @@
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Cloud, Columns2, Monitor, Plus, Rows2, X } from 'lucide-react';
import { Cloud, Columns2, Loader2, Monitor, Plus, Rows2, X } from 'lucide-react';
import {
type Dir,
@@ -60,6 +60,7 @@ import {
machineOf,
mintName,
rescued,
restore,
shellUrl,
} from '@/lib/panes';
@@ -139,11 +140,12 @@ export function Workspace({
const known = new Set(live.map((h) => h.machine));
setBind(
Object.fromEntries(
Object.entries(saved.bind).map(([id, b]) =>
Object.entries(saved.bind).map(([id, b]) => [
id,
b.kind === 'shell' && !known.has(b.shell.machine)
? [id, { kind: 'gone', shell: b.shell } as Binding]
: [id, b],
),
? ({ kind: 'gone', shell: b.shell } as Binding)
: restore(b),
]),
),
);
setTile(saved.tree);
@@ -202,24 +204,45 @@ export function Workspace({
);
const screenOn = useCallback((machine: string): Binding => ({ kind: 'screen', machine }), []);
/** Open a NEW pane showing `what`, beside `target` — or as the whole layout. */
/** Open a NEW pane showing `what`, beside `target` — or as the whole layout.
* Answers with the pane's id, so a caller still waiting on what goes in it
* can settle that one box later. */
const open = useCallback((what: Binding, dir: Dir | null, target: string | null) => {
const id = `p${seq.current++}`;
setBind((b) => ({ ...b, [id]: what }));
setTile((t) => (t && target && dir ? splitPane(t, target, dir, id) : (t ?? pane(id))));
setFocus(id);
return id;
}, []);
/** Put `what` in a pane that is still open. A pane closed while its machine
* was starting stays closed — an answer arriving late must not reopen a box
* someone shut. */
const settle = useCallback((id: string, what: Binding) => {
setBind((b) => (b[id] ? { ...b, [id]: what } : b));
}, []);
/** Start a machine and open it. The point of the button is what you get to
* look at: a machine that arrives with nothing framed on it is a row in a
* list, and you would have to go and ask for the thing you already asked for. */
const launch = useCallback(
async (kind: 'dev' | 'desktop') => {
(kind: 'dev' | 'desktop') => {
if (!onLaunch) return;
const machine = await onLaunch(kind);
open(kind === 'desktop' ? screenOn(machine) : shellOn(machine), focus ? 'row' : null, focus);
const want = kind === 'desktop' ? 'screen' : 'shell';
// The pane opens on the CLICK, not on the answer. Provisioning is the
// long part, so waiting for it before drawing anything is a workspace
// that sits unchanged for the whole minute you are waiting.
const id = open({ kind: 'starting', want }, focus ? 'row' : null, focus);
onLaunch(kind)
.then((machine) => settle(id, want === 'screen' ? screenOn(machine) : shellOn(machine)))
.catch((e) =>
settle(id, {
kind: 'failed',
why: e instanceof Error ? e.message : 'could not start a machine',
}),
);
},
[onLaunch, open, focus, shellOn, screenOn],
[onLaunch, open, settle, focus, shellOn, screenOn],
);
const doClose = useCallback((id: string) => {
@@ -410,8 +433,8 @@ export function Workspace({
at all is exactly who has nowhere else to get one. */}
{onLaunch ? (
<div className="flex items-center gap-1.5 text-xs">
<Launch run={() => launch('dev')} icon={<Cloud className="h-3.5 w-3.5" />} label="New cloud machine" busyLabel="Starting a machine…" />
<Launch run={() => launch('desktop')} icon={<Monitor className="h-3.5 w-3.5" />} label="New desktop" busyLabel="Starting a desktop…" />
<Act run={() => launch('dev')} icon={<Cloud className="h-3.5 w-3.5" />} label="New cloud machine" />
<Act run={() => launch('desktop')} icon={<Monitor className="h-3.5 w-3.5" />} label="New desktop" />
</div>
) : null}
</div>
@@ -437,31 +460,27 @@ export function Workspace({
<div className="flex h-full w-full flex-col gap-2">
{/* One row. Actions, not a status report. */}
<div className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground">
<button
type="button"
onClick={() => openShell(focus ? 'row' : null, focus)}
className="inline-flex min-h-9 items-center gap-1 rounded-md border border-border px-2.5 text-foreground hover:bg-muted"
>
<Plus className="h-3.5 w-3.5" /> New shell
</button>
<Act
run={() => openShell(focus ? 'row' : null, focus)}
icon={<Plus className="h-3.5 w-3.5" />}
label="New shell"
/>
{/* One button for the screen, whether or not a machine with one exists
yet: with a desktop live it opens it, without one it starts it. The
alternative — a disabled button beside a second button that makes it
work — is two controls for one intention. */}
{watchable.length || onLaunch ? (
<Launch
run={async () => openScreen(focus ? 'row' : null, focus)}
<Act
run={() => openScreen(focus ? 'row' : null, focus)}
icon={<Monitor className="h-3.5 w-3.5" />}
label="Desktop"
busyLabel="Starting a desktop…"
/>
) : null}
{onLaunch ? (
<Launch
<Act
run={() => launch('dev')}
icon={<Cloud className="h-3.5 w-3.5" />}
label="New cloud machine"
busyLabel="Starting a machine…"
/>
) : null}
<span className="ml-auto flex shrink-0 items-center gap-1">
@@ -643,6 +662,28 @@ export function Workspace({
onCancel={() => doClose(id)}
/>
</div>
) : b.kind === 'starting' ? (
// The loader lives HERE, in the box the machine is for —
// not on the button that asked. It is what tells you the
// click landed, and it is where the terminal appears.
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 px-4 text-center">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<p className="max-w-xs text-xs text-muted-foreground">
Starting {b.want === 'screen' ? 'a desktop' : 'a machine'}. Its{' '}
{b.want === 'screen' ? 'screen' : 'terminal'} opens here.
</p>
</div>
) : b.kind === 'failed' ? (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 px-4 text-center">
<p className="max-w-xs text-xs text-muted-foreground">{b.why}</p>
<button
type="button"
onClick={() => doClose(id)}
className="min-h-9 rounded-md border border-border px-3 text-xs hover:bg-muted"
>
Close pane
</button>
</div>
) : b.kind === 'gone' ? (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 px-4 text-center">
<p className="text-xs text-muted-foreground">
@@ -775,45 +816,22 @@ export function Workspace({
}
/**
* Ask for a machine.
* One labelled button in the action row.
*
* A launch is a request in flight, and it keeps its own state because nothing
* else here has a reason to re-render while it is one. Failure is shown next to
* the button that caused it rather than banner-wide: provisioning is a second
* plane, and a workspace full of working terminals is not broken because it is
* down.
* It carries no busy state and no failure of its own, and that is the point: a
* button that asks for a machine opens the pane the machine is for, so the wait
* and the reason it failed both belong to that pane. Keeping a spinner here too
* would say the same thing twice, in the one place you are not looking.
*/
function Launch({
run,
icon,
label,
busyLabel,
}: {
run: () => Promise<void>;
icon: React.ReactNode;
label: string;
busyLabel: string;
}) {
const [busy, setBusy] = useState(false);
const [failed, setFailed] = useState<string | null>(null);
function Act({ run, icon, label }: { run: () => void; icon: React.ReactNode; label: string }) {
return (
<>
<button
type="button"
disabled={busy}
onClick={() => {
setBusy(true);
setFailed(null);
run()
.catch((e) => setFailed(e instanceof Error ? e.message : 'could not launch a machine'))
.finally(() => setBusy(false));
}}
className="inline-flex min-h-9 shrink-0 items-center gap-1 rounded-md border border-border px-2.5 text-foreground hover:bg-muted disabled:opacity-40"
>
{icon} {busy ? busyLabel : label}
</button>
{failed ? <span className="truncate text-muted-foreground">{failed}</span> : null}
</>
<button
type="button"
onClick={run}
className="inline-flex min-h-9 shrink-0 items-center gap-1 rounded-md border border-border px-2.5 text-foreground hover:bg-muted"
>
{icon} {label}
</button>
);
}
+29
View File
@@ -33,6 +33,18 @@ export type Binding =
| { kind: 'screen'; machine: string }
/** Split made, machine not chosen yet. */
| { kind: 'empty' }
/**
* A machine is being STARTED for this pane, and does not exist yet.
*
* The pane is on screen from the click, because the wait is the thing worth
* watching and it is the longest part: provisioning a cloud machine takes
* tens of seconds, and a spinner on the button that caused it leaves the
* workspace looking untouched for all of them. `want` is how the machine will
* be shown the moment it answers.
*/
| { kind: 'starting'; want: 'shell' | 'screen' }
/** No machine came. The pane keeps the reason, next to the space it was for. */
| { kind: 'failed'; why: string }
/** The machine went away. The pane STAYS, because tmux is still holding the
* session and the layout is the reader's, not a function of someone's uptime. */
| { kind: 'gone'; shell: Shell };
@@ -48,11 +60,28 @@ export function label(b: Binding): string {
return `${b.shell.machine} · ${b.shell.name}`;
case 'screen':
return `${b.machine} · desktop`;
case 'starting':
return b.want === 'screen' ? 'Starting a desktop' : 'Starting a machine';
case 'failed':
return 'Could not start';
default:
return 'New shell';
}
}
/**
* What a saved pane becomes on the way back in.
*
* A launch does not survive the page that asked for it: the promise died with
* the tab, so a restored `starting` would spin for a machine nobody is waiting
* on, and a restored `failed` would state a reason about a moment that has
* passed. Both come back as the pane that asks — by then the machine may well
* have finished starting, and it is in the list.
*/
export function restore(b: Binding): Binding {
return b.kind === 'starting' || b.kind === 'failed' ? { kind: 'empty' } : b;
}
/** The machine a pane is bound to, whichever way it is looking at it. */
export function machineOf(b: Binding): string | null {
switch (b.kind) {
+45
View File
@@ -8,6 +8,7 @@ import {
machineOf,
mintName,
rescued,
restore,
safeName,
shellUrl,
} from '@/lib/panes';
@@ -140,3 +141,47 @@ describe('a pane bound to a screen is still bound to a machine', () => {
expect(label({ kind: 'empty' })).toBe('New shell');
});
});
/**
* A machine that does not exist yet still gets a pane.
*
* The pane is opened by the CLICK and settled by the ANSWER, because
* provisioning a cloud machine is tens of seconds and waiting for it before
* drawing anything leaves the workspace looking like the click missed. So a
* binding has to be able to say "a machine is coming" and "none came".
*/
describe('a pane can be waiting for a machine', () => {
it('is bound to no machine while one is being made', () => {
// Load-bearing: the mint effect keys on machineOf, so a starting pane
// naming a machine would send a ticket request for a box that is not there.
expect(machineOf({ kind: 'starting', want: 'shell' })).toBeNull();
expect(machineOf({ kind: 'starting', want: 'screen' })).toBeNull();
expect(machineOf({ kind: 'failed', why: 'out of capacity' })).toBeNull();
});
it('says which kind of machine it is waiting for', () => {
expect(label({ kind: 'starting', want: 'shell' })).toBe('Starting a machine');
expect(label({ kind: 'starting', want: 'screen' })).toBe('Starting a desktop');
expect(label({ kind: 'failed', why: 'out of capacity' })).toBe('Could not start');
});
it('comes back as the pane that asks, never as one still waiting', () => {
// The promise died with the page, so nothing will ever settle a restored
// `starting` — it would spin for a machine nobody is waiting on. And a
// restored `failed` states a reason about a moment that has passed.
expect(restore({ kind: 'starting', want: 'shell' })).toEqual({ kind: 'empty' });
expect(restore({ kind: 'starting', want: 'screen' })).toEqual({ kind: 'empty' });
expect(restore({ kind: 'failed', why: 'out of capacity' })).toEqual({ kind: 'empty' });
});
it('leaves a pane that is showing something alone', () => {
const shell = { kind: 'shell', shell: { machine: 'desk', name: 'build' } } as const;
const screen = { kind: 'screen', machine: 'desk' } as const;
const gone = { kind: 'gone', shell: { machine: 'desk', name: 'build' } } as const;
expect(restore(shell)).toBe(shell);
expect(restore(screen)).toBe(screen);
// A machine that went away is the ONE waiting state that survives a reload:
// tmux is still holding that session, so the pane is still about something.
expect(restore(gone)).toBe(gone);
});
});