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:
2026-08-16 14:57:25 +00:00
co-authored by Claude Opus 5
parent c7749308e1
commit f4f8ea32d1
@@ -37,6 +37,15 @@ export const BackgroundTaskTray = ({ messages, sessionId }: BackgroundTaskTrayPr
const { tasks, pinned, unpinned, running, clearable, togglePin, dismiss, dismissFinished } =
useBackgroundTasks(messages);
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();
/**
@@ -91,6 +100,7 @@ export const BackgroundTaskTray = ({ messages, sessionId }: BackgroundTaskTrayPr
try {
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');
else setPausedIds((prev) => (paused ? [...new Set([...prev, taskId])] : prev.filter((id) => id !== taskId)));
return result.ok;
} catch (err) {
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)}
onStop={open.status ? undefined : () => void stopTask(open.taskId)}
onPause={(next) => pauseTask(open.taskId, next)}
paused={pausedIds.includes(open.taskId)}
onRestart={() => void restartTask(open.taskId)}
/>
)}
@@ -280,18 +291,16 @@ type TaskPanelProps = {
onStop?: () => void;
/** Freeze and thaw the process. Present while it is running; the label follows the state. */
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. */
onRestart?: () => void;
task: BackgroundTask;
onClose: () => void;
};
const TaskPanel = ({ task, onClose, onStop, onPause, onRestart }: TaskPanelProps) => {
const TaskPanel = ({ task, onClose, onStop, onPause, onRestart, paused = false }: TaskPanelProps) => {
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 bodyRef = useRef<HTMLDivElement | null>(null);
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="truncate text-xs text-foreground/80">{task.description || 'Background task'}</div>
<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>
{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
whether to keep something running, so the control that ends it should not be a glyph you
have to hover to identify. */}
@@ -321,10 +338,7 @@ const TaskPanel = ({ task, onClose, onStop, onPause, onRestart }: TaskPanelProps
{running && onPause && (
<button
type="button"
onClick={async () => {
const next = !paused;
if (await onPause(next)) setPaused(next);
}}
onClick={() => void onPause(!paused)}
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"
>