From da33cd2d22074eaf78027a61648203e0bc111555 Mon Sep 17 00:00:00 2001 From: Bilal Irfan Date: Tue, 27 Oct 2026 03:03:25 +0500 Subject: [PATCH] fix(web): clear failed badges when threads are opened Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/Sidebar.logic.test.ts | 50 +++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 29 ++++++++--- .../components/sidebar/SidebarThreadRow.tsx | 4 +- 3 files changed, 75 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index a66540ff4fdf3..d0373cfb06c97 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, resolveSidebarV2TopStatus, resolveThreadStatusPill, @@ -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, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 475ca41271143..bf8a9fadb3135 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -698,13 +698,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; } export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null): boolean { @@ -894,6 +903,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; +} + export type SidebarV2TopStatusKind = | "approval" | "done" diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index 8837d47ae0eec..179dbbcc5c7be 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -78,7 +78,7 @@ import { hasUnseenCompletion, resolveSidebarRowAccessibility, type SidebarDropVerb, - resolveSidebarThreadStatus, + resolveSidebarRowStatus, resolveThreadLastVisitedAt, shouldRecedeSidebarThread, resolveWorkingStartedAt, @@ -682,7 +682,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 =