diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index c6e113a44523..1268b0d60d4a 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -19,6 +19,7 @@ import { resolveSidebarStageBadgeLabel, resolveThreadRowClassName, resolveSidebarThreadStatus, + resolveSidebarThreadTopStatusKind, resolveThreadStatusPill, resolveWorkingStartedAt, searchSidebarThreadsByTitle, @@ -730,11 +731,89 @@ describe("resolveSidebarThreadStatus", () => { ).toBe("ready"); }); + it("keeps pending input and approvals ahead of a live watch loop", () => { + expect( + resolveSidebarThreadStatus({ + ...idle, + hasPendingUserInput: true, + session: null, + backgroundLiveness: "monitoring", + }), + ).toBe("input"); + expect( + resolveSidebarThreadStatus({ + ...idle, + hasPendingApprovals: true, + session: null, + backgroundLiveness: "monitoring", + }), + ).toBe("approval"); + }); + + it("reports a live watch loop only when nothing else is happening", () => { + expect( + resolveSidebarThreadStatus({ ...idle, session: null, backgroundLiveness: "monitoring" }), + ).toBe("monitoring"); + expect( + resolveSidebarThreadStatus({ ...idle, session: null, backgroundLiveness: "working" }), + ).toBe("working"); + }); + it("defaults to ready with no session", () => { expect(resolveSidebarThreadStatus({ ...idle, session: null })).toBe("ready"); }); }); +describe("resolveSidebarThreadTopStatusKind", () => { + const quiet = { status: "ready" as const, isWoke: false, isUnread: false }; + + it("shows an unread completion instead of monitoring, so a watch loop cannot mask it", () => { + expect( + resolveSidebarThreadTopStatusKind({ ...quiet, status: "monitoring", isUnread: true }), + ).toBe("done"); + }); + + it("shows a wake instead of monitoring", () => { + expect( + resolveSidebarThreadTopStatusKind({ ...quiet, status: "monitoring", isWoke: true }), + ).toBe("woke"); + }); + + it("keeps monitoring once the completion has been read", () => { + expect(resolveSidebarThreadTopStatusKind({ ...quiet, status: "monitoring" })).toBe( + "monitoring", + ); + }); + + it("keeps working ahead of an unread completion: background work is still in flight", () => { + expect(resolveSidebarThreadTopStatusKind({ ...quiet, status: "working", isUnread: true })).toBe( + "working", + ); + }); + + it("keeps approval, input, and failure ahead of an unread completion", () => { + expect( + resolveSidebarThreadTopStatusKind({ ...quiet, status: "approval", isUnread: true }), + ).toBe("approval"); + expect(resolveSidebarThreadTopStatusKind({ ...quiet, status: "input", isUnread: true })).toBe( + "input", + ); + expect(resolveSidebarThreadTopStatusKind({ ...quiet, status: "failed", isUnread: true })).toBe( + "failed", + ); + }); + + it("prefers a wake over an unread completion on a settled thread", () => { + expect(resolveSidebarThreadTopStatusKind({ ...quiet, isWoke: true, isUnread: true })).toBe( + "woke", + ); + }); + + it("returns null for a read, settled thread with nothing live", () => { + expect(resolveSidebarThreadTopStatusKind(quiet)).toBeNull(); + }); +}); + describe("searchSidebarThreadsByTitle", () => { const threads = [ { id: "thread-1", title: "Fix workspace search", project: "Alpha" }, @@ -1097,6 +1176,27 @@ describe("resolveThreadStatusPill", () => { ).toMatchObject({ label: "Working", pulse: true }); }); + it("shows an unread completion ahead of monitoring, then monitoring once it is read", () => { + const settledWithWatchLoop = { + ...baseThread, + session: { ...baseThread.session, status: "idle" as const, activeTurnId: null as never }, + latestTurn: { completedAt: "2026-03-09T12:00:00.000Z" } as never, + backgroundLiveness: "monitoring" as const, + }; + + expect( + resolveThreadStatusPill({ + thread: { ...settledWithWatchLoop, lastVisitedAt: "2026-03-09T11:00:00.000Z" }, + }), + ).toMatchObject({ label: "Completed" }); + + expect( + resolveThreadStatusPill({ + thread: { ...settledWithWatchLoop, lastVisitedAt: "2026-03-09T13:00:00.000Z" }, + }), + ).toMatchObject({ label: "Monitoring" }); + }); + it("shows plan ready when a settled plan turn has a proposed plan ready for follow-up", () => { expect( resolveThreadStatusPill({ @@ -1201,6 +1301,25 @@ describe("resolveProjectStatusIndicator", () => { ).toMatchObject({ label: "Pending Approval", dotClass: "bg-amber-500" }); }); + it("does not let a monitoring sibling hide a completed thread", () => { + expect( + resolveProjectStatusIndicator([ + { + label: "Monitoring", + colorClass: "text-sky-600", + dotClass: "bg-sky-500", + pulse: false, + }, + { + label: "Completed", + colorClass: "text-emerald-600", + dotClass: "bg-emerald-500", + pulse: false, + }, + ]), + ).toMatchObject({ label: "Completed" }); + }); + it("prefers plan-ready over completed when no stronger action is needed", () => { expect( resolveProjectStatusIndicator([ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 9cb09219df09..aaa486a0f4df 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -132,16 +132,17 @@ export interface ThreadStatusPill { } // Rollup order mirrors the per-thread resolver exactly: attention states, -// then active work, then the actionable plan prompt, then passive -// monitoring. A Monitoring sibling must never hide a Plan Ready thread. +// then active work, then the actionable plan prompt, then an unread +// completion, then passive monitoring. A Monitoring sibling must never hide +// a Plan Ready or Completed thread. const THREAD_STATUS_PRIORITY: Record = { "Pending Approval": 6, "Awaiting Input": 5, Working: 4, Connecting: 4, "Plan Ready": 3, - Monitoring: 2, - Completed: 1, + Completed: 2, + Monitoring: 1, }; type ThreadStatusInput = Pick< @@ -498,6 +499,42 @@ export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): Si return "ready"; } +/** Which signal the sidebar row's top-right label shows, highest priority first. */ +export type SidebarThreadTopStatusKind = + | "approval" + | "input" + | "failed" + | "working" + | "woke" + | "done" + | "monitoring" + | null; + +/** + * Attention outranks passive monitoring. `backgroundLiveness: "monitoring"` + * means watch loops (a long-running shell) are the ONLY live work, so the + * turn has already settled — an unseen completion or a wake is exactly the + * news the user is waiting for, and monitoring would otherwise mask it for + * as long as the shell lives. `working` still wins: real background work is + * in flight, so the thread is not done yet. Monitoring re-appears once the + * user has read the completion. + */ +export function resolveSidebarThreadTopStatusKind(input: { + status: SidebarThreadStatus; + isWoke: boolean; + isUnread: boolean; +}): SidebarThreadTopStatusKind { + const { isUnread, isWoke, status } = input; + if (status === "approval") return "approval"; + if (status === "input") return "input"; + if (status === "failed") return "failed"; + if (status === "working") return "working"; + if (isWoke) return "woke"; + if (isUnread) return "done"; + if (status === "monitoring") return "monitoring"; + return null; +} + /** NaN-safe Date.parse for sort comparators: a malformed timestamp must not poison the whole ordering, so it sinks to the epoch instead. */ export function parseTimestampMs(isoDate: string): number { @@ -705,20 +742,23 @@ export function resolveThreadStatusPill(input: { }; } - if (thread.backgroundLiveness === "monitoring") { + // Monitoring means watch loops are the only live work, so the turn has + // settled: an unseen completion is the news, and monitoring would mask it + // for as long as the watch loop lives. Monitoring returns once it is read. + if (hasUnseenCompletion(thread)) { return { - label: "Monitoring", - colorClass: "text-sky-600 dark:text-sky-300/80", - dotClass: "bg-sky-500 dark:bg-sky-300/80", + label: "Completed", + colorClass: "text-emerald-600 dark:text-emerald-300/90", + dotClass: "bg-emerald-500 dark:bg-emerald-300/90", pulse: false, }; } - if (hasUnseenCompletion(thread)) { + if (thread.backgroundLiveness === "monitoring") { return { - label: "Completed", - colorClass: "text-emerald-600 dark:text-emerald-300/90", - dotClass: "bg-emerald-500 dark:bg-emerald-300/90", + label: "Monitoring", + colorClass: "text-sky-600 dark:text-sky-300/80", + dotClass: "bg-sky-500 dark:bg-sky-300/80", pulse: false, }; } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 060cdd47b59a..926b31e0c45f 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -132,6 +132,7 @@ import { resolveAdjacentThreadId, resolveSettledTimestamp, resolveSidebarThreadStatus, + resolveSidebarThreadTopStatusKind, searchSidebarThreadsByTitle, shouldCreateNewThreadInCurrentProject, resolveWorkingStartedAt, @@ -836,8 +837,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // Status hues follow the system-wide convention set by sidebar v1 and the // mobile Live Activity/widgets (amber approval, indigo input, sky working) // so a thread reads the same color everywhere it surfaces. + const topStatusKind = resolveSidebarThreadTopStatusKind({ status, isWoke, isUnread }); const topStatus = - status === "working" + topStatusKind === "working" ? { label: "Working", icon: "working" as const, @@ -848,7 +850,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // the label at full strength. className: cn("text-sky-600 dark:text-sky-400", !props.isActive && "opacity-75"), } - : status === "monitoring" + : topStatusKind === "monitoring" ? { // Monitoring is calm background presence, not active progress // (monitoring-pill D6), so it keeps the label at full strength. @@ -856,31 +858,31 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { icon: null, className: "text-sky-600 dark:text-sky-400", } - : status === "approval" + : topStatusKind === "approval" ? { label: "Approval", icon: null, className: "text-amber-700 dark:text-amber-300", } - : status === "input" + : topStatusKind === "input" ? { label: "Input", icon: null, className: "text-indigo-600 dark:text-indigo-300", } - : status === "failed" + : topStatusKind === "failed" ? { label: "Failed", icon: null, className: "text-red-700 dark:text-red-300", } - : isWoke + : topStatusKind === "woke" ? { label: "Woke", icon: "woke" as const, className: "text-amber-700 dark:text-amber-300", } - : isUnread + : topStatusKind === "done" ? { label: "Done", icon: "done" as const,