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
5 changes: 3 additions & 2 deletions apps/app/src/components/sidebar/ProjectList.modes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
Expand All @@ -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();
});
});
12 changes: 6 additions & 6 deletions apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
{
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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", () => {
Expand Down
10 changes: 5 additions & 5 deletions apps/app/src/components/sidebar/ThreadRow.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -392,15 +392,15 @@ export function Overview() {
</StoryRow>
<StoryRow
label="active workflow + runtime"
hint="named workflow activity wins over the generic runtime spinner"
hint="foreground runtime activity wins and shows the Loading03 working spinner"
>
<SidebarStage>
<WorkflowAndRuntimeActiveThreadRow />
</SidebarStage>
</StoryRow>
<StoryRow
label="pending interaction"
hint="needs user input - far-right reserved slot shows the grey question icon"
hint="foreground runtime remains active, so the working spinner wins over pending input"
>
<SidebarStage>
<StoryThreadRow
Expand Down Expand Up @@ -462,7 +462,7 @@ export function Overview() {
</StoryRow>
<StoryRow
label="active working + draft"
hint="the selected thread has prompt content, so its shimmering pencil replaces the working spinner"
hint="foreground runtime activity wins over the saved draft and shows the working spinner"
>
<SidebarStage>
<StoryThreadRow
Expand All @@ -484,7 +484,7 @@ export function Overview() {
</StoryRow>
<StoryRow
label="inactive working + draft"
hint="the draft pencil keeps the same working shimmer even when the thread is not selected"
hint="foreground runtime activity wins over the saved draft even when the row is not selected"
>
<SidebarStage>
<StoryThreadRow
Expand Down Expand Up @@ -756,7 +756,7 @@ export function ActiveWorkflow() {
</StoryRow>
<StoryRow
label="active workflow + runtime"
hint="named workflow activity wins over the generic runtime spinner"
hint="foreground runtime activity wins and shows the Loading03 working spinner"
>
<SidebarStage>
<WorkflowAndRuntimeActiveThreadRow />
Expand Down
96 changes: 54 additions & 42 deletions apps/app/src/components/sidebar/ThreadRow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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([
Expand Down Expand Up @@ -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",
Expand All @@ -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();
Expand Down Expand Up @@ -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({
Expand Down
1 change: 1 addition & 0 deletions apps/app/src/components/sidebar/ThreadRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,7 @@ function ThreadTrailingIndicator({
const indicatorKind = resolveThreadListIndicator(statusProps);
const pluginStatusIsVisible =
pluginStatus !== null &&
indicatorKind !== "runtime" &&
indicatorKind !== "unread-error" &&
indicatorKind !== "waiting-for-input";

Expand Down
41 changes: 32 additions & 9 deletions apps/app/src/lib/thread-activity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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({
Expand Down
9 changes: 5 additions & 4 deletions apps/app/src/lib/thread-activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,23 +116,24 @@ 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 ||
state.isPlanModeActive ||
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";
Expand Down
Loading
Loading