From 10c0435b7d47c88f9b60ee62b0d52f73d7005d41 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Sat, 1 Aug 2026 09:58:34 +0100 Subject: [PATCH 1/3] fix(desktop): spin the sidebar dot for starting cloud runs Cloud queued means a sandbox is being claimed and the backend leaves that state on its own, so it earns the spinner (labelled "Starting"). A local queued run has nothing advancing its persisted status, so it keeps the still dot alongside in_progress runs whose claim outlives the work. --- .../canvas/components/ChannelItemRow.test.tsx | 9 ++++++ .../components/items/taskStatusVocabulary.ts | 30 +++++++++++-------- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/products/desktop/packages/ui/src/features/canvas/components/ChannelItemRow.test.tsx b/products/desktop/packages/ui/src/features/canvas/components/ChannelItemRow.test.tsx index 9f73215f0502..f6c919c45667 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/ChannelItemRow.test.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/ChannelItemRow.test.tsx @@ -85,7 +85,16 @@ describe("ChannelItemRow", () => { "Pending — no work in flight", ], [ + // Launching: a sandbox is being claimed and the backend leaves this state + // on its own, so the motion is honest. "a queued cloud run", + { taskRunStatus: "queued" as const, workspaceMode: "cloud" as const }, + "Starting", + ], + [ + // A local run's status is never advanced, so queued here means "was + // launched at some point", not "is starting". Seen parked for hours. + "a local run parked at queued", { taskRunStatus: "queued" as const }, "Pending — no work in flight", ], diff --git a/products/desktop/packages/ui/src/features/sidebar/components/items/taskStatusVocabulary.ts b/products/desktop/packages/ui/src/features/sidebar/components/items/taskStatusVocabulary.ts index 3b3196762c4b..84aabb1f48a5 100644 --- a/products/desktop/packages/ui/src/features/sidebar/components/items/taskStatusVocabulary.ts +++ b/products/desktop/packages/ui/src/features/sidebar/components/items/taskStatusVocabulary.ts @@ -107,10 +107,11 @@ export interface TaskDot { * the reader actually gets is output they haven't seen, which is `isUnread`, and * the run's real story lives in the task detail where there's room to tell it. * - * Queued is folded into pending for the same reason. "Waiting on a sandbox" and - * "a run that hasn't been closed out" are one fact to the reader — it's live but - * nothing is moving — so they share the still yellow dot. Only a prompt in - * flight spins. + * Queued is folded into working for the same reason. "Waiting on a sandbox" and + * "a sandbox is writing code" are one fact to the reader — it's under way — so + * they share the spinner. What doesn't share it is a run sitting at + * `in_progress` with nothing streaming: that claim outlives the work, and a + * spinner that never stops is a lie about the machine. * * And a run that has already opened a PR is not working, whatever its status * says. The cloud workflow keeps the run `in_progress` while it babysits CI @@ -128,21 +129,26 @@ export function taskDot(props: TaskStatusInput): TaskDot { label: "Needs permission — blocked on you", }; } - // The spinner is reserved for a prompt actually in flight — the agent typing - // right now. A run status can't earn it: nothing writes a terminal status when - // a local agent goes idle, and the cloud workflow holds `in_progress` while it - // babysits CI, so both keep claiming work for as long as the row exists. A - // spinner that never stops is a lie about the machine, so the claim gets the - // still dot below instead. - if (props.isGenerating) { + // Spinning means something is moving on its own: a prompt in flight, or a + // cloud run still coming up. Cloud `queued` is a sandbox being claimed — the + // backend leaves that state by itself, so the motion is bounded. A LOCAL run + // queued is not a launch: nothing advances a local run's persisted status, so + // it can sit there for hours after the agent is done with it. + const isStartingCloudRun = + props.taskRunStatus === "queued" && props.workspaceMode === "cloud"; + if (props.isGenerating || isStartingCloudRun) { return { tone: "yellow", style: "solid", pulse: false, spinner: true, - label: "Working", + label: props.isGenerating ? "Working" : "Starting", }; } + // The statuses that lie. Nothing writes a terminal status when a local agent + // goes idle, and the cloud workflow holds in_progress while it babysits CI, so + // the claim outlives the work — sometimes for the row's whole life. Live, but + // nothing moving: the still dot. const runClaimsWork = props.taskRunStatus === "in_progress" || props.taskRunStatus === "queued"; if (runClaimsWork && !hasPullRequest(props)) { From 83bf56fc9d86ab5dc8c4d45833a2617a16912d3b Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Sat, 1 Aug 2026 11:33:15 +0100 Subject: [PATCH 2/3] docs(desktop): scope the queued-spins note to cloud runs --- .../components/items/taskStatusVocabulary.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/products/desktop/packages/ui/src/features/sidebar/components/items/taskStatusVocabulary.ts b/products/desktop/packages/ui/src/features/sidebar/components/items/taskStatusVocabulary.ts index 84aabb1f48a5..ce5d736622f2 100644 --- a/products/desktop/packages/ui/src/features/sidebar/components/items/taskStatusVocabulary.ts +++ b/products/desktop/packages/ui/src/features/sidebar/components/items/taskStatusVocabulary.ts @@ -107,10 +107,11 @@ export interface TaskDot { * the reader actually gets is output they haven't seen, which is `isUnread`, and * the run's real story lives in the task detail where there's room to tell it. * - * Queued is folded into working for the same reason. "Waiting on a sandbox" and - * "a sandbox is writing code" are one fact to the reader — it's under way — so - * they share the spinner. What doesn't share it is a run sitting at - * `in_progress` with nothing streaming: that claim outlives the work, and a + * A cloud run's queued is folded into working for the same reason. "Waiting on a + * sandbox" and "a sandbox is writing code" are one fact to the reader, that it's + * under way, so they share the spinner. Two states don't share it: a local run + * at `queued`, whose persisted status nothing ever advances, and a run at + * `in_progress` with nothing streaming. Both claims outlive the work, and a * spinner that never stops is a lie about the machine. * * And a run that has already opened a PR is not working, whatever its status @@ -130,10 +131,10 @@ export function taskDot(props: TaskStatusInput): TaskDot { }; } // Spinning means something is moving on its own: a prompt in flight, or a - // cloud run still coming up. Cloud `queued` is a sandbox being claimed — the - // backend leaves that state by itself, so the motion is bounded. A LOCAL run - // queued is not a launch: nothing advances a local run's persisted status, so - // it can sit there for hours after the agent is done with it. + // cloud run still coming up. Cloud `queued` is a sandbox being claimed, and + // the backend leaves that state by itself, so the motion is bounded. A local + // run at `queued` is not a launch: nothing advances a local run's persisted + // status, so it can sit there for hours after the agent is done with it. const isStartingCloudRun = props.taskRunStatus === "queued" && props.workspaceMode === "cloud"; if (props.isGenerating || isStartingCloudRun) { @@ -147,7 +148,7 @@ export function taskDot(props: TaskStatusInput): TaskDot { } // The statuses that lie. Nothing writes a terminal status when a local agent // goes idle, and the cloud workflow holds in_progress while it babysits CI, so - // the claim outlives the work — sometimes for the row's whole life. Live, but + // the claim outlives the work, sometimes for the row's whole life. Live, but // nothing moving: the still dot. const runClaimsWork = props.taskRunStatus === "in_progress" || props.taskRunStatus === "queued"; From 3018547d9375f187dcbe6830c09eb636aa51c5db Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Sat, 1 Aug 2026 12:28:54 +0100 Subject: [PATCH 3/3] test(desktop): pin the starting spinner over an existing PR --- .../canvas/components/ChannelItemRow.test.tsx | 16 +++++++++++++++- .../components/items/taskStatusVocabulary.ts | 4 +++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/products/desktop/packages/ui/src/features/canvas/components/ChannelItemRow.test.tsx b/products/desktop/packages/ui/src/features/canvas/components/ChannelItemRow.test.tsx index f6c919c45667..80f9ba7857a4 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/ChannelItemRow.test.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/ChannelItemRow.test.tsx @@ -68,7 +68,8 @@ beforeEach(() => { describe("ChannelItemRow", () => { // The dot vocabulary in one table: what the row's leading mark says for each // state a task can be in. Only the states a reader can act on get a voice — - // run mechanics (queued, failed) resolve to "working" or "something to read". + // run mechanics (queued, failed) resolve to a dot that describes the work + // rather than the status: starting, live but stalled, or something to read. it.each([ [ "a permission prompt", @@ -98,6 +99,19 @@ describe("ChannelItemRow", () => { { taskRunStatus: "queued" as const }, "Pending — no work in flight", ], + [ + // A PR outranks a run that only claims to be working, but not one that is + // demonstrably coming up. Re-running a task that already shipped a PR + // leaves the url on the session and the state in the PR query, so this is + // the ordinary shape of a second run, not an edge case. + "a re-queued cloud run on a task that already has a PR", + { + taskRunStatus: "queued" as const, + workspaceMode: "cloud" as const, + prState: "open" as const, + }, + "Starting", + ], [ "a broken run with unseen output", { taskRunStatus: "failed" as const, isUnread: true }, diff --git a/products/desktop/packages/ui/src/features/sidebar/components/items/taskStatusVocabulary.ts b/products/desktop/packages/ui/src/features/sidebar/components/items/taskStatusVocabulary.ts index ce5d736622f2..4d340c79a4f0 100644 --- a/products/desktop/packages/ui/src/features/sidebar/components/items/taskStatusVocabulary.ts +++ b/products/desktop/packages/ui/src/features/sidebar/components/items/taskStatusVocabulary.ts @@ -119,7 +119,9 @@ export interface TaskDot { * after opening the PR, and under a merge queue that wait ends only when someone * enqueues the merge — so the run can claim to be working for hours after the * agent stopped. The PR is the deliverable; once it exists the badge carries the - * story and the dot goes quiet. + * story and the dot goes quiet. This beats a status that merely claims work, not + * one that is visibly starting: a re-queued cloud run keeps its spinner even + * with last run's PR still on the task. */ export function taskDot(props: TaskStatusInput): TaskDot { if (props.needsPermission) {