diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 616ff8c4ec05..52c08fd7ee59 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -2096,8 +2096,22 @@ describe("navigation after parking a thread", () => { describe("unseen completion with background work", () => { it.each([ - { kind: "command", status: "ready", topStatus: "done", receded: false, pill: "Completed" }, - { kind: "monitor", status: "waiting", topStatus: "waiting", receded: true, pill: "Waiting" }, + { + kind: "command", + status: "ready", + topStatus: "done", + receded: false, + working: true, + pill: "Completed", + }, + { + kind: "monitor", + status: "waiting", + topStatus: "waiting", + receded: true, + working: true, + pill: "Waiting", + }, ] as const)("presents a completed thread with a $kind roster", (expected) => { const thread = presentThreadShell(localEnvironmentId, { ...makeThreadFixture().source, @@ -2122,7 +2136,7 @@ describe("unseen completion with background work", () => { isSelected: false, }), ).toBe(expected.receded); - expect(isSidebarThreadWorking(thread)).toBe(expected.receded); + expect(isSidebarThreadWorking(thread)).toBe(expected.working); expect(resolveThreadStatusPill({ thread })).toMatchObject({ label: expected.pill }); }); }); @@ -2143,6 +2157,7 @@ describe("Working shelf (beta)", () => { hasPendingUserInput: false, interactionMode: "default" as const, latestRun: makeLatestRun(), + pendingBackgroundTasks: [], runtime: null, }; // Stopped with background tasks still open: V2's "waiting" sidebar status. @@ -2166,6 +2181,29 @@ describe("Working shelf (beta)", () => { ).toBe(false); }); + it("folds away every background kind, commands included", () => { + const completed = { ...runtime, status: "completed" as const }; + for (const kind of ["command", "monitor", "subagent", "background_task"] as const) { + expect( + isSidebarThreadWorking({ + ...idle, + runtime: completed, + pendingBackgroundTasks: [{ taskId: "bg-1", kind }], + }), + ).toBe(true); + } + }); + + it("keeps a failed thread in the inbox while a command still runs", () => { + expect( + isSidebarThreadWorking({ + ...idle, + runtime: { ...runtime, status: "failed" as const, lastError: "boom" }, + pendingBackgroundTasks: [{ taskId: "bg-1", kind: "command" as const }], + }), + ).toBe(false); + }); + it("keeps a ready plan in the inbox while background work runs", () => { expect( isSidebarThreadWorking({ diff --git a/packages/client-runtime/src/state/threadInbox.test.ts b/packages/client-runtime/src/state/threadInbox.test.ts index 2ae5670933c7..b1bc4bc58884 100644 --- a/packages/client-runtime/src/state/threadInbox.test.ts +++ b/packages/client-runtime/src/state/threadInbox.test.ts @@ -12,6 +12,7 @@ function thread(id: string, working: boolean) { createdAt: "2026-06-01T00:00:00.000Z", unsettledAt: null, latestRun: null, + pendingBackgroundTasks: [], hasActionableProposedPlan: false, hasPendingApprovals: false, hasPendingUserInput: false, diff --git a/packages/client-runtime/src/state/threadInbox.ts b/packages/client-runtime/src/state/threadInbox.ts index 0a7ed0918afe..df9f65ff34de 100644 --- a/packages/client-runtime/src/state/threadInbox.ts +++ b/packages/client-runtime/src/state/threadInbox.ts @@ -11,15 +11,25 @@ type WorkingThreadInput = Pick< | "hasPendingUserInput" | "interactionMode" | "latestRun" + | "pendingBackgroundTasks" | "runtime" >; /** Threads busy with work that does not need the user fold into the Working - section: a running run, or one stopped with background work that will wake - it. Approvals, questions, plan prompts, and failures stay in the inbox. */ + section: a running run, or one stopped with any background work still + live. Commands count here even though they do not hold completion: the + agent may be waiting on one, and a dev server is still visibly running. + Approvals, questions, plan prompts, and failures stay in the inbox. */ export function isThreadWorking(thread: WorkingThreadInput): boolean { if (thread.hasPendingApprovals || thread.hasPendingUserInput) return false; - if (!threadRuntimeIsActive(thread.runtime) && thread.runtime?.status !== "idle") return false; + if (thread.runtime?.status === "failed") return false; + if ( + !threadRuntimeIsActive(thread.runtime) && + thread.runtime?.status !== "idle" && + thread.pendingBackgroundTasks.length === 0 + ) { + return false; + } // A plan prompt outranks lingering background work: the user has to act on it. const run = thread.latestRun; const runSettled =