From 87fc95c554807cd8f4cd5d8f134fd5bc6ac54b8d Mon Sep 17 00:00:00 2001 From: BinBandit Date: Wed, 11 Mar 2026 19:41:02 +1100 Subject: [PATCH] Add unread error state for thread prefixes --- apps/web/src/components/ChatView.tsx | 8 ++ apps/web/src/components/Sidebar.logic.test.ts | 92 ++++++++++++++++++- apps/web/src/components/Sidebar.logic.ts | 25 ++--- apps/web/src/thread-status.ts | 56 +++++++++++ 4 files changed, 167 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/thread-status.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3c8a0a152..15c3a178f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -80,6 +80,7 @@ import { formatElapsed, formatTimestamp, } from "../session-logic"; +import { hasUnseenError } from "../thread-status"; import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX, isScrollContainerNearBottom } from "../chat-scroll"; import { buildPendingUserInputAnswers, @@ -863,6 +864,13 @@ export default function ChatView({ threadId }: ChatViewProps) { markThreadVisited, ]); + useEffect(() => { + if (!activeThread?.id) return; + if (!hasUnseenError(activeThread)) return; + + markThreadVisited(activeThread.id); + }, [activeThread, markThreadVisited]); + const sessionProvider = activeThread?.session?.provider ?? null; const selectedProviderByThreadId = composerDraft.provider; const hasThreadStarted = Boolean( diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 634d91e9d..3d6d421d8 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { hasUnseenCompletion, + hasUnseenError, resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown, } from "./Sidebar.logic"; @@ -24,14 +25,69 @@ describe("hasUnseenCompletion", () => { it("returns true when a thread completed after its last visit", () => { expect( hasUnseenCompletion({ - interactionMode: "default", latestTurn: makeLatestTurn(), lastVisitedAt: "2026-03-09T10:04:00.000Z", - proposedPlans: [], + }), + ).toBe(true); + }); +}); + +describe("hasUnseenError", () => { + it("returns true when an error activity happened after the last visit", () => { + expect( + hasUnseenError({ + activities: [ + { + id: "activity-1" as never, + tone: "error", + kind: "checkpoint.capture.failed", + summary: "Checkpoint capture failed", + payload: {}, + turnId: null, + createdAt: "2026-03-09T10:05:00.000Z", + }, + ], + latestTurn: null, + lastVisitedAt: "2026-03-09T10:04:00.000Z", session: null, }), ).toBe(true); }); + + it("returns false when all error signals predate the last visit", () => { + expect( + hasUnseenError({ + activities: [ + { + id: "activity-1" as never, + tone: "error", + kind: "checkpoint.capture.failed", + summary: "Checkpoint capture failed", + payload: {}, + turnId: null, + createdAt: "2026-03-09T10:03:00.000Z", + }, + ], + latestTurn: { + turnId: "turn-1" as never, + state: "error", + assistantMessageId: null, + requestedAt: "2026-03-09T10:00:00.000Z", + startedAt: "2026-03-09T10:00:00.000Z", + completedAt: "2026-03-09T10:03:30.000Z", + }, + lastVisitedAt: "2026-03-09T10:04:00.000Z", + session: { + provider: "codex" as const, + status: "error" as const, + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: "2026-03-09T10:03:45.000Z", + orchestrationStatus: "error" as const, + lastError: "Turn failed", + }, + }), + ).toBe(false); + }); }); describe("shouldClearThreadSelectionOnMouseDown", () => { @@ -64,6 +120,7 @@ describe("shouldClearThreadSelectionOnMouseDown", () => { describe("resolveThreadStatusPill", () => { const baseThread = { + activities: [], interactionMode: "plan" as const, latestTurn: null, lastVisitedAt: undefined, @@ -87,6 +144,37 @@ describe("resolveThreadStatusPill", () => { ).toMatchObject({ label: "Pending Approval", pulse: false }); }); + it("shows error before every other derived status when an unseen error exists", () => { + expect( + resolveThreadStatusPill({ + thread: { + ...baseThread, + interactionMode: "default", + latestTurn: makeLatestTurn(), + lastVisitedAt: "2026-03-09T10:04:00.000Z", + activities: [ + { + id: "activity-1" as never, + tone: "error", + kind: "checkpoint.capture.failed", + summary: "Checkpoint capture failed", + payload: {}, + turnId: null, + createdAt: "2026-03-09T10:05:30.000Z", + }, + ], + session: { + ...baseThread.session, + status: "ready", + orchestrationStatus: "ready", + }, + }, + hasPendingApprovals: true, + hasPendingUserInput: true, + }), + ).toMatchObject({ label: "Error", pulse: false }); + }); + it("shows awaiting input when plan mode is blocked on user answers", () => { expect( resolveThreadStatusPill({ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index f1afa0f64..a0218309f 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,10 +1,12 @@ import type { Thread } from "../types"; import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic"; +import { hasUnseenCompletion, hasUnseenError } from "../thread-status"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; export interface ThreadStatusPill { label: + | "Error" | "Working" | "Connecting" | "Completed" @@ -18,19 +20,9 @@ export interface ThreadStatusPill { type ThreadStatusInput = Pick< Thread, - "interactionMode" | "latestTurn" | "lastVisitedAt" | "proposedPlans" | "session" + "activities" | "interactionMode" | "latestTurn" | "lastVisitedAt" | "proposedPlans" | "session" >; - -export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { - if (!thread.latestTurn?.completedAt) return false; - const completedAt = Date.parse(thread.latestTurn.completedAt); - if (Number.isNaN(completedAt)) return false; - if (!thread.lastVisitedAt) return true; - - const lastVisitedAt = Date.parse(thread.lastVisitedAt); - if (Number.isNaN(lastVisitedAt)) return true; - return completedAt > lastVisitedAt; -} +export { hasUnseenCompletion, hasUnseenError } from "../thread-status"; export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null): boolean { if (target === null) return true; @@ -44,6 +36,15 @@ export function resolveThreadStatusPill(input: { }): ThreadStatusPill | null { const { hasPendingApprovals, hasPendingUserInput, thread } = input; + if (hasUnseenError(thread)) { + return { + label: "Error", + colorClass: "text-red-600 dark:text-red-300/90", + dotClass: "bg-red-500 dark:bg-red-300/90", + pulse: false, + }; + } + if (hasPendingApprovals) { return { label: "Pending Approval", diff --git a/apps/web/src/thread-status.ts b/apps/web/src/thread-status.ts new file mode 100644 index 000000000..7ed9caea7 --- /dev/null +++ b/apps/web/src/thread-status.ts @@ -0,0 +1,56 @@ +import type { Thread } from "./types"; + +type TimestampedThreadInput = Pick; + +type UnseenCompletionThreadInput = Pick; + +type UnseenErrorThreadInput = Pick< + Thread, + "activities" | "lastVisitedAt" | "latestTurn" | "session" +>; + +function parseIsoTimestamp(iso: string | null | undefined): number | null { + if (!iso) return null; + const timestamp = Date.parse(iso); + return Number.isNaN(timestamp) ? null : timestamp; +} + +function happenedAfterLastVisit( + thread: TimestampedThreadInput, + occurredAt: string | null | undefined, +): boolean { + const occurredAtMs = parseIsoTimestamp(occurredAt); + if (occurredAtMs === null) return false; + + const lastVisitedAtMs = parseIsoTimestamp(thread.lastVisitedAt); + if (lastVisitedAtMs === null) return true; + + return occurredAtMs > lastVisitedAtMs; +} + +export function hasUnseenCompletion(thread: UnseenCompletionThreadInput): boolean { + return happenedAfterLastVisit(thread, thread.latestTurn?.completedAt); +} + +export function hasUnseenError(thread: UnseenErrorThreadInput): boolean { + const hasUnseenErrorActivity = thread.activities.some( + (activity) => activity.tone === "error" && happenedAfterLastVisit(thread, activity.createdAt), + ); + if (hasUnseenErrorActivity) { + return true; + } + + if (thread.latestTurn?.state === "error") { + if (happenedAfterLastVisit(thread, thread.latestTurn.completedAt)) { + return true; + } + } + + if (thread.session?.lastError) { + if (happenedAfterLastVisit(thread, thread.session.updatedAt)) { + return true; + } + } + + return false; +}