From 44361d9486edbf3509f68566e7b5f305a70936f3 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Tue, 21 Jul 2026 02:01:35 +0000 Subject: [PATCH] Prioritize active runtime spinner in thread rows --- .../sidebar/ProjectList.modes.test.tsx | 5 +- .../sidebar/ProjectRow.interactions.test.tsx | 12 +-- .../sidebar/SidebarThreadSearchPanel.test.tsx | 6 +- .../components/sidebar/ThreadRow.stories.tsx | 10 +- .../src/components/sidebar/ThreadRow.test.tsx | 96 +++++++++++-------- apps/app/src/components/sidebar/ThreadRow.tsx | 1 + apps/app/src/lib/thread-activity.test.ts | 41 ++++++-- apps/app/src/lib/thread-activity.ts | 9 +- .../views/RootComposeMobileRecents.test.tsx | 13 +-- 9 files changed, 116 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 a25cdff13..3f11c113f 100644 --- a/apps/app/src/components/sidebar/ProjectList.modes.test.tsx +++ b/apps/app/src/components/sidebar/ProjectList.modes.test.tsx @@ -284,7 +284,7 @@ describe("sidebar organization mode sections", () => { expect(mockBuildMachineThreadGroups).toHaveBeenCalledWith([], []); }); - it("surfaces shared activity for a collapsed machine section", () => { + it("surfaces shared runtime activity for a collapsed machine section", () => { const store = createStore(); store.set(sidebarMachineSectionOrderAtom, ["machine:no-machine"]); store.set(sidebarCollapsedMachinesAtom, ["no-machine"]); @@ -296,6 +296,7 @@ describe("sidebar organization mode sections", () => { ); expect(screen.queryByText("Machine activity")).toBeNull(); - expect(screen.getByLabelText("Plan mode active")).not.toBeNull(); + expect(screen.getByLabelText("Thread working")).not.toBeNull(); + expect(screen.queryByLabelText("Plan mode active")).toBeNull(); }); }); diff --git a/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx b/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx index 7b9589d46..9d05fb2ea 100644 --- a/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx @@ -156,7 +156,7 @@ describe("ProjectRow interactions", () => { vi.clearAllMocks(); }); - it("shows named workflow rollup before generic runtime activity", () => { + it("shows generic runtime activity before a named workflow rollup", () => { renderProjectRow( vi.fn(), { @@ -199,8 +199,8 @@ describe("ProjectRow interactions", () => { name: "Expand Feature workspace threads", }), ).not.toBeNull(); - expect(screen.getByLabelText("Workflow running")).not.toBeNull(); - expect(screen.queryByLabelText("Thread working")).toBeNull(); + expect(screen.getByLabelText("Thread working")).not.toBeNull(); + expect(screen.queryByLabelText("Workflow running")).toBeNull(); }); it("shows a working draft before named work for a collapsed environment", () => { @@ -241,7 +241,7 @@ describe("ProjectRow interactions", () => { expect(screen.queryByLabelText("Plan mode active")).toBeNull(); }); - it("uses shared Plan precedence when a top-level section is collapsed", () => { + it("uses shared runtime precedence when a top-level section is collapsed", () => { const store = createStore(); const queryClient = new QueryClient(); const sectionId = "sec_active"; @@ -292,9 +292,9 @@ describe("ProjectRow interactions", () => { ); expect(screen.queryByText("Test thread")).toBeNull(); - expect(screen.getByLabelText("Plan mode active")).not.toBeNull(); + expect(screen.getByLabelText("Thread working")).not.toBeNull(); + expect(screen.queryByLabelText("Plan mode active")).toBeNull(); expect(screen.queryByLabelText("Goal active")).toBeNull(); - expect(screen.queryByLabelText("Thread working")).toBeNull(); }); it("shows a working draft before Plan for a collapsed section", () => { diff --git a/apps/app/src/components/sidebar/SidebarThreadSearchPanel.test.tsx b/apps/app/src/components/sidebar/SidebarThreadSearchPanel.test.tsx index d5c1a5d2b..3c4892fcf 100644 --- a/apps/app/src/components/sidebar/SidebarThreadSearchPanel.test.tsx +++ b/apps/app/src/components/sidebar/SidebarThreadSearchPanel.test.tsx @@ -182,7 +182,7 @@ describe("SidebarThreadSearchPanel", () => { expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest" }); }); - it("uses shared Plan precedence for search results", () => { + it("uses shared runtime precedence for search results", () => { const thread = createThreadListEntry({ id: "thr_plan_goal", title: "Concurrent Plan and Goal", @@ -220,9 +220,9 @@ describe("SidebarThreadSearchPanel", () => { />, ); - expect(screen.getByLabelText("Plan mode active")).not.toBeNull(); + expect(screen.getByLabelText("Thread working")).not.toBeNull(); + expect(screen.queryByLabelText("Plan mode active")).toBeNull(); expect(screen.queryByLabelText("Goal active")).toBeNull(); - expect(screen.queryByLabelText("Thread working")).toBeNull(); }); it("subscribes search results to working draft state", () => { diff --git a/apps/app/src/components/sidebar/ThreadRow.stories.tsx b/apps/app/src/components/sidebar/ThreadRow.stories.tsx index ed826e9be..d3ae74928 100644 --- a/apps/app/src/components/sidebar/ThreadRow.stories.tsx +++ b/apps/app/src/components/sidebar/ThreadRow.stories.tsx @@ -392,7 +392,7 @@ export function Overview() { @@ -400,7 +400,7 @@ export function Overview() { diff --git a/apps/app/src/components/sidebar/ThreadRow.test.tsx b/apps/app/src/components/sidebar/ThreadRow.test.tsx index 10ce53070..3ab1c20f1 100644 --- a/apps/app/src/components/sidebar/ThreadRow.test.tsx +++ b/apps/app/src/components/sidebar/ThreadRow.test.tsx @@ -245,7 +245,7 @@ describe("ThreadRow", () => { ); }); - it("replaces the generic working indicator with a shimmering plugin status", () => { + it("keeps the runtime spinner ahead of a shimmering plugin status", () => { setPluginThreadRowStatus("thr_test", "composer-status-test", { icon: "AiContentGenerator01", label: "Plugin improving draft", @@ -262,16 +262,16 @@ describe("ThreadRow", () => { }), }); - const runningIcon = screen.getByLabelText("Plugin improving draft"); - expect(runningIcon.getAttribute("data-icon")).toBe("AiContentGenerator01"); - expect(Array.from(runningIcon.classList)).toContain("animate-shine-icon"); - expect(screen.queryByLabelText("Agent working")).toBeNull(); + const runningIcon = screen.getByLabelText("Thread working"); + expect(runningIcon.getAttribute("data-icon")).toBe("Loading"); + expect(Array.from(runningIcon.classList)).toContain("animate-spin"); + expect(screen.queryByLabelText("Plugin improving draft")).toBeNull(); expect( container.querySelector("[data-sidebar-thread-trailing-indicator]"), ).not.toBeNull(); }); - it("replaces the active working spinner with a shimmering draft icon", () => { + it("keeps the active working spinner ahead of a draft", () => { renderThreadRow({ hasComposerDraft: true, isActive: true, @@ -284,17 +284,18 @@ describe("ThreadRow", () => { }), }); - const draftIcon = screen.getByLabelText( - "Thread working with unsubmitted draft", - ); - expect(Array.from(draftIcon.classList)).toContain("animate-shine-icon"); - expect(Array.from(draftIcon.classList)).toContain( + 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("Agent working")).toBeNull(); + expect( + screen.queryByLabelText("Thread working with unsubmitted draft"), + ).toBeNull(); }); - it("keeps the draft icon shimmering when a working thread is not selected", () => { + it("keeps the runtime spinner ahead of a draft when the row is not selected", () => { renderThreadRow({ hasComposerDraft: true, isActive: false, @@ -307,14 +308,15 @@ describe("ThreadRow", () => { }), }); - const draftIcon = screen.getByLabelText( - "Thread working with unsubmitted draft", - ); - expect(Array.from(draftIcon.classList)).toContain("animate-shine-icon"); - expect(Array.from(draftIcon.classList)).toContain( + 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("Agent working")).toBeNull(); + expect( + screen.queryByLabelText("Thread working with unsubmitted draft"), + ).toBeNull(); }); it.each([ @@ -573,7 +575,7 @@ describe("ThreadRow", () => { ).toBe("Meta+3"); }); - it("shows an unread error before pending or active work", () => { + it("shows runtime work before unread, pending, draft, and background work", () => { renderThreadRow({ hasComposerDraft: true, shortcutKey: "3", @@ -596,7 +598,8 @@ describe("ThreadRow", () => { }), }); - expect(screen.getByLabelText("Unread thread failed")).not.toBeNull(); + expect(screen.getByLabelText("Thread working")).not.toBeNull(); + expect(screen.queryByLabelText("Unread thread failed")).toBeNull(); expect(screen.queryByLabelText("Thread needs user input")).toBeNull(); expect(screen.queryByLabelText("Agent working")).toBeNull(); expect(screen.queryByLabelText("Workflow running")).toBeNull(); @@ -627,28 +630,37 @@ describe("ThreadRow", () => { expect(screen.queryByLabelText("Agent working")).toBeNull(); }); - it("shows named workflow work before generic runtime work", () => { - renderThreadRow({ - thread: createThread({ - title: "Active workflow thread", - status: "active", - runtime: { - displayStatus: "active", - hostReconnectGraceExpiresAt: null, - }, - activity: { - activeWorkflowCount: 1, - activeBackgroundAgentCount: 0, - activeBackgroundCommandCount: 0, - activePlanModeCount: 0, - activeGoalCount: 0, - }, - }), - }); + it.each([ + ["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) => { + 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("Workflow running")).not.toBeNull(); - expect(screen.queryByLabelText("Thread working")).toBeNull(); - }); + expect(screen.getByLabelText("Thread working")).not.toBeNull(); + expect(screen.queryByLabelText(secondaryLabel)).toBeNull(); + }, + ); it("shows an animated delegated-agent glyph for active background agent work", () => { renderThreadRow({ diff --git a/apps/app/src/components/sidebar/ThreadRow.tsx b/apps/app/src/components/sidebar/ThreadRow.tsx index 48759e65b..c1de5f181 100644 --- a/apps/app/src/components/sidebar/ThreadRow.tsx +++ b/apps/app/src/components/sidebar/ThreadRow.tsx @@ -402,6 +402,7 @@ function ThreadTrailingIndicator({ const indicatorKind = resolveThreadListIndicator(statusProps); const pluginStatusIsVisible = pluginStatus !== null && + indicatorKind !== "runtime" && indicatorKind !== "unread-error" && indicatorKind !== "waiting-for-input"; diff --git a/apps/app/src/lib/thread-activity.test.ts b/apps/app/src/lib/thread-activity.test.ts index d4a71315a..413329956 100644 --- a/apps/app/src/lib/thread-activity.test.ts +++ b/apps/app/src/lib/thread-activity.test.ts @@ -61,26 +61,29 @@ const idleIndicatorState: ThreadListIndicatorState = { describe("thread-activity", () => { describe("resolveThreadListIndicator", () => { it.each([ - ["isWorkflowActive", "workflow"], - ["isBackgroundAgentActive", "background-agent"], - ["isBackgroundCommandActive", "background-command"], - ["isPlanModeActive", "plan-mode"], - ["isGoalActive", "goal"], + "hasPendingInteraction", + "hasUnsubmittedDraft", + "hasUnreadError", + "hasUnreadSuccess", + "isWorkflowActive", + "isBackgroundAgentActive", + "isBackgroundCommandActive", + "isPlanModeActive", + "isGoalActive", ] as const)( - "prefers %s over concurrent generic runtime work", - (flag, expected) => { + "prefers runtime work over concurrent %s", + (flag) => { expect( resolveThreadListIndicator({ ...idleIndicatorState, isRuntimeActive: true, [flag]: true, }), - ).toBe(expected); + ).toBe("runtime"); }, ); it.each([ - "isRuntimeActive", "isWorkflowActive", "isBackgroundAgentActive", "isBackgroundCommandActive", @@ -106,6 +109,26 @@ describe("thread-activity", () => { ).toBe("plan-mode"); }); + it("applies idle activity precedence before background work", () => { + expect( + resolveThreadListIndicator({ + ...idleIndicatorState, + isBackgroundAgentActive: true, + isBackgroundCommandActive: true, + isGoalActive: true, + isPlanModeActive: true, + }), + ).toBe("plan-mode"); + expect( + resolveThreadListIndicator({ + ...idleIndicatorState, + isBackgroundAgentActive: true, + isBackgroundCommandActive: true, + isGoalActive: true, + }), + ).toBe("goal"); + }); + it("applies critical, idle draft, and unread precedence", () => { expect( resolveThreadListIndicator({ diff --git a/apps/app/src/lib/thread-activity.ts b/apps/app/src/lib/thread-activity.ts index b3ac083f1..a82234492 100644 --- a/apps/app/src/lib/thread-activity.ts +++ b/apps/app/src/lib/thread-activity.ts @@ -116,11 +116,13 @@ export function getThreadListIndicatorLabel( 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"; if (state.hasUnreadError) return "unread-error"; if (state.hasPendingInteraction) return "waiting-for-input"; const hasActiveWork = - state.isRuntimeActive || state.isWorkflowActive || state.isBackgroundAgentActive || state.isBackgroundCommandActive || @@ -128,11 +130,10 @@ export function resolveThreadListIndicator( state.isGoalActive; if (state.hasUnsubmittedDraft && hasActiveWork) return "working-draft"; if (state.isWorkflowActive) return "workflow"; - if (state.isBackgroundAgentActive) return "background-agent"; - if (state.isBackgroundCommandActive) return "background-command"; if (state.isPlanModeActive) return "plan-mode"; if (state.isGoalActive) return "goal"; - if (state.isRuntimeActive) return "runtime"; + if (state.isBackgroundAgentActive) return "background-agent"; + if (state.isBackgroundCommandActive) return "background-command"; if (state.hasUnsubmittedDraft) return "draft"; if (state.hasUnreadSuccess) return "unread-success"; return "none"; diff --git a/apps/app/src/views/RootComposeMobileRecents.test.tsx b/apps/app/src/views/RootComposeMobileRecents.test.tsx index 52431fb8b..f79b9692f 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("does not let runtime gating conceal concurrent Plan and Goal activity", () => { + it("shows runtime activity before concurrent Plan and Goal activity", () => { render( { , ); - expect(screen.getByLabelText("Plan mode active")).not.toBeNull(); + expect(screen.getByLabelText("Thread working")).not.toBeNull(); + expect(screen.queryByLabelText("Plan mode active")).toBeNull(); expect(screen.queryByLabelText("Goal active")).toBeNull(); - expect(screen.queryByLabelText("Thread working")).toBeNull(); }); - it("subscribes mobile recents to working draft state", () => { + it("keeps runtime activity ahead of mobile working draft state", () => { window.localStorage.setItem( "bb.promptbox.contents-proj_mobile-thr_mobile-3", JSON.stringify({ text: "Keep editing", attachments: [] }), @@ -90,9 +90,10 @@ describe("RootComposeMobileRecents", () => { , ); + expect(screen.getByLabelText("Thread working")).not.toBeNull(); expect( - screen.getByLabelText("Thread working with unsubmitted draft"), - ).not.toBeNull(); + screen.queryByLabelText("Thread working with unsubmitted draft"), + ).toBeNull(); expect(screen.queryByLabelText("Plan mode active")).toBeNull(); });