a paused job shows a pause icon, not a spinner
The details panel kept spinning after Pause, so a frozen dev server looked like a working one. The spinner is the whole claim being made, and it was false — the state most likely to be watched, since the panel is where you sit while a `bun dev` runs. Amber either way: it is still a live job holding its port and its memory, which is what separates paused from stopped. What changes is the motion, and the line beside it now reads `paused` rather than `running…`. ── And the state moved up a level ── `paused` lived in the panel, which unmounts when you close it. Closing and reopening would have put a spinning icon back over a stopped process — the exact failure this commit is fixing, arriving by a different route. It is the tray's now, so it survives the panel being closed. Still per-session: a reload forgets, and the button then offers Pause on a job that is already frozen. SIGSTOP makes that harmless, and the honest fix is asking the process rather than remembering — `ps -o state=` on the pids holding the output file would make it survive anything, at the cost of a spawn per poll. Worth doing if this ever misleads. The chip is unchanged: a paused job still shows the running dot there, because live is what that dot means and the distinction belongs where the controls are. tsgo clean, frontend builds, 914 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,15 @@ export const BackgroundTaskTray = ({ messages, sessionId }: BackgroundTaskTrayPr
|
|||||||
const { tasks, pinned, unpinned, running, clearable, togglePin, dismiss, dismissFinished } =
|
const { tasks, pinned, unpinned, running, clearable, togglePin, dismiss, dismissFinished } =
|
||||||
useBackgroundTasks(messages);
|
useBackgroundTasks(messages);
|
||||||
const [openId, setOpenId] = useState<string | null>(null);
|
const [openId, setOpenId] = useState<string | null>(null);
|
||||||
|
/**
|
||||||
|
* Which jobs are frozen.
|
||||||
|
*
|
||||||
|
* Held here rather than in the panel because the panel unmounts when you close it, and a `paused` that
|
||||||
|
* resets would put a spinning icon over a stopped process — the exact thing this display is for. Still
|
||||||
|
* per-session: a reload forgets, and the button then says Pause over an already-frozen job, which
|
||||||
|
* SIGSTOP makes harmless.
|
||||||
|
*/
|
||||||
|
const [pausedIds, setPausedIds] = useState<string[]>([]);
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,6 +100,7 @@ export const BackgroundTaskTray = ({ messages, sessionId }: BackgroundTaskTrayPr
|
|||||||
try {
|
try {
|
||||||
const result = await client.post<{ ok: boolean; reason?: string }>(`/chat/tasks/${taskId}/pause`, { paused });
|
const result = await client.post<{ ok: boolean; reason?: string }>(`/chat/tasks/${taskId}/pause`, { paused });
|
||||||
if (!result.ok) toast.error(result.reason ?? 'Could not pause that job');
|
if (!result.ok) toast.error(result.reason ?? 'Could not pause that job');
|
||||||
|
else setPausedIds((prev) => (paused ? [...new Set([...prev, taskId])] : prev.filter((id) => id !== taskId)));
|
||||||
return result.ok;
|
return result.ok;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'Could not pause that job');
|
toast.error(err instanceof Error ? err.message : 'Could not pause that job');
|
||||||
@@ -123,6 +133,7 @@ export const BackgroundTaskTray = ({ messages, sessionId }: BackgroundTaskTrayPr
|
|||||||
onClose={() => setOpenId(null)}
|
onClose={() => setOpenId(null)}
|
||||||
onStop={open.status ? undefined : () => void stopTask(open.taskId)}
|
onStop={open.status ? undefined : () => void stopTask(open.taskId)}
|
||||||
onPause={(next) => pauseTask(open.taskId, next)}
|
onPause={(next) => pauseTask(open.taskId, next)}
|
||||||
|
paused={pausedIds.includes(open.taskId)}
|
||||||
onRestart={() => void restartTask(open.taskId)}
|
onRestart={() => void restartTask(open.taskId)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -280,18 +291,16 @@ type TaskPanelProps = {
|
|||||||
onStop?: () => void;
|
onStop?: () => void;
|
||||||
/** Freeze and thaw the process. Present while it is running; the label follows the state. */
|
/** Freeze and thaw the process. Present while it is running; the label follows the state. */
|
||||||
onPause?: (paused: boolean) => Promise<boolean>;
|
onPause?: (paused: boolean) => Promise<boolean>;
|
||||||
|
/** Frozen right now — owned by the tray, so closing this panel does not forget it. */
|
||||||
|
paused?: boolean;
|
||||||
/** Run the command again, whether it is running or long finished. */
|
/** Run the command again, whether it is running or long finished. */
|
||||||
onRestart?: () => void;
|
onRestart?: () => void;
|
||||||
task: BackgroundTask;
|
task: BackgroundTask;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TaskPanel = ({ task, onClose, onStop, onPause, onRestart }: TaskPanelProps) => {
|
const TaskPanel = ({ task, onClose, onStop, onPause, onRestart, paused = false }: TaskPanelProps) => {
|
||||||
const running = !task.status;
|
const running = !task.status;
|
||||||
// Local, because a paused process is a short-lived state somebody is watching. It resets on reload,
|
|
||||||
// which is honest enough: SIGCONT is idempotent, and the button says what it will do rather than what
|
|
||||||
// it believes has happened.
|
|
||||||
const [paused, setPaused] = useState(false);
|
|
||||||
const { data, isLoading } = useTaskDetail(task.taskId, running);
|
const { data, isLoading } = useTaskDetail(task.taskId, running);
|
||||||
const bodyRef = useRef<HTMLDivElement | null>(null);
|
const bodyRef = useRef<HTMLDivElement | null>(null);
|
||||||
const pinnedRef = useRef(true);
|
const pinnedRef = useRef(true);
|
||||||
@@ -309,10 +318,18 @@ const TaskPanel = ({ task, onClose, onStop, onPause, onRestart }: TaskPanelProps
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="truncate text-xs text-foreground/80">{task.description || 'Background task'}</div>
|
<div className="truncate text-xs text-foreground/80">{task.description || 'Background task'}</div>
|
||||||
<div className="truncate text-xs text-muted-foreground">
|
<div className="truncate text-xs text-muted-foreground">
|
||||||
{task.taskType ?? 'task'} · {task.status ? (task.summary ?? task.status) : 'running…'}
|
{task.taskType ?? 'task'} · {task.status ? (task.summary ?? task.status) : paused ? 'paused' : 'running…'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{running && <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-warning" />}
|
{/* A frozen process is not a working one, and a spinner says it is. Same amber either way — it is
|
||||||
|
still a live job holding its port and its memory — but STILL, because the animation is the
|
||||||
|
whole claim being made. */}
|
||||||
|
{running &&
|
||||||
|
(paused ? (
|
||||||
|
<Pause className="h-3.5 w-3.5 shrink-0 text-warning" />
|
||||||
|
) : (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-warning" />
|
||||||
|
))}
|
||||||
{/* Labelled here, unlike the chip's icon-only version. The panel is where you come to decide
|
{/* Labelled here, unlike the chip's icon-only version. The panel is where you come to decide
|
||||||
whether to keep something running, so the control that ends it should not be a glyph you
|
whether to keep something running, so the control that ends it should not be a glyph you
|
||||||
have to hover to identify. */}
|
have to hover to identify. */}
|
||||||
@@ -321,10 +338,7 @@ const TaskPanel = ({ task, onClose, onStop, onPause, onRestart }: TaskPanelProps
|
|||||||
{running && onPause && (
|
{running && onPause && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={async () => {
|
onClick={() => void onPause(!paused)}
|
||||||
const next = !paused;
|
|
||||||
if (await onPause(next)) setPaused(next);
|
|
||||||
}}
|
|
||||||
title={paused ? 'Let it continue where it left off' : 'Freeze the process, keeping its state'}
|
title={paused ? 'Let it continue where it left off' : 'Freeze the process, keeping its state'}
|
||||||
className="flex shrink-0 cursor-pointer items-center gap-1 rounded border border-border px-2 py-1 text-xs text-muted-foreground transition-colors hover:border-warning/40 hover:text-warning"
|
className="flex shrink-0 cursor-pointer items-center gap-1 rounded border border-border px-2 py-1 text-xs text-muted-foreground transition-colors hover:border-warning/40 hover:text-warning"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user