From 213a9fe2b88b4592d5aed01ce8072af5dce3a57e Mon Sep 17 00:00:00 2001 From: Adam Firestone Date: Mon, 17 Aug 2026 14:21:16 -0500 Subject: [PATCH 1/2] fix(web): preserve Done status for threads left mid-turn - Seed the visited marker when an active turn starts - Advance it on completion so open threads clear Done normally - Add coverage for in-flight, completed, and pre-turn states --- .../web/src/components/ChatView.logic.test.ts | 36 +++++++++++++++++++ apps/web/src/components/ChatView.logic.ts | 22 ++++++++++++ apps/web/src/components/ChatView.tsx | 23 +++++------- 3 files changed, 67 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 5c026c94a138..5aee78e21e13 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -26,6 +26,7 @@ import { isBranchMismatchDismissedForSession, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, + resolveActiveThreadVisitedAt, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, scheduleEnvironmentReconnectWarning, @@ -39,6 +40,41 @@ const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); const now = "2026-03-29T00:00:00.000Z"; +describe("resolveActiveThreadVisitedAt", () => { + const thread = { + createdAt: "2026-03-29T00:00:00.000Z", + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "running" as const, + assistantMessageId: null, + requestedAt: "2026-03-29T00:01:00.000Z", + startedAt: "2026-03-29T00:01:01.000Z", + completedAt: null, + }, + }; + + it("records an in-flight first turn before it completes", () => { + expect(resolveActiveThreadVisitedAt(thread)).toBe("2026-03-29T00:01:01.000Z"); + }); + + it("advances the visit marker to completion while the thread remains open", () => { + expect( + resolveActiveThreadVisitedAt({ + ...thread, + latestTurn: { + ...thread.latestTurn, + state: "completed", + completedAt: "2026-03-29T00:02:00.000Z", + }, + }), + ).toBe("2026-03-29T00:02:00.000Z"); + }); + + it("uses thread creation as the baseline before a turn is projected", () => { + expect(resolveActiveThreadVisitedAt({ ...thread, latestTurn: null })).toBe(thread.createdAt); + }); +}); + describe("environment reconnect warning grace", () => { afterEach(() => vi.useRealTimers()); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 04561b507c3e..7df98b5e896b 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -370,6 +370,28 @@ export function threadHasStarted(thread: Thread | null | undefined): boolean { ); } +/** + * The newest server-backed point the open chat has actually presented. + * Recording an in-flight turn gives its first completion a read baseline: + * leaving before completion makes the later completedAt unread, while staying + * open advances the marker to completedAt and clears Done as usual. + */ +export function resolveActiveThreadVisitedAt( + thread: Pick, +): string | null { + const turn = thread.latestTurn; + const candidates = turn + ? [turn.completedAt, turn.startedAt, turn.requestedAt] + : [thread.createdAt]; + + for (const candidate of candidates) { + if (candidate !== null && Number.isFinite(Date.parse(candidate))) { + return candidate; + } + } + return null; +} + // `threadProvider` is the open branded driver kind carried by the session. // Unknown driver kinds degrade to `null` (i.e. "unlocked"), which is the safe // rollback / fork behavior — the routing layer is the right place to surface diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7f1c7b733ffc..9cf93090ba9f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -339,6 +339,7 @@ import { deriveLockedProvider, readFileAsDataUrl, reconcileMountedTerminalThreadIds, + resolveActiveThreadVisitedAt, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, revokeBlobPreviewUrl, @@ -1736,24 +1737,18 @@ function ChatViewContent(props: ChatViewProps) { return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); const activeLatestTurn = activeThread?.latestTurn ?? null; - // Reading a finished thread clears the sidebar's Done badge. The visit is - // stamped at the turn's completion time — not now/updatedAt — so it clears - // exactly the completion the user is looking at: a wake or completion that - // lands later still gets its signal (markThreadVisited never moves the - // timestamp backwards). + const activeThreadVisitedAt = serverThread ? resolveActiveThreadVisitedAt(serverThread) : null; + // Keep the visit marker at the newest server-backed state shown in the open + // chat. While the first turn runs this seeds a baseline before completion, + // so navigating away lets completedAt surface as unread Done. If the chat + // stays open, the completion advances the marker and clears Done normally. useEffect(() => { - const completedAt = serverThread?.latestTurn?.completedAt; - if (!serverThread?.id || !completedAt) return; + if (!serverThread?.id || !activeThreadVisitedAt) return; markThreadVisited( scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)), - completedAt, + activeThreadVisitedAt, ); - }, [ - markThreadVisited, - serverThread?.environmentId, - serverThread?.id, - serverThread?.latestTurn?.completedAt, - ]); + }, [activeThreadVisitedAt, markThreadVisited, serverThread?.environmentId, serverThread?.id]); useEffect(() => { setMountedTerminalThreadKeys((currentThreadIds) => { const nextThreadIds = reconcileMountedTerminalThreadIds({ From ab0c1e59e5b0437d953a311867d6963cf20f7e65 Mon Sep 17 00:00:00 2001 From: Adam Firestone Date: Mon, 17 Aug 2026 14:34:56 -0500 Subject: [PATCH 2/2] fix(web): preserve Done status for threads left mid-turn - Track live thread completions globally and mark newly completed background threads unread - Stamp active-thread visits at completion time to preserve later Done notifications --- .../web/src/components/ChatView.logic.test.ts | 36 --- apps/web/src/components/ChatView.logic.ts | 22 -- apps/web/src/components/ChatView.tsx | 23 +- .../useMarkLiveCompletedThreadsUnread.test.ts | 223 ++++++++++++++++++ .../useMarkLiveCompletedThreadsUnread.ts | 126 ++++++++++ apps/web/src/routes/__root.tsx | 7 + 6 files changed, 370 insertions(+), 67 deletions(-) create mode 100644 apps/web/src/hooks/useMarkLiveCompletedThreadsUnread.test.ts create mode 100644 apps/web/src/hooks/useMarkLiveCompletedThreadsUnread.ts diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 5aee78e21e13..5c026c94a138 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -26,7 +26,6 @@ import { isBranchMismatchDismissedForSession, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, - resolveActiveThreadVisitedAt, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, scheduleEnvironmentReconnectWarning, @@ -40,41 +39,6 @@ const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); const now = "2026-03-29T00:00:00.000Z"; -describe("resolveActiveThreadVisitedAt", () => { - const thread = { - createdAt: "2026-03-29T00:00:00.000Z", - latestTurn: { - turnId: TurnId.make("turn-1"), - state: "running" as const, - assistantMessageId: null, - requestedAt: "2026-03-29T00:01:00.000Z", - startedAt: "2026-03-29T00:01:01.000Z", - completedAt: null, - }, - }; - - it("records an in-flight first turn before it completes", () => { - expect(resolveActiveThreadVisitedAt(thread)).toBe("2026-03-29T00:01:01.000Z"); - }); - - it("advances the visit marker to completion while the thread remains open", () => { - expect( - resolveActiveThreadVisitedAt({ - ...thread, - latestTurn: { - ...thread.latestTurn, - state: "completed", - completedAt: "2026-03-29T00:02:00.000Z", - }, - }), - ).toBe("2026-03-29T00:02:00.000Z"); - }); - - it("uses thread creation as the baseline before a turn is projected", () => { - expect(resolveActiveThreadVisitedAt({ ...thread, latestTurn: null })).toBe(thread.createdAt); - }); -}); - describe("environment reconnect warning grace", () => { afterEach(() => vi.useRealTimers()); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 7df98b5e896b..04561b507c3e 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -370,28 +370,6 @@ export function threadHasStarted(thread: Thread | null | undefined): boolean { ); } -/** - * The newest server-backed point the open chat has actually presented. - * Recording an in-flight turn gives its first completion a read baseline: - * leaving before completion makes the later completedAt unread, while staying - * open advances the marker to completedAt and clears Done as usual. - */ -export function resolveActiveThreadVisitedAt( - thread: Pick, -): string | null { - const turn = thread.latestTurn; - const candidates = turn - ? [turn.completedAt, turn.startedAt, turn.requestedAt] - : [thread.createdAt]; - - for (const candidate of candidates) { - if (candidate !== null && Number.isFinite(Date.parse(candidate))) { - return candidate; - } - } - return null; -} - // `threadProvider` is the open branded driver kind carried by the session. // Unknown driver kinds degrade to `null` (i.e. "unlocked"), which is the safe // rollback / fork behavior — the routing layer is the right place to surface diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9cf93090ba9f..7f1c7b733ffc 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -339,7 +339,6 @@ import { deriveLockedProvider, readFileAsDataUrl, reconcileMountedTerminalThreadIds, - resolveActiveThreadVisitedAt, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, revokeBlobPreviewUrl, @@ -1737,18 +1736,24 @@ function ChatViewContent(props: ChatViewProps) { return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); const activeLatestTurn = activeThread?.latestTurn ?? null; - const activeThreadVisitedAt = serverThread ? resolveActiveThreadVisitedAt(serverThread) : null; - // Keep the visit marker at the newest server-backed state shown in the open - // chat. While the first turn runs this seeds a baseline before completion, - // so navigating away lets completedAt surface as unread Done. If the chat - // stays open, the completion advances the marker and clears Done normally. + // Reading a finished thread clears the sidebar's Done badge. The visit is + // stamped at the turn's completion time — not now/updatedAt — so it clears + // exactly the completion the user is looking at: a wake or completion that + // lands later still gets its signal (markThreadVisited never moves the + // timestamp backwards). useEffect(() => { - if (!serverThread?.id || !activeThreadVisitedAt) return; + const completedAt = serverThread?.latestTurn?.completedAt; + if (!serverThread?.id || !completedAt) return; markThreadVisited( scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)), - activeThreadVisitedAt, + completedAt, ); - }, [activeThreadVisitedAt, markThreadVisited, serverThread?.environmentId, serverThread?.id]); + }, [ + markThreadVisited, + serverThread?.environmentId, + serverThread?.id, + serverThread?.latestTurn?.completedAt, + ]); useEffect(() => { setMountedTerminalThreadKeys((currentThreadIds) => { const nextThreadIds = reconcileMountedTerminalThreadIds({ 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);