From 92071031d82a8335d8b2d0550de2515325f1d2f6 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Thu, 18 Jun 2026 09:03:53 +0000 Subject: [PATCH 1/2] fix: mark first-seen completed threads unread Co-authored-by: Codex (cherry picked from commit c87567aa1f16fcb43652d224c38d37a167312875) --- .../useMarkFirstSeenCompletedThreadsUnread.ts | 57 +++++++++++++++++++ apps/web/src/routes/__root.tsx | 3 + 2 files changed, 60 insertions(+) create mode 100644 apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.ts diff --git a/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.ts b/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.ts new file mode 100644 index 00000000000..3cc7e2a5faf --- /dev/null +++ b/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.ts @@ -0,0 +1,57 @@ +import { useAtomValue } from "@effect/atom-react"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentId } 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")); + +export function useMarkFirstSeenCompletedThreadsUnread(): void { + const threads = useThreadShells(); + const environmentSnapshotIds = useAtomValue(environmentSnapshotIdsAtom); + const seenThreadKeysByEnvironmentRef = useRef>>(new Map()); + + useEffect(() => { + const snapshotEnvironmentIds = new Set(environmentSnapshotIds); + const nextThreadKeysByEnvironment = new Map>(); + for (const environmentId of snapshotEnvironmentIds) { + nextThreadKeysByEnvironment.set(environmentId, new Set()); + } + + for (const thread of threads) { + if (!snapshotEnvironmentIds.has(thread.environmentId)) { + continue; + } + + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const threadKey = scopedThreadKey(threadRef); + const nextThreadKeys = nextThreadKeysByEnvironment.get(thread.environmentId); + nextThreadKeys?.add(threadKey); + + const previousThreadKeys = seenThreadKeysByEnvironmentRef.current.get(thread.environmentId); + if ( + previousThreadKeys !== undefined && + !previousThreadKeys.has(threadKey) && + thread.latestTurn?.state === "completed" + ) { + useUiStateStore.getState().markThreadUnread(threadKey, thread.latestTurn.completedAt); + } + } + + seenThreadKeysByEnvironmentRef.current = nextThreadKeysByEnvironment; + }, [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); From fc354d8fb7c16ddedd07e718ff70e2f3129629f9 Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Thu, 23 Jul 2026 00:23:47 -0400 Subject: [PATCH 2/2] test(web): cover first-seen unread detection --- ...arkFirstSeenCompletedThreadsUnread.test.ts | 72 +++++++++++++++ .../useMarkFirstSeenCompletedThreadsUnread.ts | 92 +++++++++++++------ 2 files changed, 138 insertions(+), 26 deletions(-) create mode 100644 apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.test.ts 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 index 3cc7e2a5faf..241c998ae53 100644 --- a/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.ts +++ b/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.ts @@ -1,6 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; -import type { EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { Atom } from "effect/unstable/reactivity"; import { useEffect, useRef } from "react"; @@ -20,38 +20,78 @@ const environmentSnapshotIdsAtom = Atom.make((get): ReadonlyArray 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 snapshotEnvironmentIds = new Set(environmentSnapshotIds); - const nextThreadKeysByEnvironment = new Map>(); - for (const environmentId of snapshotEnvironmentIds) { - nextThreadKeysByEnvironment.set(environmentId, new Set()); - } - - for (const thread of threads) { - if (!snapshotEnvironmentIds.has(thread.environmentId)) { - continue; - } - - const threadRef = scopeThreadRef(thread.environmentId, thread.id); - const threadKey = scopedThreadKey(threadRef); - const nextThreadKeys = nextThreadKeysByEnvironment.get(thread.environmentId); - nextThreadKeys?.add(threadKey); - - const previousThreadKeys = seenThreadKeysByEnvironmentRef.current.get(thread.environmentId); - if ( - previousThreadKeys !== undefined && - !previousThreadKeys.has(threadKey) && - thread.latestTurn?.state === "completed" - ) { - useUiStateStore.getState().markThreadUnread(threadKey, thread.latestTurn.completedAt); - } + const { nextSeenThreadKeysByEnvironment, newlyUnreadThreads } = + resolveFirstSeenCompletedThreads({ + threads, + environmentSnapshotIds, + previouslySeenThreadKeysByEnvironment: seenThreadKeysByEnvironmentRef.current, + }); + for (const thread of newlyUnreadThreads) { + useUiStateStore.getState().markThreadUnread(thread.threadKey, thread.completedAt); } - seenThreadKeysByEnvironmentRef.current = nextThreadKeysByEnvironment; + seenThreadKeysByEnvironmentRef.current = nextSeenThreadKeysByEnvironment; }, [environmentSnapshotIds, threads]); }