From e65f5a34970c292005a44abe25aa2d77927cd146 Mon Sep 17 00:00:00 2001 From: Bilal Irfan Date: Sun, 1 Nov 2026 15:59:25 +0500 Subject: [PATCH 1/4] fix(web): clear failed badges when threads are opened Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/Sidebar.logic.test.ts | 56 +++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 33 ++++++++--- .../components/sidebar/SidebarThreadRow.tsx | 4 +- 3 files changed, 83 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index fde82414afa79..b2d839b09fe6e 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -18,6 +18,7 @@ import { pinOrderKeyBetween, resolveProjectStatusIndicator, resolveSidebarSweepKeys, + resolveSidebarRowStatus, resolveSidebarThreadStatus, resolveThreadStatusPill, resolveWorkingStartedAt, @@ -348,6 +349,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, @@ -1732,6 +1782,12 @@ 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 0f51d231d333b..cb4d03b252034 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -775,13 +775,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. @@ -952,6 +961,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. @@ -959,7 +976,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") || @@ -971,7 +988,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 fc47fd8e21adc..ce0cc7f7cea8c 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, @@ -707,7 +707,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 = From 13889f09d8d4d4d6425a2a13496ddeaf0c02328a Mon Sep 17 00:00:00 2001 From: Bilal Irfan Date: Sun, 1 Nov 2026 16:20:34 +0500 Subject: [PATCH 2/4] fix(web): renaming a thread no longer brings back a failure you already saw Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/Sidebar.logic.test.ts | 14 ++++++++------ apps/web/src/components/Sidebar.logic.ts | 11 ++++++----- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index b2d839b09fe6e..a9adb77405c58 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -381,20 +381,18 @@ describe("resolveSidebarRowStatus", () => { expect(resolveSidebarRowStatus(failedAgain)).toBe("failed"); }); - it("dates a failure without a completed run by the runtime update", () => { + it("keeps an undated failure unseen, so later thread updates cannot revive or hide it", () => { 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", + "failed", ); const noRun = { ...failed, latestRun: null }; - expect(resolveSidebarRowStatus({ ...noRun, lastVisitedAt: visitedBefore })).toBe("failed"); - expect(resolveSidebarRowStatus({ ...noRun, lastVisitedAt: visitedAfter })).toBe("ready"); + expect(resolveSidebarRowStatus({ ...noRun, lastVisitedAt: visitedAfter })).toBe("failed"); }); }); @@ -1783,7 +1781,11 @@ describe("resolveSidebarFolderDot", () => { 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 } }; + const failed = { + ...idle, + latestRun: { ...idle.latestRun, status: "failed" as const }, + runtime: { ...runtime, status: "failed" as const }, + }; expect(resolveSidebarFolderDot([failed])).toBeNull(); expect( resolveSidebarFolderDot([{ ...failed, lastVisitedAt: "2026-03-09T09:00:00.000Z" }]), diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index cb4d03b252034..53c25afdffe94 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -778,16 +778,17 @@ export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { function hasUnseenFailure( thread: Pick, ): boolean { - // Failed runs can lack completedAt, and a session can fail before any run. + // Only a run's completion dates a failure: runtime.updatedAt also moves on renames and + // branch changes. An undated failure, such as a session failing before any run, stays unseen. const failedAt = thread.latestRun?.status === "failed" - ? (thread.latestRun.completedAt ?? thread.runtime?.updatedAt) + ? thread.latestRun.completedAt : thread.runtime?.status === "failed" - ? thread.runtime.updatedAt + ? null : undefined; if (failedAt === undefined) return false; - if (!thread.lastVisitedAt) return true; - const failedAtMs = Date.parse(failedAt ?? ""); + if (failedAt === null || !thread.lastVisitedAt) return true; + const failedAtMs = Date.parse(failedAt); const lastVisitedAtMs = Date.parse(thread.lastVisitedAt); if (Number.isNaN(failedAtMs) || Number.isNaN(lastVisitedAtMs)) return true; return failedAtMs > lastVisitedAtMs; From a0c76da121fdabf56b440482ae5ac8d50eb0d016 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 12:45:36 +0000 Subject: [PATCH 3/4] fix(web): keep the Failed status pill unchanged while acknowledging sidebar failures hasUnseenFailure also drives resolveThreadStatusPill, so widening it to runtime-only failures would add a Failed pill outside the sidebar. Keep it as on main and handle the undated case in resolveSidebarRowStatus. Drop the folder-dot assertion that repeated the row-status case. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/Sidebar.logic.test.ts | 10 ------ apps/web/src/components/Sidebar.logic.ts | 31 +++++++------------ 2 files changed, 12 insertions(+), 29 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index c3800e07bd7e4..e4870327b114e 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1705,16 +1705,6 @@ 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, - latestRun: { ...idle.latestRun, status: "failed" as const }, - 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 d511645c406e7..1f0da9bf83687 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -808,23 +808,13 @@ export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { } /** Unopened threads' failures still count as unseen, unlike completions. */ -function hasUnseenFailure( - thread: Pick, -): boolean { - // Only a run's completion dates a failure: runtime.updatedAt also moves on renames and - // branch changes. An undated failure, such as a session failing before any run, stays unseen. - const failedAt = - thread.latestRun?.status === "failed" - ? thread.latestRun.completedAt - : thread.runtime?.status === "failed" - ? null - : undefined; - if (failedAt === undefined) return false; - if (failedAt === null || !thread.lastVisitedAt) return true; - const failedAtMs = Date.parse(failedAt); - const lastVisitedAtMs = Date.parse(thread.lastVisitedAt); - if (Number.isNaN(failedAtMs) || Number.isNaN(lastVisitedAtMs)) return true; - return failedAtMs > lastVisitedAtMs; +function hasUnseenFailure(thread: ThreadStatusInput): boolean { + if (thread.latestRun?.status !== "failed") 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; } // Suppress trailing double-click navigation; keyboard and synthetic clicks use detail 0. @@ -1003,10 +993,13 @@ export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): Si /** Sidebar row status: a failure the user has already opened settles to ready, like Done. */ export function resolveSidebarRowStatus( - thread: SidebarThreadStatusInput & Pick, + thread: ThreadStatusInput & SidebarThreadStatusInput, ): SidebarThreadStatus { const status = resolveSidebarThreadStatus(thread); - return status === "failed" && !hasUnseenFailure(thread) ? "ready" : status; + // Only a failed run's completion dates a failure; runtime.updatedAt also moves on renames. + // A runtime failure with no failed run, such as a session failing before any run, stays. + if (status !== "failed" || thread.latestRun?.status !== "failed") return status; + return hasUnseenFailure(thread) ? status : "ready"; } /** What the window title says the open thread is doing, in the words its announcer uses. */ From 6b2e600e2401fd523f89dae4727e2d9627c1f017 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 13:04:27 +0000 Subject: [PATCH 4/4] fix(mobile): opened failures clear on mobile rows and undated failures too Move the seen-failure rule to client-runtime (hasUnseenRunFailure) so the web sidebar and the mobile thread list share it. A failed run without a completion time is dated by its start, which renames cannot move, so opening it clears its Failed badge. Mobile rows now settle an opened failure to ready like the web sidebar, while the inline error and raw status stay as they were. Co-Authored-By: Claude Opus 5.5 --- .../features/threads/thread-list-v2-items.tsx | 6 ++-- .../src/features/threads/threadListV2.test.ts | 17 +++++++++++ .../src/features/threads/threadListV2.ts | 18 ++++++++++-- apps/web/src/components/Sidebar.logic.test.ts | 24 ++-------------- apps/web/src/components/Sidebar.logic.ts | 18 +++--------- .../client-runtime/src/state/threadSettled.ts | 19 +++++++++++++ .../src/state/threadSnoozed.test.ts | 28 ++++++++++++++++++- 7 files changed, 90 insertions(+), 40 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index b6bf0c98e349c..69d0c84ecaa8d 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -79,6 +79,7 @@ import { resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozePresets, threadHasUnseenCompletion, + resolveThreadListV2RowStatus, resolveThreadListV2Status, resolveThreadListV2ProviderDrivers, resolveThreadListV2SwipeActions, @@ -772,7 +773,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const selected = props.selected === true; const rowAppearance = getThreadListV2RowAppearance(theme, sidebarPane, selected); - const status = resolveThreadListV2Status(thread); + const rawStatus = resolveThreadListV2Status(thread); + const status = resolveThreadListV2RowStatus(thread); const isUnread = status === "ready" && threadHasUnseenCompletion(thread); const workingLabel = STATUS_LABEL_BY_STATUS[status]; const showsWoke = status === "ready" && props.wokeAt !== null; @@ -1298,7 +1300,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ) : null} - {(status === "failed" || status === "limited") && thread.runtime?.lastError ? ( + {(rawStatus === "failed" || rawStatus === "limited") && thread.runtime?.lastError ? ( { ); }); + it("settles a failure the user opened to ready in the row, keeping the raw status", () => { + const failed = presentThreadShell( + environmentId, + makeRawThreadShell({ + status: "failed", + latestRunId: RunId.make("run-failed"), + latestRunCompletedAt: DateTime.makeUnsafe("2026-06-01T23:59:00.000Z"), + lastVisitedAt: DateTime.makeUnsafe(NOW), + }), + ); + + expect(resolveThreadListV2Status(failed)).toBe("failed"); + expect(resolveThreadListV2RowStatus(failed)).toBe("ready"); + expect(resolveThreadListV2RowStatus({ ...failed, lastVisitedAt: null })).toBe("failed"); + }); + it("shows a waiting guest message as Request, after the agent's own requests", () => { const thread = presentThreadShell( environmentId, diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 7854f9d7fdb85..f4dd34cefd5c0 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -8,7 +8,10 @@ import { snoozeWakeLabel, threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; -import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; +import { + hasUnseenRunFailure, + type SnoozePreset, +} from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { resolveThreadProviderStack } from "@t3tools/client-runtime/state/models"; import { @@ -296,6 +299,17 @@ export function resolveThreadListV2Status( return "ready"; } +/** Row status: a failure the user has already opened settles to ready, like Done. */ +export function resolveThreadListV2RowStatus( + thread: Parameters[0] & + Pick, +): ThreadListV2Status { + const status = resolveThreadListV2Status(thread); + // A runtime failure without a failed run has nothing to date it, so it stays. + if (status !== "failed" || thread.latestRun?.status !== "failed") return status; + return hasUnseenRunFailure(thread) ? status : "ready"; +} + function parseTimestampMs(isoDate: string): number { const parsed = Date.parse(isoDate); return Number.isNaN(parsed) ? 0 : parsed; @@ -506,7 +520,7 @@ function resolveThreadListV2ItemTimeLabel( if (showSnoozeWakeLabel) return ""; if ( variant === "card" && - (resolveThreadListV2Status(thread) !== "ready" || + (resolveThreadListV2RowStatus(thread) !== "ready" || item.wokeAt !== null || threadHasUnseenCompletion(thread)) ) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index e4870327b114e..b89aa1621a0fa 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -350,32 +350,14 @@ describe("resolveSidebarRowStatus", () => { latestRun: { ...makeLatestRun(), status: "failed" as const }, }; - it("clears a failure once opened, without showing Done, and shows the next failure", () => { + it("settles a failure the user opened to ready, without showing Done", () => { 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("keeps an undated failure unseen, so later thread updates cannot revive or hide it", () => { - const visitedAfter = "2026-03-09T10:06:00.000Z"; - const noCompletedAt = { - ...failed, - latestRun: { ...failed.latestRun, completedAt: null }, - }; - expect(resolveSidebarRowStatus({ ...noCompletedAt, lastVisitedAt: visitedAfter })).toBe( - "failed", - ); - - const noRun = { ...failed, latestRun: null }; - expect(resolveSidebarRowStatus({ ...noRun, lastVisitedAt: visitedAfter })).toBe("failed"); + // A runtime failure without a failed run has no date, so a visit cannot settle it. + expect(resolveSidebarRowStatus({ ...opened, latestRun: null })).toBe("failed"); }); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 1f0da9bf83687..7c96c89d30289 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -28,6 +28,7 @@ import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/c import type { AsyncResult } from "effect/reactivity"; import { effectiveSnoozed, + hasUnseenRunFailure, type ThreadSnoozeShell, } from "@t3tools/client-runtime/state/thread-settled"; import { @@ -807,16 +808,6 @@ export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { return completedAt > lastVisitedAt; } -/** Unopened threads' failures still count as unseen, unlike completions. */ -function hasUnseenFailure(thread: ThreadStatusInput): boolean { - if (thread.latestRun?.status !== "failed") 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; -} - // Suppress trailing double-click navigation; keyboard and synthetic clicks use detail 0. export function isTrailingDoubleClick(detail: number): boolean { return detail > 1; @@ -996,10 +987,9 @@ export function resolveSidebarRowStatus( thread: ThreadStatusInput & SidebarThreadStatusInput, ): SidebarThreadStatus { const status = resolveSidebarThreadStatus(thread); - // Only a failed run's completion dates a failure; runtime.updatedAt also moves on renames. - // A runtime failure with no failed run, such as a session failing before any run, stays. + // A runtime failure without a failed run has nothing to date it, so it stays. if (status !== "failed" || thread.latestRun?.status !== "failed") return status; - return hasUnseenFailure(thread) ? status : "ready"; + return hasUnseenRunFailure(thread) ? status : "ready"; } /** What the window title says the open thread is doing, in the words its announcer uses. */ @@ -1288,7 +1278,7 @@ export function resolveThreadStatusPill(input: { }; } - if (hasUnseenFailure(thread)) { + if (hasUnseenRunFailure(thread)) { return { label: "Failed", colorClass: "text-red-600 dark:text-red-300/90", diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index c247f9d80489f..cc18de109de18 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -411,3 +411,22 @@ export function isThreadWokeUnseen(input: { .filter((ms) => !Number.isNaN(ms)); return seenAt.every((ms) => ms < wokeAtMs); } + +/** + * Whether the latest run failed after the user last saw the thread. Only the run dates the + * failure, since the shell's updatedAt also moves on renames. A failed run without a + * completion time, from older data, is dated by its start. + */ +export function hasUnseenRunFailure( + thread: Pick & { + readonly lastVisitedAt?: string | null | undefined; + }, +): boolean { + const run = thread.latestRun; + if (run?.status !== "failed") return false; + if (!thread.lastVisitedAt) return true; + const failedAtMs = Date.parse(run.completedAt ?? run.startedAt ?? run.requestedAt ?? ""); + const lastVisitedAtMs = Date.parse(thread.lastVisitedAt); + if (Number.isNaN(failedAtMs) || Number.isNaN(lastVisitedAtMs)) return true; + return failedAtMs > lastVisitedAtMs; +} diff --git a/packages/client-runtime/src/state/threadSnoozed.test.ts b/packages/client-runtime/src/state/threadSnoozed.test.ts index 3abc4dbb39848..e0b4c630b1c87 100644 --- a/packages/client-runtime/src/state/threadSnoozed.test.ts +++ b/packages/client-runtime/src/state/threadSnoozed.test.ts @@ -1,6 +1,6 @@ // @effect-diagnostics globalDate:off -- Tests exercise local calendar snooze boundaries. import * as DateTime from "effect/DateTime"; -import { RuntimeRequestId } from "@t3tools/contracts"; +import { RunId, RuntimeRequestId } from "@t3tools/contracts"; import { ThreadId } from "@t3tools/contracts"; import { TurnId } from "@t3tools/contracts"; import type { SnoozePresetSetting } from "@t3tools/contracts/settings"; @@ -10,6 +10,7 @@ import { canSnooze, effectiveSnoozed, hasQueuedTurnStart, + hasUnseenRunFailure, pickSnoozePreset, resolveSnoozePresets, threadRaisedHandWhileSnoozed, @@ -214,6 +215,31 @@ describe("effectiveSnoozed", () => { }); }); +describe("hasUnseenRunFailure", () => { + it("dates a failure by its run, falling back to the run's start when it never completed", () => { + const run = { + runId: RunId.make("run-failed"), + status: "failed" as const, + requestedAt: "2026-04-10T09:00:00.000Z", + startedAt: "2026-04-10T09:01:00.000Z", + completedAt: "2026-04-10T09:05:00.000Z", + assistantMessageId: null, + }; + const visitedAt = "2026-04-10T09:06:00.000Z"; + + expect(hasUnseenRunFailure({ latestRun: run, lastVisitedAt: null })).toBe(true); + expect(hasUnseenRunFailure({ latestRun: run, lastVisitedAt: visitedAt })).toBe(false); + const failedAgain = { ...run, completedAt: "2026-04-10T09:10:00.000Z" }; + expect(hasUnseenRunFailure({ latestRun: failedAgain, lastVisitedAt: visitedAt })).toBe(true); + + const undated = { ...run, completedAt: null }; + expect(hasUnseenRunFailure({ latestRun: undated, lastVisitedAt: visitedAt })).toBe(false); + expect( + hasUnseenRunFailure({ latestRun: undated, lastVisitedAt: "2026-04-10T09:00:30.000Z" }), + ).toBe(true); + }); +}); + describe("threadRaisedHandWhileSnoozed", () => { it("is true for approvals, input, and failures", () => { expect(