diff --git a/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.test.ts b/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.test.ts new file mode 100644 index 00000000000..34e78f56c84 --- /dev/null +++ b/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.test.ts @@ -0,0 +1,72 @@ +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolveFirstSeenCompletedThreads } from "./useMarkFirstSeenCompletedThreadsUnread"; + +const localEnvironmentId = EnvironmentId.make("environment-local"); +const remoteEnvironmentId = EnvironmentId.make("environment-remote"); + +function thread( + id: string, + state: "completed" | "running" = "completed", + environmentId = localEnvironmentId, +) { + return { + environmentId, + id: ThreadId.make(id), + latestTurn: { + state, + completedAt: "2026-06-18T09:00:00.000Z", + }, + } as const; +} + +describe("resolveFirstSeenCompletedThreads", () => { + it("seeds initial snapshot history without marking it unread", () => { + const result = resolveFirstSeenCompletedThreads({ + threads: [thread("historical")], + environmentSnapshotIds: [localEnvironmentId], + previouslySeenThreadKeysByEnvironment: new Map(), + }); + + expect(result.newlyUnreadThreads).toEqual([]); + expect(result.nextSeenThreadKeysByEnvironment.get(localEnvironmentId)).toEqual( + new Set([scopedThreadKey(scopeThreadRef(localEnvironmentId, ThreadId.make("historical")))]), + ); + }); + + it("marks a completed thread that first appears after bootstrap unread", () => { + const historicalKey = scopedThreadKey( + scopeThreadRef(localEnvironmentId, ThreadId.make("historical")), + ); + const completedKey = scopedThreadKey( + scopeThreadRef(localEnvironmentId, ThreadId.make("completed")), + ); + const result = resolveFirstSeenCompletedThreads({ + threads: [thread("historical"), thread("completed")], + environmentSnapshotIds: [localEnvironmentId], + previouslySeenThreadKeysByEnvironment: new Map([ + [localEnvironmentId, new Set([historicalKey])], + ]), + }); + + expect(result.newlyUnreadThreads).toEqual([ + { + threadKey: completedKey, + completedAt: "2026-06-18T09:00:00.000Z", + }, + ]); + }); + + it("does not mark a new unfinished thread or a thread outside a snapshot environment", () => { + const result = resolveFirstSeenCompletedThreads({ + threads: [thread("running", "running"), thread("remote", "completed", remoteEnvironmentId)], + environmentSnapshotIds: [localEnvironmentId], + previouslySeenThreadKeysByEnvironment: new Map([[localEnvironmentId, new Set()]]), + }); + + expect(result.newlyUnreadThreads).toEqual([]); + expect(result.nextSeenThreadKeysByEnvironment.has(remoteEnvironmentId)).toBe(false); + }); +}); diff --git a/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.ts b/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.ts new file mode 100644 index 00000000000..241c998ae53 --- /dev/null +++ b/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.ts @@ -0,0 +1,97 @@ +import { useAtomValue } from "@effect/atom-react"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentId, 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 { useThreadShells } from "../state/entities"; +import { environmentShell } from "../state/shell"; +import { useUiStateStore } from "../uiStateStore"; + +const environmentSnapshotIdsAtom = Atom.make((get): ReadonlyArray => { + const environmentIds: EnvironmentId[] = []; + for (const environmentId of get(environmentCatalog.catalogValueAtom).entries.keys()) { + if (Option.isSome(get(environmentShell.stateValueAtom(environmentId)).snapshot)) { + environmentIds.push(environmentId); + } + } + return environmentIds; +}).pipe(Atom.withLabel("completed-thread-unread:snapshot-environments")); + +interface FirstSeenThreadInput { + readonly environmentId: EnvironmentId; + readonly id: ThreadId; + readonly latestTurn: { + readonly state: string; + readonly completedAt: string | null; + } | null; +} + +export function resolveFirstSeenCompletedThreads(input: { + readonly threads: ReadonlyArray; + readonly environmentSnapshotIds: ReadonlyArray; + readonly previouslySeenThreadKeysByEnvironment: ReadonlyMap>; +}): { + readonly nextSeenThreadKeysByEnvironment: Map>; + readonly newlyUnreadThreads: ReadonlyArray<{ + readonly threadKey: string; + readonly completedAt: string | null; + }>; +} { + const snapshotEnvironmentIds = new Set(input.environmentSnapshotIds); + const nextSeenThreadKeysByEnvironment = new Map>(); + const newlyUnreadThreads: Array<{ + readonly threadKey: string; + readonly completedAt: string | null; + }> = []; + for (const environmentId of snapshotEnvironmentIds) { + nextSeenThreadKeysByEnvironment.set(environmentId, new Set()); + } + + for (const thread of input.threads) { + if (!snapshotEnvironmentIds.has(thread.environmentId)) { + continue; + } + + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + nextSeenThreadKeysByEnvironment.get(thread.environmentId)?.add(threadKey); + + const previousThreadKeys = input.previouslySeenThreadKeysByEnvironment.get( + thread.environmentId, + ); + if ( + previousThreadKeys !== undefined && + !previousThreadKeys.has(threadKey) && + thread.latestTurn?.state === "completed" + ) { + newlyUnreadThreads.push({ + threadKey, + completedAt: thread.latestTurn.completedAt, + }); + } + } + + return { nextSeenThreadKeysByEnvironment, newlyUnreadThreads }; +} + +export function useMarkFirstSeenCompletedThreadsUnread(): void { + const threads = useThreadShells(); + const environmentSnapshotIds = useAtomValue(environmentSnapshotIdsAtom); + const seenThreadKeysByEnvironmentRef = useRef>>(new Map()); + + useEffect(() => { + const { nextSeenThreadKeysByEnvironment, newlyUnreadThreads } = + resolveFirstSeenCompletedThreads({ + threads, + environmentSnapshotIds, + previouslySeenThreadKeysByEnvironment: seenThreadKeysByEnvironmentRef.current, + }); + for (const thread of newlyUnreadThreads) { + useUiStateStore.getState().markThreadUnread(thread.threadKey, thread.completedAt); + } + + seenThreadKeysByEnvironmentRef.current = nextSeenThreadKeysByEnvironment; + }, [environmentSnapshotIds, threads]); +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 346991d114d..cb7530c0345 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -27,6 +27,7 @@ import { toastManager, } from "../components/ui/toast"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; +import { useMarkFirstSeenCompletedThreadsUnread } from "../hooks/useMarkFirstSeenCompletedThreadsUnread"; import { useClientSettings } from "../hooks/useSettings"; import { deriveLogicalProjectKeyFromSettings, @@ -277,6 +278,8 @@ function AuthenticatedTracingBootstrap() { } function EventRouter() { + useMarkFirstSeenCompletedThreadsUnread(); + const navigate = useNavigate(); const pathname = useLocation({ select: (loc) => loc.pathname }); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);