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();
});