diff --git a/apps/web/src/hooks/useMarkLiveCompletedThreadsUnread.test.ts b/apps/web/src/hooks/useMarkLiveCompletedThreadsUnread.test.ts new file mode 100644 index 000000000000..cab82605a207 --- /dev/null +++ b/apps/web/src/hooks/useMarkLiveCompletedThreadsUnread.test.ts @@ -0,0 +1,223 @@ +import { + EnvironmentId, + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, + type OrchestrationLatestTurnState, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + transitionCompletedThreadUnreadState, + type CompletedThreadUnreadEnvironment, + type CompletedThreadUnreadState, +} from "./useMarkLiveCompletedThreadsUnread"; + +const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); +const COMPLETED_AT = "2026-06-17T18:30:00.000Z"; + +function makeThread(input: { + readonly id: string; + readonly state?: OrchestrationLatestTurnState; + readonly completedAt?: string | null; + readonly updatedAt?: string; + readonly archivedAt?: string | null; +}): OrchestrationThreadShell { + const threadId = ThreadId.make(input.id); + const state = input.state ?? "completed"; + return { + id: threadId, + projectId: ProjectId.make("project-1"), + title: input.id, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.6-sol", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + latestTurn: { + turnId: TurnId.make(`turn-${input.id}`), + state, + requestedAt: "2026-06-17T18:00:00.000Z", + startedAt: "2026-06-17T18:00:01.000Z", + completedAt: input.completedAt === undefined ? COMPLETED_AT : input.completedAt, + assistantMessageId: null, + }, + createdAt: "2026-06-17T18:00:00.000Z", + updatedAt: input.updatedAt ?? COMPLETED_AT, + archivedAt: input.archivedAt ?? null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: "2026-06-17T18:00:00.000Z", + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }; +} + +function environment( + threads: ReadonlyArray, + isLive = true, +): CompletedThreadUnreadEnvironment { + return { environmentId: ENVIRONMENT_ID, isLive, threads }; +} + +function bootstrap(threads: ReadonlyArray = []) { + return transitionCompletedThreadUnreadState(new Map(), [environment(threads)]); +} + +describe("transitionCompletedThreadUnreadState", () => { + it("keeps completed threads in the initial historical snapshot read", () => { + const historical = makeThread({ id: "historical" }); + + const transition = bootstrap([historical]); + + expect(transition.actions).toEqual([]); + expect(transition.state.get(ENVIRONMENT_ID)?.get(historical.id)).toBe(COMPLETED_AT); + }); + + it("marks a first-seen live completed thread unread when updatedAt equals completedAt", () => { + const initial = bootstrap(); + const completed = makeThread({ id: "live-equal" }); + + const transition = transitionCompletedThreadUnreadState(initial.state, [ + environment([completed]), + ]); + + expect(transition.actions).toEqual([ + { environmentId: ENVIRONMENT_ID, threadId: completed.id, completedAt: COMPLETED_AT }, + ]); + }); + + it("marks a first-seen live completed thread unread when updatedAt is after completedAt", () => { + const initial = bootstrap(); + const completed = makeThread({ + id: "live-later", + updatedAt: "2026-06-17T18:31:00.000Z", + }); + + const transition = transitionCompletedThreadUnreadState(initial.state, [ + environment([completed]), + ]); + + expect(transition.actions).toEqual([ + { environmentId: ENVIRONMENT_ID, threadId: completed.id, completedAt: COMPLETED_AT }, + ]); + }); + + it("marks a completed thread introduced by a post-bootstrap snapshot unread", () => { + const historical = makeThread({ id: "historical" }); + const initial = bootstrap([historical]); + const added = makeThread({ id: "snapshot-addition" }); + + const transition = transitionCompletedThreadUnreadState(initial.state, [ + environment([historical, added]), + ]); + + expect(transition.actions).toEqual([ + { environmentId: ENVIRONMENT_ID, threadId: added.id, completedAt: COMPLETED_AT }, + ]); + }); + + it("marks a previously running background thread when it completes", () => { + const running = makeThread({ id: "background", state: "running", completedAt: null }); + const initial = bootstrap([running]); + const completed = makeThread({ id: "background" }); + + const transition = transitionCompletedThreadUnreadState(initial.state, [ + environment([completed]), + ]); + + expect(transition.actions).toEqual([ + { environmentId: ENVIRONMENT_ID, threadId: completed.id, completedAt: COMPLETED_AT }, + ]); + }); + + it("preserves observations across reconnect snapshots without repeating unread actions", () => { + const completed = makeThread({ id: "completed" }); + const initial = bootstrap([completed]); + + const synchronizing = transitionCompletedThreadUnreadState(initial.state, [ + environment([], false), + ]); + const reconnected = transitionCompletedThreadUnreadState(synchronizing.state, [ + environment([completed]), + ]); + + expect(synchronizing.actions).toEqual([]); + expect(synchronizing.state).toEqual(initial.state); + expect(reconnected.actions).toEqual([]); + }); + + it("does not repeat an unread action after reconnecting", () => { + const initial = bootstrap(); + const completed = makeThread({ id: "completed" }); + const firstSeen = transitionCompletedThreadUnreadState(initial.state, [ + environment([completed]), + ]); + const synchronizing = transitionCompletedThreadUnreadState(firstSeen.state, [ + environment([completed], false), + ]); + const reconnected = transitionCompletedThreadUnreadState(synchronizing.state, [ + environment([completed]), + ]); + + expect(firstSeen.actions).toHaveLength(1); + expect(reconnected.actions).toEqual([]); + }); + + it("waits for a completion-consistent updatedAt before marking unread", () => { + const initial = bootstrap(); + const stale = makeThread({ + id: "stale", + updatedAt: "2026-06-17T18:29:59.000Z", + }); + const staleTransition = transitionCompletedThreadUnreadState(initial.state, [ + environment([stale]), + ]); + const consistent = makeThread({ id: "stale" }); + const consistentTransition = transitionCompletedThreadUnreadState(staleTransition.state, [ + environment([consistent]), + ]); + + expect(staleTransition.actions).toEqual([]); + expect(consistentTransition.actions).toEqual([ + { environmentId: ENVIRONMENT_ID, threadId: consistent.id, completedAt: COMPLETED_AT }, + ]); + }); + + it("does not mark interrupted or error turns as completed", () => { + const initial = bootstrap(); + const interrupted = makeThread({ id: "interrupted", state: "interrupted" }); + const failed = makeThread({ id: "failed", state: "error" }); + + const transition = transitionCompletedThreadUnreadState(initial.state, [ + environment([interrupted, failed]), + ]); + + expect(transition.actions).toEqual([]); + expect(transition.state.get(ENVIRONMENT_ID)).toEqual( + new Map([ + [interrupted.id, null], + [failed.id, null], + ]), + ); + }); + + it("treats a removed and re-added environment as a fresh historical bootstrap", () => { + const historical = makeThread({ id: "historical" }); + const initial = bootstrap([historical]); + const removed = transitionCompletedThreadUnreadState(initial.state, []); + const readded = transitionCompletedThreadUnreadState(removed.state, [ + environment([historical]), + ]); + + expect(removed.state).toEqual(new Map() satisfies CompletedThreadUnreadState); + expect(readded.actions).toEqual([]); + }); +}); diff --git a/apps/web/src/hooks/useMarkLiveCompletedThreadsUnread.ts b/apps/web/src/hooks/useMarkLiveCompletedThreadsUnread.ts new file mode 100644 index 000000000000..cf9f73514165 --- /dev/null +++ b/apps/web/src/hooks/useMarkLiveCompletedThreadsUnread.ts @@ -0,0 +1,126 @@ +import { useAtomValue } from "@effect/atom-react"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentId, OrchestrationThreadShell, ThreadId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { Atom } from "effect/unstable/reactivity"; +import { useEffect, useRef } from "react"; + +import { environmentCatalog } from "../connection/catalog"; +import { environmentShell } from "../state/shell"; +import { useUiStateStore } from "../uiStateStore"; + +export interface CompletedThreadUnreadEnvironment { + readonly environmentId: EnvironmentId; + readonly isLive: boolean; + readonly threads: ReadonlyArray; +} + +export type CompletedThreadUnreadState = ReadonlyMap< + EnvironmentId, + ReadonlyMap +>; + +export interface CompletedThreadUnreadAction { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly completedAt: string; +} + +export interface CompletedThreadUnreadTransition { + readonly state: CompletedThreadUnreadState; + readonly actions: ReadonlyArray; +} + +function eligibleCompletedAt(thread: OrchestrationThreadShell): string | null { + if (thread.latestTurn?.state !== "completed" || !thread.latestTurn.completedAt) { + return null; + } + + const completedAt = Date.parse(thread.latestTurn.completedAt); + const updatedAt = Date.parse(thread.updatedAt); + if (!Number.isFinite(completedAt) || !Number.isFinite(updatedAt) || updatedAt < completedAt) { + return null; + } + + return thread.latestTurn.completedAt; +} + +export function transitionCompletedThreadUnreadState( + previousState: CompletedThreadUnreadState, + environments: ReadonlyArray, +): CompletedThreadUnreadTransition { + const nextState = new Map>(); + const actions: CompletedThreadUnreadAction[] = []; + + for (const environment of environments) { + const previousCompletions = previousState.get(environment.environmentId); + if (!environment.isLive) { + // Keep the last live observations while a known environment reconnects, + // but do not treat bootstrap or catch-up snapshots as new completions. + if (previousCompletions !== undefined) { + nextState.set(environment.environmentId, previousCompletions); + } + continue; + } + + const currentCompletions = new Map(); + for (const thread of environment.threads) { + const completedAt = eligibleCompletedAt(thread); + currentCompletions.set(thread.id, completedAt); + + if ( + // An absent previous map means this is the environment's initial live + // snapshot, whose historical completions establish the read baseline. + previousCompletions !== undefined && + completedAt !== null && + thread.archivedAt === null && + (!previousCompletions.has(thread.id) || previousCompletions.get(thread.id) !== completedAt) + ) { + actions.push({ + environmentId: environment.environmentId, + threadId: thread.id, + completedAt, + }); + } + } + nextState.set(environment.environmentId, currentCompletions); + } + + return { state: nextState, actions }; +} + +const completedThreadUnreadEnvironmentsAtom = Atom.make( + (get): ReadonlyArray => { + const environments: CompletedThreadUnreadEnvironment[] = []; + for (const environmentId of get(environmentCatalog.catalogValueAtom).entries.keys()) { + const shellState = get(environmentShell.stateValueAtom(environmentId)); + environments.push({ + environmentId, + isLive: shellState.status === "live", + threads: Option.match(shellState.snapshot, { + onNone: () => [], + onSome: (snapshot) => snapshot.threads, + }), + }); + } + return environments; + }, +).pipe(Atom.withLabel("completed-thread-unread:environments")); + +export function useMarkLiveCompletedThreadsUnread(): void { + const environments = useAtomValue(completedThreadUnreadEnvironmentsAtom); + const stateRef = useRef(new Map()); + + useEffect(() => { + const transition = transitionCompletedThreadUnreadState(stateRef.current, environments); + stateRef.current = transition.state; + + const uiState = useUiStateStore.getState(); + for (const action of transition.actions) { + uiState.markThreadUnread( + scopedThreadKey(scopeThreadRef(action.environmentId, action.threadId)), + action.completedAt, + ); + } + }, [environments]); +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index bc8f8507b814..058afed611e2 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -32,6 +32,7 @@ import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { applyAppearanceFontVariables, isFontFamilyAvailable } from "~/appearanceFonts"; import { loadHostFontFamily } from "../hostFonts"; import { useClientSettings } from "../hooks/useSettings"; +import { useMarkLiveCompletedThreadsUnread } from "../hooks/useMarkLiveCompletedThreadsUnread"; import { deriveLogicalProjectKeyFromSettings, derivePhysicalProjectKeyFromPath, @@ -140,6 +141,7 @@ function RootRouteView() { + {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} {appShell} @@ -151,6 +153,11 @@ function RootRouteView() { ); } +function CompletedThreadUnreadTracker() { + useMarkLiveCompletedThreadsUnread(); + return null; +} + function GlassAppearanceSync() { const glassOpacity = useClientSettings((settings) => settings.glassOpacity);