From 6369972fbae1c6296ccdff15497819c82ba9df66 Mon Sep 17 00:00:00 2001 From: Shawn <5414767+playcations@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:53:07 -0400 Subject: [PATCH 1/2] fix(mobile): a run that finishes during a resume is not marked read unseen An open thread marked a finished run read the moment it arrived. Resuming the app on a thread, a completion that landed during the sync was marked read even if the user backed out before seeing it. Opening a synced thread still counts at once; after the app leaves the foreground or the environment syncs, the view counts again only once the thread has shown, synced and in the foreground, for two seconds. Co-Authored-By: Claude Opus 5.5 --- .../features/threads/ThreadDetailScreen.tsx | 18 +++++---- .../threads/threadViewInterruption.test.ts | 30 ++++++++++++++ .../threads/threadViewInterruption.ts | 17 ++++++++ .../features/threads/useSettledThreadView.ts | 40 +++++++++++++++++++ 4 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 apps/mobile/src/features/threads/threadViewInterruption.test.ts create mode 100644 apps/mobile/src/features/threads/threadViewInterruption.ts create mode 100644 apps/mobile/src/features/threads/useSettledThreadView.ts diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 870ab31f00003..19abcfebe5a71 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -200,6 +200,8 @@ import type { ThreadContentPresentation } from "./threadContentPresentation"; import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-live-follow"; import { useOpenLinkedThread } from "./useOpenLinkedThread"; import { useGlobalVoiceInput } from "../voice-input/VoiceInputProvider"; +import { useEnvironmentShellReadiness } from "../../state/shell"; +import { useSettledThreadView } from "./useSettledThreadView"; export interface ThreadDetailScreenProps { readonly header?: React.ReactNode; @@ -1115,14 +1117,16 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { } }; const showContent = props.showContent ?? true; - // Showing the thread counts as viewing it, before its live shell arrives too. - useAtomMount( - threadVisitTracker( - showContent && !props.draftMode - ? { environmentId: props.environmentId, threadId: props.selectedThread.id } - : null, - ), + // Showing the thread counts as viewing it; after a resume or sync, only once it settles. + const shellSynchronizing = + useEnvironmentShellReadiness(props.environmentId).status === "synchronizing"; + const viewedThread = useSettledThreadView( + showContent && !props.draftMode + ? { environmentId: props.environmentId, threadId: props.selectedThread.id } + : null, + shellSynchronizing, ); + useAtomMount(threadVisitTracker(viewedThread)); const layoutVariant = props.layoutVariant ?? "compact"; const isSplitLayout = layoutVariant === "split"; const contentMaxWidth = diff --git a/apps/mobile/src/features/threads/threadViewInterruption.test.ts b/apps/mobile/src/features/threads/threadViewInterruption.test.ts new file mode 100644 index 0000000000000..91d34b1622e27 --- /dev/null +++ b/apps/mobile/src/features/threads/threadViewInterruption.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { nextThreadViewState } from "./threadViewInterruption"; + +describe("nextThreadViewState", () => { + it("counts a thread opened while synced and in the foreground at once", () => { + expect(nextThreadViewState(null, "env:a", true)).toEqual({ key: "env:a", interrupted: false }); + }); + + it("waits for a thread opened while syncing or in the background", () => { + expect(nextThreadViewState(null, "env:a", false)).toEqual({ key: "env:a", interrupted: true }); + }); + + it("keeps waiting after a resume or sync interrupts the view, until another thread opens", () => { + const opened = nextThreadViewState(null, "env:a", true); + const hidden = nextThreadViewState(opened, "env:a", false); + expect(hidden).toEqual({ key: "env:a", interrupted: true }); + expect(nextThreadViewState(hidden, "env:a", true)).toBe(hidden); + expect(nextThreadViewState(hidden, "env:b", true)).toEqual({ + key: "env:b", + interrupted: false, + }); + }); + + it("returns the same state while nothing changes", () => { + const opened = nextThreadViewState(null, "env:a", true); + expect(nextThreadViewState(opened, "env:a", true)).toBe(opened); + expect(nextThreadViewState(opened, null, true)).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/threads/threadViewInterruption.ts b/apps/mobile/src/features/threads/threadViewInterruption.ts new file mode 100644 index 0000000000000..ef4c8113c478d --- /dev/null +++ b/apps/mobile/src/features/threads/threadViewInterruption.ts @@ -0,0 +1,17 @@ +export interface ThreadViewState { + readonly key: string; + /** The view began, or has since been, hidden or syncing, so reading waits for it to settle. */ + readonly interrupted: boolean; +} + +/** The next view state for the shown thread; returns `previous` itself when nothing changed. */ +export function nextThreadViewState( + previous: ThreadViewState | null, + key: string | null, + visible: boolean, +): ThreadViewState | null { + if (key === null) return null; + if (previous?.key !== key) return { key, interrupted: !visible }; + if (!visible && !previous.interrupted) return { key, interrupted: true }; + return previous; +} diff --git a/apps/mobile/src/features/threads/useSettledThreadView.ts b/apps/mobile/src/features/threads/useSettledThreadView.ts new file mode 100644 index 0000000000000..b15f7dd264d09 --- /dev/null +++ b/apps/mobile/src/features/threads/useSettledThreadView.ts @@ -0,0 +1,40 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { useEffect, useMemo, useState } from "react"; + +import { useAppActive } from "../../lib/appState"; +import { scopedThreadKey } from "../../lib/scopedEntities"; +import { nextThreadViewState, type ThreadViewState } from "./threadViewInterruption"; + +/** How long a thread shows, synced and in the foreground, after a resume or sync before it counts as read. */ +const THREAD_READ_DWELL_MS = 2_000; + +/** + * The thread while it counts as viewed, otherwise null. Opening a synced thread counts at once. + * After the app leaves the foreground or the environment syncs, a run that finished meanwhile + * may land before the user has looked, so the view counts again only once the thread has shown, + * synced and in the foreground, for a moment; backing out sooner keeps it unread. + */ +export function useSettledThreadView( + ref: ScopedThreadRef | null, + synchronizing: boolean, +): ScopedThreadRef | null { + const active = useAppActive(); + const key = ref === null ? null : scopedThreadKey(ref.environmentId, ref.threadId); + const visible = active && !synchronizing && key !== null; + const [opened, setOpened] = useState(null); + const next = nextThreadViewState(opened, key, visible); + if (next !== opened) setOpened(next); + const interrupted = next?.interrupted ?? false; + // A fresh view each time the thread becomes visible again, so the wait restarts even if no + // timer ran while the app was in the background. + const view = useMemo(() => (visible && key !== null ? { key } : null), [visible, key]); + const [settled, setSettled] = useState<{ readonly key: string } | null>(null); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- times how long a resumed or synced thread has shown in the foreground + useEffect(() => { + if (view === null || !interrupted) return; + const timer = setTimeout(() => setSettled(view), THREAD_READ_DWELL_MS); + return () => clearTimeout(timer); + }, [view, interrupted]); + if (view === null) return null; + return !interrupted || settled === view ? ref : null; +} From 378e8ef2488bd0719d62aab7f6434d216613e305 Mon Sep 17 00:00:00 2001 From: Shawn <5414767+playcations@users.noreply.github.com> Date: Fri, 6 Nov 2026 06:44:40 -0500 Subject: [PATCH 2/2] fix(mobile): a covered thread does not count down to read after a resume A screen over the thread, such as the browser or device preview, kept the post-resume wait running, so a run that finished during the resume could still be marked read unseen. The wait now runs only while the thread is on top, and restarts when it is uncovered. A synced visit is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../features/threads/ThreadDetailScreen.tsx | 4 +++- .../features/threads/useSettledThreadView.ts | 22 +++++++++++-------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index f462cd67adf79..8ef88e43be319 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -15,7 +15,7 @@ import { parseThreadKey, threadKey } from "@t3tools/client-runtime/state/entitie import { createThreadVisitTracker } from "@t3tools/client-runtime/state/thread-visits"; import { useChildThreadInputs, useThreadReportedModelSelection } from "../../state/entities"; import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard"; -import { useFocusEffect, useNavigation } from "@react-navigation/native"; +import { useFocusEffect, useIsFocused, useNavigation } from "@react-navigation/native"; import { effectiveSnoozed, threadWokeAt, @@ -1222,11 +1222,13 @@ function ThreadDetailBody( // Showing the thread counts as viewing it; after a resume or sync, only once it settles. const shellSynchronizing = useEnvironmentShellReadiness(props.environmentId).status === "synchronizing"; + const covered = !useIsFocused(); const viewedThread = useSettledThreadView( showContent && !props.draftMode ? { environmentId: props.environmentId, threadId: props.selectedThread.id } : null, shellSynchronizing, + covered, ); useAtomMount(threadVisitTracker(viewedThread)); const layoutVariant = props.layoutVariant ?? "compact"; diff --git a/apps/mobile/src/features/threads/useSettledThreadView.ts b/apps/mobile/src/features/threads/useSettledThreadView.ts index b15f7dd264d09..f1e77bc2e5308 100644 --- a/apps/mobile/src/features/threads/useSettledThreadView.ts +++ b/apps/mobile/src/features/threads/useSettledThreadView.ts @@ -9,14 +9,14 @@ import { nextThreadViewState, type ThreadViewState } from "./threadViewInterrupt const THREAD_READ_DWELL_MS = 2_000; /** - * The thread while it counts as viewed, otherwise null. Opening a synced thread counts at once. - * After the app leaves the foreground or the environment syncs, a run that finished meanwhile - * may land before the user has looked, so the view counts again only once the thread has shown, - * synced and in the foreground, for a moment; backing out sooner keeps it unread. + * The thread while it counts as viewed. A synced open counts at once; after a resume or sync a + * finished run may land unseen, so the view counts only once the thread has shown, synced, in the + * foreground and uncovered, for a moment. Backing out sooner keeps it unread. */ export function useSettledThreadView( ref: ScopedThreadRef | null, synchronizing: boolean, + covered: boolean, ): ScopedThreadRef | null { const active = useAppActive(); const key = ref === null ? null : scopedThreadKey(ref.environmentId, ref.threadId); @@ -25,9 +25,12 @@ export function useSettledThreadView( const next = nextThreadViewState(opened, key, visible); if (next !== opened) setOpened(next); const interrupted = next?.interrupted ?? false; - // A fresh view each time the thread becomes visible again, so the wait restarts even if no - // timer ran while the app was in the background. - const view = useMemo(() => (visible && key !== null ? { key } : null), [visible, key]); + // A fresh view each time the thread shows again, so the wait restarts even if no timer ran + // while the app was in the background or the thread was covered. + const view = useMemo( + () => (visible && !covered && key !== null ? { key } : null), + [visible, covered, key], + ); const [settled, setSettled] = useState<{ readonly key: string } | null>(null); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- times how long a resumed or synced thread has shown in the foreground useEffect(() => { @@ -35,6 +38,7 @@ export function useSettledThreadView( const timer = setTimeout(() => setSettled(view), THREAD_READ_DWELL_MS); return () => clearTimeout(timer); }, [view, interrupted]); - if (view === null) return null; - return !interrupted || settled === view ? ref : null; + if (!visible) return null; + if (!interrupted) return ref; + return view !== null && settled === view ? ref : null; }