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..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", @@ -85,10 +86,32 @@ 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", ], + [ + // 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 3b3196762c4b..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 @@ -107,17 +107,21 @@ 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. + * 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 * says. The cloud workflow keeps the run `in_progress` while it babysits CI * 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) { @@ -128,21 +132,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, 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) { 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)) {