fix(web): /machine terminal visible prop, stop/restart confirmation, accurate waiting label

This commit is contained in:
Gabriel Brown
2026-07-11 13:36:54 -04:00
parent a5bc0434f3
commit 696f03e867
3 changed files with 172 additions and 29 deletions
@@ -1,8 +1,18 @@
'use client'; 'use client';
import type { ReactNode } from 'react';
import { Loader2, Play, RotateCw, Server, Square } from 'lucide-react'; import { Loader2, Play, RotateCw, Server, Square } from 'lucide-react';
import { import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
Badge, Badge,
Button, Button,
Card, Card,
@@ -50,6 +60,48 @@ const Detail = ({ label, value }: { label: string; value: string }) => (
</div> </div>
); );
// Stopping or restarting the box tears down the SAME per-user container that a
// running agent thread job executes in, so both actions are destructive and get
// a confirmation step. Start is safe and stays a plain button.
const ConfirmLifecycleButton = ({
icon,
label,
busy,
title,
description,
confirmLabel,
onConfirm,
}: {
icon: ReactNode;
label: string;
busy: boolean;
title: string;
description: string;
confirmLabel: string;
onConfirm: () => void;
}) => (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button type='button' variant='outline' size='sm' disabled={busy}>
{icon}
{label}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction variant='destructive' onClick={onConfirm}>
{confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
export const BoxStatusCard = ({ export const BoxStatusCard = ({
status, status,
pendingAction, pendingAction,
@@ -96,34 +148,36 @@ export const BoxStatusCard = ({
<div className='flex flex-wrap gap-2'> <div className='flex flex-wrap gap-2'>
{running ? ( {running ? (
<> <>
<Button <ConfirmLifecycleButton
type='button' label='Stop'
variant='outline' busy={busy}
size='sm' icon={
disabled={busy} pendingAction === 'stop' ? (
onClick={() => onAction('stop')}
>
{pendingAction === 'stop' ? (
<Loader2 className='size-4 animate-spin' /> <Loader2 className='size-4 animate-spin' />
) : ( ) : (
<Square className='size-4' /> <Square className='size-4' />
)} )
Stop }
</Button> title='Stop this machine?'
<Button description='Stopping the machine shuts down the container it runs in. Any agent thread job currently running in this machine will be interrupted and lose its in-progress work.'
type='button' confirmLabel='Stop machine'
variant='outline' onConfirm={() => onAction('stop')}
size='sm' />
disabled={busy} <ConfirmLifecycleButton
onClick={() => onAction('restart')} label='Restart'
> busy={busy}
{pendingAction === 'restart' ? ( icon={
pendingAction === 'restart' ? (
<Loader2 className='size-4 animate-spin' /> <Loader2 className='size-4 animate-spin' />
) : ( ) : (
<RotateCw className='size-4' /> <RotateCw className='size-4' />
)} )
Restart }
</Button> title='Restart this machine?'
description='Restarting the machine recreates the container it runs in. Any agent thread job currently running in this machine will be interrupted and lose its in-progress work.'
confirmLabel='Restart machine'
onConfirm={() => onAction('restart')}
/>
</> </>
) : ( ) : (
<Button <Button
@@ -235,6 +235,13 @@ export const MachineShell = () => {
const activeFile = activeFilePath ? files[activeFilePath] : undefined; const activeFile = activeFilePath ? files[activeFilePath] : undefined;
const running = Boolean(status?.running); const running = Boolean(status?.running);
const starting = pendingAction === 'start' || pendingAction === 'restart';
// The terminal only shows this while the box is not running. Distinguish a box
// that is starting/restarting from one that is simply stopped so the label is
// accurate instead of always claiming "Starting…".
const terminalWaitingLabel = starting
? 'Starting your machine…'
: 'Machine is stopped — start it to open a terminal.';
return ( return (
<main className='space-y-6'> <main className='space-y-6'>
@@ -339,9 +346,10 @@ export const MachineShell = () => {
> >
<XtermSession <XtermSession
active={running} active={running}
visible={activeTab === 'terminal'}
tokenUrl='/api/box/terminal-token' tokenUrl='/api/box/terminal-token'
sessionKey={username} sessionKey={username}
waitingLabel='Starting your machine…' waitingLabel={terminalWaitingLabel}
label='your machine' label='your machine'
/> />
</TabsContent> </TabsContent>
@@ -42,6 +42,40 @@ const stoppedStatus = {
containerName: 'spoon-home-gib', containerName: 'spoon-home-gib',
}; };
const runningStatus = {
...stoppedStatus,
running: true,
startedAt: new Date().toISOString(),
};
// Point the status endpoint at a running box so the Stop/Restart controls render.
const useRunningStatus = () => {
fetchSpy.mockImplementation((input: RequestInfo | URL) => {
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.href
: input.url;
if (url.startsWith('/api/box/status')) {
return Promise.resolve(jsonResponse({ status: runningStatus }));
}
if (url.startsWith('/api/box/tree')) {
return Promise.resolve(jsonResponse({ tree }));
}
if (url.startsWith('/api/box/lifecycle')) {
return Promise.resolve(jsonResponse({ status: stoppedStatus }));
}
return Promise.resolve(jsonResponse({}));
});
};
const lifecycleCalls = () =>
fetchSpy.mock.calls.filter(([input]) => {
const url = typeof input === 'string' ? input : '';
return url.startsWith('/api/box/lifecycle');
});
const tree = { const tree = {
name: '', name: '',
path: '', path: '',
@@ -127,4 +161,51 @@ describe('MachineShell', () => {
render(<MachineShell />); render(<MachineShell />);
expect(await screen.findByText('terminal')).toBeInTheDocument(); expect(await screen.findByText('terminal')).toBeInTheDocument();
}); });
it('does not stop the box until the confirmation is accepted', async () => {
useRunningStatus();
render(<MachineShell />);
const stopButton = await screen.findByRole('button', { name: 'Stop' });
fireEvent.click(stopButton);
// The confirmation dialog intervenes: no lifecycle POST fires on the click.
const confirm = await screen.findByRole('button', { name: 'Stop machine' });
expect(lifecycleCalls()).toHaveLength(0);
fireEvent.click(confirm);
await waitFor(() =>
expect(fetchSpy).toHaveBeenCalledWith(
'/api/box/lifecycle',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ action: 'stop' }),
}),
),
);
});
it('does not restart the box until the confirmation is accepted', async () => {
useRunningStatus();
render(<MachineShell />);
const restartButton = await screen.findByRole('button', {
name: 'Restart',
});
fireEvent.click(restartButton);
const confirm = await screen.findByRole('button', {
name: 'Restart machine',
});
expect(lifecycleCalls()).toHaveLength(0);
fireEvent.click(confirm);
await waitFor(() =>
expect(fetchSpy).toHaveBeenCalledWith(
'/api/box/lifecycle',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ action: 'restart' }),
}),
),
);
});
}); });