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 d1d045ed41a35..b89aa1621a0fa 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -14,6 +14,7 @@ import { orderItemsByPreferredIds, pinOrderKeyBetween, resolveSidebarSweepKeys, + resolveSidebarRowStatus, resolveSidebarThreadStatus, resolveThreadStatusPill, resolveWindowTitleThreadStatus, @@ -331,6 +332,35 @@ 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("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); + // A runtime failure without a failed run has no date, so a visit cannot settle it. + expect(resolveSidebarRowStatus({ ...opened, latestRun: null })).toBe("failed"); + }); +}); + 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 7f60c1d35215a..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; @@ -991,6 +982,16 @@ 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: ThreadStatusInput & SidebarThreadStatusInput, +): SidebarThreadStatus { + const status = resolveSidebarThreadStatus(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"; +} + /** What the window title says the open thread is doing, in the words its announcer uses. */ export function resolveWindowTitleThreadStatus(thread: SidebarThreadStatusInput): string | null { // A guest waiting doesn't hide that the agent is still working. @@ -1017,7 +1018,7 @@ export function resolveWindowTitleThreadStatus(thread: SidebarThreadStatusInput) 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") || @@ -1029,7 +1030,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") || statuses.has("preparing")) return "working"; @@ -1277,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/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index db896481cf857..5b32e2d949f13 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -86,7 +86,7 @@ import { hasUnseenCompletion, resolveSidebarRowAccessibility, type SidebarDropVerb, - resolveSidebarThreadStatus, + resolveSidebarRowStatus, resolveThreadLastVisitedAt, shouldRecedeSidebarThread, resolveWorkingStartedAt, @@ -731,7 +731,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 = 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(