Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/app/src/components/sidebar/ProjectList.modes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down
128 changes: 74 additions & 54 deletions apps/app/src/components/sidebar/ThreadRow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) => {
Expand All @@ -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({
Expand Down
24 changes: 19 additions & 5 deletions apps/app/src/lib/thread-activity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
12 changes: 8 additions & 4 deletions apps/app/src/lib/thread-activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
40 changes: 33 additions & 7 deletions apps/app/src/views/RootComposeMobileRecents.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<MemoryRouter>
<RootComposeMobileRecents
Expand All @@ -68,12 +68,38 @@ describe("RootComposeMobileRecents", () => {
</MemoryRouter>,
);

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(
<MemoryRouter>
<RootComposeMobileRecents
highlightedThreadId={null}
projectNamesById={new Map()}
showCreatingRow={false}
threads={[
makeThread({
activity: {
activeWorkflowCount: 1,
activeBackgroundAgentCount: 1,
activeBackgroundCommandCount: 1,
activePlanModeCount: 0,
activeGoalCount: 0,
},
}),
]}
/>
</MemoryRouter>,
);

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: [] }),
Expand All @@ -90,10 +116,10 @@ describe("RootComposeMobileRecents", () => {
</MemoryRouter>,
);

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

Expand Down
Loading