diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index e4fa68a65201b..e51ceae551ba6 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -15,6 +15,7 @@ import { pinOrderKeyBetween, resolveProjectStatusIndicator, resolveSidebarSweepKeys, + resolveSidebarRowStatus, resolveSidebarThreadStatus, resolveThreadStatusPill, resolveWorkingStartedAt, @@ -339,6 +340,55 @@ describe("resolveSidebarThreadStatus", () => { }); }); +describe("resolveSidebarRowStatus", () => { + const failedRuntime = { + status: "failed" as const, + activeRunId: null, + providerInstanceId: ProviderInstanceId.make("codex"), + providerName: "Codex", + lastError: "Provider turn failed.", + updatedAt: "2026-03-09T10:05:00.000Z", + }; + const failed = { + hasActionableProposedPlan: false, + hasPendingApprovals: false, + hasPendingUserInput: false, + interactionMode: "default" as const, + runtime: failedRuntime, + latestRun: { ...makeLatestRun(), status: "failed" as const }, + }; + + it("clears a failure once opened, without showing Done, and shows the next failure", () => { + expect(resolveSidebarRowStatus({ ...failed, lastVisitedAt: undefined })).toBe("failed"); + + const opened = { ...failed, lastVisitedAt: "2026-03-09T10:06:00.000Z" }; + expect(resolveSidebarRowStatus(opened)).toBe("ready"); + expect(hasUnseenCompletion(opened)).toBe(false); + + const failedAgain = { + ...opened, + latestRun: { ...opened.latestRun, completedAt: "2026-03-09T10:10:00.000Z" }, + }; + expect(resolveSidebarRowStatus(failedAgain)).toBe("failed"); + }); + + it("dates a failure without a completed run by the runtime update", () => { + const visitedAfter = "2026-03-09T10:06:00.000Z"; + const visitedBefore = "2026-03-09T10:00:00.000Z"; + const noCompletedAt = { + ...failed, + latestRun: { ...failed.latestRun, completedAt: null }, + }; + expect(resolveSidebarRowStatus({ ...noCompletedAt, lastVisitedAt: visitedAfter })).toBe( + "ready", + ); + + const noRun = { ...failed, latestRun: null }; + expect(resolveSidebarRowStatus({ ...noRun, lastVisitedAt: visitedBefore })).toBe("failed"); + expect(resolveSidebarRowStatus({ ...noRun, lastVisitedAt: visitedAfter })).toBe("ready"); + }); +}); + describe("searchSidebarThreads", () => { const searchThread = (id: string, title: string, latestUserMessageAt: string) => ({ environmentId: localEnvironmentId, @@ -1445,5 +1495,11 @@ describe("resolveSidebarFolderDot", () => { expect(resolveSidebarFolderDot([idle, working])).toBe("working"); expect(resolveSidebarFolderDot([working, unread])).toBe("unread"); expect(resolveSidebarFolderDot([unread, limited])).toBe("attention"); + // A failure the user already opened clears, like its row. + const failed = { ...idle, runtime: { ...runtime, status: "failed" as const } }; + expect(resolveSidebarFolderDot([failed])).toBeNull(); + expect( + resolveSidebarFolderDot([{ ...failed, lastVisitedAt: "2026-03-09T09:00:00.000Z" }]), + ).toBe("failed"); }); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index efd8244f8c2b2..966601616825b 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -697,13 +697,22 @@ export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { } /** Unopened threads' failures still count as unseen, unlike completions. */ -function hasUnseenFailure(thread: ThreadStatusInput): boolean { - if (thread.latestRun?.status !== "failed") return false; +function hasUnseenFailure( + thread: Pick, +): boolean { + // Failed runs can lack completedAt, and a session can fail before any run. + const failedAt = + thread.latestRun?.status === "failed" + ? (thread.latestRun.completedAt ?? thread.runtime?.updatedAt) + : thread.runtime?.status === "failed" + ? thread.runtime.updatedAt + : undefined; + if (failedAt === undefined) return false; if (!thread.lastVisitedAt) return true; - const completedAt = Date.parse(thread.latestRun.completedAt ?? ""); - const lastVisitedAt = Date.parse(thread.lastVisitedAt); - if (Number.isNaN(completedAt) || Number.isNaN(lastVisitedAt)) return true; - return completedAt > lastVisitedAt; + const failedAtMs = Date.parse(failedAt ?? ""); + const lastVisitedAtMs = Date.parse(thread.lastVisitedAt); + if (Number.isNaN(failedAtMs) || Number.isNaN(lastVisitedAtMs)) return true; + return failedAtMs > lastVisitedAtMs; } // Suppress trailing double-click navigation; keyboard and synthetic clicks use detail 0. @@ -872,6 +881,14 @@ export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): Si return "ready"; } +/** Sidebar row status: a failure the user has already opened settles to ready, like Done. */ +export function resolveSidebarRowStatus( + thread: SidebarThreadStatusInput & Pick, +): SidebarThreadStatus { + const status = resolveSidebarThreadStatus(thread); + return status === "failed" && !hasUnseenFailure(thread) ? "ready" : status; +} + /** * The dot on a closed boring-sidebar folder: the most urgent state among its * hidden threads. Waiting threads stay quiet, like their rows. @@ -879,7 +896,7 @@ export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): Si export function resolveSidebarFolderDot( threads: ReadonlyArray, ): "attention" | "failed" | "unread" | "working" | null { - const statuses = new Set(threads.map(resolveSidebarThreadStatus)); + const statuses = new Set(threads.map(resolveSidebarRowStatus)); if ( statuses.has("input") || statuses.has("approval") || @@ -891,7 +908,7 @@ export function resolveSidebarFolderDot( if (statuses.has("failed")) return "failed"; // Rows show Done only for ready threads, so the dot follows the same rule. const hasUnread = threads.some( - (thread) => resolveSidebarThreadStatus(thread) === "ready" && hasUnseenCompletion(thread), + (thread) => resolveSidebarRowStatus(thread) === "ready" && hasUnseenCompletion(thread), ); if (hasUnread) return "unread"; if (statuses.has("working")) return "working"; diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index eac7c8591fa1c..28431c0059970 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -79,7 +79,7 @@ import { hasUnseenCompletion, resolveSidebarRowAccessibility, type SidebarDropVerb, - resolveSidebarThreadStatus, + resolveSidebarRowStatus, resolveThreadLastVisitedAt, shouldRecedeSidebarThread, resolveWorkingStartedAt, @@ -705,7 +705,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP : null; const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); - const status = resolveSidebarThreadStatus(thread); + const status = resolveSidebarRowStatus({ ...thread, lastVisitedAt }); const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt); const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt); const isWoke =