From c65ec3086d66f9eb1cae86e252c14893c3812297 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Thu, 23 Jul 2026 22:14:36 +0000 Subject: [PATCH] Show the pending-question glyph while a thread's runtime is active MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A thread blocked on AskUserQuestion keeps its runtime `active` for as long as the question is open, so ranking "runtime" first in `resolveThreadListIndicator` made the spinner win every time and left the CircleQuestion glyph unreachable in the sidebar. Restore the pre-#828 ordering and demote only the three ambient work signals — workflow, background-agent, and background-command — below the spinner. That keeps #828's intent (the spinner beats work the row can't otherwise explain) while attention states, working-draft, plan, and goal sit above it again as they did before. Co-Authored-By: Claude --- .../sidebar/ProjectList.modes.test.tsx | 4 +- .../sidebar/ProjectRow.interactions.test.tsx | 6 +- .../sidebar/SidebarThreadSearchPanel.test.tsx | 4 +- .../src/components/sidebar/ThreadRow.test.tsx | 128 ++++++++++-------- apps/app/src/lib/thread-activity.test.ts | 24 +++- apps/app/src/lib/thread-activity.ts | 12 +- .../views/RootComposeMobileRecents.test.tsx | 40 +++++- 7 files changed, 141 insertions(+), 77 deletions(-) diff --git a/apps/app/src/components/sidebar/ProjectList.modes.test.tsx b/apps/app/src/components/sidebar/ProjectList.modes.test.tsx index 3f11c113f..6fc902c83 100644 --- a/apps/app/src/components/sidebar/ProjectList.modes.test.tsx +++ b/apps/app/src/components/sidebar/ProjectList.modes.test.tsx @@ -296,7 +296,7 @@ describe("sidebar organization mode sections", () => { ); expect(screen.queryByText("Machine activity")).toBeNull(); - expect(screen.getByLabelText("Thread working")).not.toBeNull(); - expect(screen.queryByLabelText("Plan mode active")).toBeNull(); + expect(screen.getByLabelText("Plan mode active")).not.toBeNull(); + expect(screen.queryByLabelText("Thread working")).toBeNull(); }); }); diff --git a/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx b/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx index 4ead933a4..7fbdfa3c1 100644 --- a/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx @@ -326,9 +326,9 @@ describe("ProjectRow interactions", () => { ); expect(screen.queryByText("Test thread")).toBeNull(); - expect(screen.getAllByLabelText("Thread working")).not.toHaveLength(0); - expectCollapsedActivityAtSidebarEdge("Thread working"); - expect(screen.queryByLabelText("Plan mode active")).toBeNull(); + expect(screen.getAllByLabelText("Plan mode active")).not.toHaveLength(0); + expectCollapsedActivityAtSidebarEdge("Plan mode active"); + expect(screen.queryByLabelText("Thread working")).toBeNull(); expect(screen.queryByLabelText("Goal active")).toBeNull(); }); diff --git a/apps/app/src/components/sidebar/SidebarThreadSearchPanel.test.tsx b/apps/app/src/components/sidebar/SidebarThreadSearchPanel.test.tsx index 91a66c64a..5f47e11a6 100644 --- a/apps/app/src/components/sidebar/SidebarThreadSearchPanel.test.tsx +++ b/apps/app/src/components/sidebar/SidebarThreadSearchPanel.test.tsx @@ -220,8 +220,8 @@ describe("SidebarThreadSearchPanel", () => { />, ); - expect(screen.getByLabelText("Thread working")).not.toBeNull(); - expect(screen.queryByLabelText("Plan mode active")).toBeNull(); + expect(screen.getByLabelText("Plan mode active")).not.toBeNull(); + expect(screen.queryByLabelText("Thread working")).toBeNull(); expect(screen.queryByLabelText("Goal active")).toBeNull(); }); diff --git a/apps/app/src/components/sidebar/ThreadRow.test.tsx b/apps/app/src/components/sidebar/ThreadRow.test.tsx index 496b886e7..977573937 100644 --- a/apps/app/src/components/sidebar/ThreadRow.test.tsx +++ b/apps/app/src/components/sidebar/ThreadRow.test.tsx @@ -520,53 +520,32 @@ describe("ThreadRow", () => { ).not.toBeNull(); }); - it("keeps the active working spinner ahead of a draft", () => { - renderThreadRow({ - hasComposerDraft: true, - isActive: true, - thread: createThread({ - status: "active", - runtime: { - displayStatus: "active", - hostReconnectGraceExpiresAt: null, - }, - }), - }); - - const workingIcon = screen.getByLabelText("Thread working"); - expect(workingIcon.getAttribute("data-icon")).toBe("Loading"); - expect(Array.from(workingIcon.classList)).toContain("animate-spin"); - expect(Array.from(workingIcon.classList)).toContain( - SIDEBAR_WORKING_STATUS_COLOR_CLASS, - ); - expect( - screen.queryByLabelText("Thread working with unsubmitted draft"), - ).toBeNull(); - }); - - it("keeps the runtime spinner ahead of a draft when the row is not selected", () => { - renderThreadRow({ - hasComposerDraft: true, - isActive: false, - thread: createThread({ - status: "active", - runtime: { - displayStatus: "active", - hostReconnectGraceExpiresAt: null, - }, - }), - }); + it.each([true, false] as const)( + "keeps the working-draft pencil ahead of the runtime spinner when isActive=%s", + (isActive) => { + renderThreadRow({ + hasComposerDraft: true, + isActive, + thread: createThread({ + status: "active", + runtime: { + displayStatus: "active", + hostReconnectGraceExpiresAt: null, + }, + }), + }); - const workingIcon = screen.getByLabelText("Thread working"); - expect(workingIcon.getAttribute("data-icon")).toBe("Loading"); - expect(Array.from(workingIcon.classList)).toContain("animate-spin"); - expect(Array.from(workingIcon.classList)).toContain( - SIDEBAR_WORKING_STATUS_COLOR_CLASS, - ); - expect( - screen.queryByLabelText("Thread working with unsubmitted draft"), - ).toBeNull(); - }); + const draftIcon = screen.getByLabelText( + "Thread working with unsubmitted draft", + ); + expect(draftIcon.getAttribute("data-icon")).toBe("Edit"); + expect(Array.from(draftIcon.classList)).toContain("animate-shine-icon"); + expect(Array.from(draftIcon.classList)).toContain( + SIDEBAR_WORKING_STATUS_COLOR_CLASS, + ); + expect(screen.queryByLabelText("Thread working")).toBeNull(); + }, + ); it.each([ "activeWorkflowCount", @@ -824,15 +803,27 @@ describe("ThreadRow", () => { ).toBe("Meta+3"); }); - it("shows runtime work before unread, pending, draft, and background work", () => { + it("shows the pending-input glyph while the runtime is still active", () => { + // A thread blocked on AskUserQuestion keeps an active runtime for as long as + // the question is open, so the spinner must not win this row. renderThreadRow({ - hasComposerDraft: true, - shortcutKey: "3", thread: createThread({ - status: "error", hasPendingInteraction: true, - lastReadAt: 1_000, - latestAttentionAt: 2_000, + runtime: { + displayStatus: "active", + hostReconnectGraceExpiresAt: null, + }, + }), + }); + + expect(screen.getByLabelText("Thread needs user input")).not.toBeNull(); + expect(screen.queryByLabelText("Thread working")).toBeNull(); + }); + + it("shows runtime work before workflow and background work", () => { + renderThreadRow({ + shortcutKey: "3", + thread: createThread({ activity: { activeWorkflowCount: 1, activeBackgroundAgentCount: 1, @@ -883,8 +874,6 @@ describe("ThreadRow", () => { ["activeWorkflowCount", "Workflow running"], ["activeBackgroundAgentCount", "Background agent running"], ["activeBackgroundCommandCount", "Background command running"], - ["activePlanModeCount", "Plan mode active"], - ["activeGoalCount", "Goal active"], ] as const)( "shows runtime work before concurrent %s activity", (activityKey, secondaryLabel) => { @@ -911,6 +900,37 @@ describe("ThreadRow", () => { }, ); + it.each([ + ["activePlanModeCount", "Plan mode active"], + ["activeGoalCount", "Goal active"], + ] as const)( + "shows concurrent %s activity before runtime work", + (activityKey, modeLabel) => { + // Plan and goal describe how the running turn behaves, and their glyphs + // shimmer, so they stay legible instead of collapsing into the spinner. + renderThreadRow({ + thread: createThread({ + status: "active", + runtime: { + displayStatus: "active", + hostReconnectGraceExpiresAt: null, + }, + activity: { + activeWorkflowCount: 0, + activeBackgroundAgentCount: 0, + activeBackgroundCommandCount: 0, + activePlanModeCount: 0, + activeGoalCount: 0, + [activityKey]: 1, + }, + }), + }); + + expect(screen.getByLabelText(modeLabel)).not.toBeNull(); + expect(screen.queryByLabelText("Thread working")).toBeNull(); + }, + ); + it("shows an animated delegated-agent glyph for active background agent work", () => { renderThreadRow({ thread: createThread({ diff --git a/apps/app/src/lib/thread-activity.test.ts b/apps/app/src/lib/thread-activity.test.ts index 1a9a7a389..7e2f72b42 100644 --- a/apps/app/src/lib/thread-activity.test.ts +++ b/apps/app/src/lib/thread-activity.test.ts @@ -96,15 +96,29 @@ describe("thread-activity", () => { describe("resolveThreadListIndicator", () => { it.each([ - "hasPendingInteraction", - "hasUnsubmittedDraft", - "hasUnreadError", + ["hasPendingInteraction", "waiting-for-input"], + ["hasUnreadError", "unread-error"], + ["hasUnsubmittedDraft", "working-draft"], + ["isPlanModeActive", "plan-mode"], + ["isGoalActive", "goal"], + ] as const)("shows %s as %s over the runtime spinner", (flag, kind) => { + // The runtime stays active for as long as a question or approval is open, + // so the spinner must not mask it. Plan and goal describe the running turn + // and shimmer on their own, so they outrank it too. + expect( + resolveThreadListIndicator({ + ...idleIndicatorState, + isRuntimeActive: true, + [flag]: true, + }), + ).toBe(kind); + }); + + it.each([ "hasUnreadSuccess", "isWorkflowActive", "isBackgroundAgentActive", "isBackgroundCommandActive", - "isPlanModeActive", - "isGoalActive", ] as const)("prefers runtime work over concurrent %s", (flag) => { expect( resolveThreadListIndicator({ diff --git a/apps/app/src/lib/thread-activity.ts b/apps/app/src/lib/thread-activity.ts index abb12c087..abceccdb0 100644 --- a/apps/app/src/lib/thread-activity.ts +++ b/apps/app/src/lib/thread-activity.ts @@ -137,17 +137,21 @@ export function hasThreadListWorkingActivity( export function resolveThreadListIndicator( state: ThreadListIndicatorState, ): ThreadListIndicatorKind { - // Foreground runtime work is the primary row status. Secondary activity and - // attention signals become useful again once the runtime is idle. - if (state.isRuntimeActive) return "runtime"; + // Attention states come first: the runtime stays active for the whole time a + // question or approval is open, so ranking "runtime" above them would hide the + // one state the user can act on behind a spinner that never resolves on its + // own. Plan and goal outrank the spinner too — they describe how the current + // turn is running, and their glyphs shimmer, so they already read as working. + // Only ambient work the row can't otherwise explain sits below the spinner. if (state.hasUnreadError) return "unread-error"; if (state.hasPendingInteraction) return "waiting-for-input"; const hasActiveWork = hasThreadListWorkingActivity(state); if (state.hasUnsubmittedDraft && hasActiveWork) return "working-draft"; - if (state.isWorkflowActive) return "workflow"; if (state.isPlanModeActive) return "plan-mode"; if (state.isGoalActive) return "goal"; + if (state.isRuntimeActive) return "runtime"; + if (state.isWorkflowActive) return "workflow"; if (state.isBackgroundAgentActive) return "background-agent"; if (state.isBackgroundCommandActive) return "background-command"; if (state.hasUnsubmittedDraft) return "draft"; diff --git a/apps/app/src/views/RootComposeMobileRecents.test.tsx b/apps/app/src/views/RootComposeMobileRecents.test.tsx index f79b9692f..774a8ee8f 100644 --- a/apps/app/src/views/RootComposeMobileRecents.test.tsx +++ b/apps/app/src/views/RootComposeMobileRecents.test.tsx @@ -56,7 +56,7 @@ afterEach(() => { }); describe("RootComposeMobileRecents", () => { - it("shows runtime activity before concurrent Plan and Goal activity", () => { + it("shows concurrent Plan activity before the runtime spinner", () => { render( { , ); - expect(screen.getByLabelText("Thread working")).not.toBeNull(); - expect(screen.queryByLabelText("Plan mode active")).toBeNull(); + expect(screen.getByLabelText("Plan mode active")).not.toBeNull(); + expect(screen.queryByLabelText("Thread working")).toBeNull(); expect(screen.queryByLabelText("Goal active")).toBeNull(); }); - it("keeps runtime activity ahead of mobile working draft state", () => { + it("shows runtime activity before concurrent workflow activity", () => { + render( + + + , + ); + + expect(screen.getByLabelText("Thread working")).not.toBeNull(); + expect(screen.queryByLabelText("Workflow running")).toBeNull(); + }); + + it("keeps the mobile working draft state ahead of runtime activity", () => { window.localStorage.setItem( "bb.promptbox.contents-proj_mobile-thr_mobile-3", JSON.stringify({ text: "Keep editing", attachments: [] }), @@ -90,10 +116,10 @@ describe("RootComposeMobileRecents", () => { , ); - expect(screen.getByLabelText("Thread working")).not.toBeNull(); expect( - screen.queryByLabelText("Thread working with unsubmitted draft"), - ).toBeNull(); + screen.getByLabelText("Thread working with unsubmitted draft"), + ).not.toBeNull(); + expect(screen.queryByLabelText("Thread working")).toBeNull(); expect(screen.queryByLabelText("Plan mode active")).toBeNull(); });