From f4f8ea32d1dd844b9b202627e338197bc985e820 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 16 Aug 2026 14:57:25 +0000 Subject: [PATCH] a paused job shows a pause icon, not a spinner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Chat/components/BackgroundTaskTray.tsx | 36 +++++++++++++------ 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/src/workspaces/officerdev/src/apps/Chat/components/BackgroundTaskTray.tsx b/src/workspaces/officerdev/src/apps/Chat/components/BackgroundTaskTray.tsx index 88c1ae60..73351914 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/BackgroundTaskTray.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/BackgroundTaskTray.tsx @@ -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(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([]); 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; + /** 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(null); const pinnedRef = useRef(true); @@ -309,10 +318,18 @@ const TaskPanel = ({ task, onClose, onStop, onPause, onRestart }: TaskPanelProps
{task.description || 'Background task'}
- {task.taskType ?? 'task'} · {task.status ? (task.summary ?? task.status) : 'running…'} + {task.taskType ?? 'task'} · {task.status ? (task.summary ?? task.status) : paused ? 'paused' : 'running…'}
- {running && } + {/* 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 ? ( + + ) : ( + + ))} {/* 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 && (