From 321a7842901fec7ed883cae2ea9bd648e1d38d30 Mon Sep 17 00:00:00 2001 From: Daniel Vernon Date: Thu, 30 Jul 2026 11:16:43 +0100 Subject: [PATCH 1/2] fix(mobile): show done for unread completions --- apps/mobile/src/features/home/HomeScreen.tsx | 23 +++- .../threads/ThreadNavigationSidebar.tsx | 11 ++ .../features/threads/ThreadRouteScreen.tsx | 10 ++ .../features/threads/thread-list-items.tsx | 3 +- .../features/threads/thread-list-v2-items.tsx | 4 +- .../src/features/threads/threadListV2.test.ts | 94 +++++++++++++++ .../src/features/threads/threadListV2.ts | 17 ++- .../threads/threadPresentation.test.ts | 64 +++++++++++ .../features/threads/threadPresentation.ts | 32 +++++- .../src/persistence/mobile-preferences.ts | 14 +++ apps/mobile/src/state/preferences.test.ts | 107 ++++++++++++++++++ apps/mobile/src/state/preferences.ts | 25 +++- apps/mobile/src/state/thread-visits.logic.ts | 34 ++++++ apps/mobile/src/state/thread-visits.test.ts | 45 ++++++++ apps/mobile/src/state/thread-visits.ts | 35 ++++++ 15 files changed, 508 insertions(+), 10 deletions(-) create mode 100644 apps/mobile/src/features/threads/threadPresentation.test.ts create mode 100644 apps/mobile/src/state/thread-visits.logic.ts create mode 100644 apps/mobile/src/state/thread-visits.test.ts create mode 100644 apps/mobile/src/state/thread-visits.ts diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 3c2ee4f7b92..facd2475e99 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -28,13 +28,14 @@ import { AppText as Text } from "../../components/AppText"; import { EmptyState } from "../../components/EmptyState"; import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel"; import type { SavedRemoteConnection } from "../../lib/connection"; -import { scopedProjectKey } from "../../lib/scopedEntities"; +import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { useThreadSearch } from "../../state/queries"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { environmentServerConfigsAtom } from "../../state/server"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; +import { useThreadVisits } from "../../state/thread-visits"; import { PendingTaskListRow, ThreadListGroupHeader, @@ -190,6 +191,7 @@ export function HomeScreen(props: HomeScreenProps) { ReadonlyMap >(() => new Map()); const preferencesResult = useAtomValue(mobilePreferencesAtom); + const { lastVisitedAtByThreadKey } = useThreadVisits(); const threadListV2Enabled = useThreadListV2Enabled(); const savePreferences = useAtomSet(updateMobilePreferencesAtom); const openSwipeableRef = useRef(null); @@ -664,6 +666,9 @@ export function HomeScreen(props: HomeScreenProps) { (thread.session?.providerInstanceId ?? thread.modelSelection.instanceId), )?.driver ?? null } + lastVisitedAt={ + lastVisitedAtByThreadKey[scopedThreadKey(thread.environmentId, thread.id)] ?? null + } environmentLabel={ Object.keys(props.savedConnectionsById).length > 1 ? (props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) @@ -698,6 +703,7 @@ export function HomeScreen(props: HomeScreenProps) { handleSwipeableClose, handleSwipeableWillOpen, handleUnsettleThread, + lastVisitedAtByThreadKey, projectByKey, projectCwdByKey, props.onArchiveThread, @@ -722,6 +728,7 @@ export function HomeScreen(props: HomeScreenProps) { projectByKey, projectCwdByKey, projectTitleByProjectKey: v2ProjectTitleByProjectKey, + lastVisitedAtByThreadKey, serverConfigs, savedConnectionsById: props.savedConnectionsById, searchQuery: props.searchQuery, @@ -730,6 +737,7 @@ export function HomeScreen(props: HomeScreenProps) { [ projectByKey, projectCwdByKey, + lastVisitedAtByThreadKey, props.searchQuery, props.savedConnectionsById, serverConfigs, @@ -741,11 +749,18 @@ export function HomeScreen(props: HomeScreenProps) { const extraData = useMemo( () => ({ projectCwdByKey, + lastVisitedAtByThreadKey, savedConnectionsById: props.savedConnectionsById, searchQuery: props.searchQuery, threadSearchMatchByKey, }), - [projectCwdByKey, props.savedConnectionsById, props.searchQuery, threadSearchMatchByKey], + [ + projectCwdByKey, + lastVisitedAtByThreadKey, + props.savedConnectionsById, + props.searchQuery, + threadSearchMatchByKey, + ], ); const renderItem = useCallback( @@ -797,6 +812,9 @@ export function HomeScreen(props: HomeScreenProps) { projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null } + lastVisitedAt={ + lastVisitedAtByThreadKey[scopedThreadKey(thread.environmentId, thread.id)] ?? null + } isLast={item.isLast} searchMatch={threadSearchMatchByKey.get( threadSearchMatchKey({ @@ -828,6 +846,7 @@ export function HomeScreen(props: HomeScreenProps) { [ handleSwipeableClose, handleSwipeableWillOpen, + lastVisitedAtByThreadKey, projectCwdByKey, props.onArchiveThread, props.onDeletePendingTask, diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 36a86ceb1e3..ff4a34269a0 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -32,6 +32,7 @@ import { useThreadSearch } from "../../state/queries"; import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; import { environmentServerConfigsAtom } from "../../state/server"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; +import { useThreadVisits } from "../../state/thread-visits"; import { useWorkspaceState } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; @@ -185,6 +186,7 @@ function ThreadNavigationSidebarPane( const colorScheme = useColorScheme() === "dark" ? "dark" : "light"; const projects = useProjects(); const threads = useThreadShells(); + const { lastVisitedAtByThreadKey } = useThreadVisits(); const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); const { savedConnectionsById } = useSavedRemoteConnections(); const [headerIsOverContent, setHeaderIsOverContent] = useState(false); @@ -729,6 +731,7 @@ function ThreadNavigationSidebarPane( projectByKey, projectCwdByKey, projectTitleByProjectKey, + lastVisitedAtByThreadKey, savedConnectionsById, serverConfigs, threadSearchMatchByKey, @@ -738,6 +741,7 @@ function ThreadNavigationSidebarPane( projectByKey, projectCwdByKey, projectTitleByProjectKey, + lastVisitedAtByThreadKey, savedConnectionsById, serverConfigs, threadSearchMatchByKey, @@ -850,6 +854,9 @@ function ThreadNavigationSidebarPane( }), )} searchQuery={props.searchQuery} + lastVisitedAt={ + lastVisitedAtByThreadKey[scopedThreadKey(thread.environmentId, thread.id)] ?? null + } pane="sidebar" selected={ scopedThreadKey(thread.environmentId, thread.id) === props.selectedThreadKey @@ -928,6 +935,9 @@ function ThreadNavigationSidebarPane( projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null } + lastVisitedAt={ + lastVisitedAtByThreadKey[scopedThreadKey(thread.environmentId, thread.id)] ?? null + } isLast={item.isLast} searchMatch={threadSearchMatchByKey.get( threadSearchMatchKey({ @@ -969,6 +979,7 @@ function ThreadNavigationSidebarPane( handleSelectThread, handleSwipeableClose, handleSwipeableWillOpen, + lastVisitedAtByThreadKey, openPendingTask, projectByKey, projectCwdByKey, diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 7fb4740ddce..d0a94e2f3c3 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -2,6 +2,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; import { StackActions, useFocusEffect, + useIsFocused, useNavigation, type StaticScreenProps, } from "@react-navigation/native"; @@ -58,6 +59,7 @@ import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-s import { useSelectedThreadRequests } from "../../state/use-selected-thread-requests"; import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; import { useThreadComposerState } from "../../state/use-thread-composer-state"; +import { resolveOpenThreadVisitedAt, useThreadVisits } from "../../state/thread-visits"; import { threadEnvironment } from "../../state/threads"; import { projectThreadContentPresentation } from "./threadContentPresentation"; import { @@ -188,6 +190,8 @@ function ThreadRouteContent( const { onReconnectEnvironment } = useRemoteConnections(); const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } = useThreadSelection(); + const isFocused = useIsFocused(); + const { markVisited } = useThreadVisits(); const selectedThreadDetailState = props.selectedThreadDetailState; const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data); const { selectedThreadCwd } = useSelectedThreadWorktree(); @@ -203,6 +207,12 @@ function ThreadRouteContent( const threadId = firstRouteParam(params.threadId); const routeThreadIdentity = environmentIdRaw !== null && threadId !== null ? `${environmentIdRaw}:${threadId}` : null; + const openThreadVisitedAt = + selectedThread === null ? null : resolveOpenThreadVisitedAt(selectedThread); + useEffect(() => { + if (!isFocused || routeThreadIdentity === null || openThreadVisitedAt === null) return; + markVisited(routeThreadIdentity, openThreadVisitedAt); + }, [isFocused, markVisited, openThreadVisitedAt, routeThreadIdentity]); const [inspectorSelection, setInspectorSelection] = useState( () => (props.renderInspector ? { routeThreadIdentity, mode: "route" } : null), ); diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 9ac4002a9b0..5a6e270d269 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -420,6 +420,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { readonly projectCwd: string | null; readonly searchMatch?: EnvironmentThreadSearchMatch; readonly searchQuery?: string; + readonly lastVisitedAt?: string | null; readonly isLast: boolean; /** Sidebar only: the thread currently open in the detail pane. */ readonly selected?: boolean; @@ -450,7 +451,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { const selectedBackgroundColor = useThemeColor("--color-user-bubble"); const { thread, onSelectThread, onArchiveThread, onDeleteThread } = props; - const status = resolveThreadStatus(thread); + const status = resolveThreadStatus(thread, props.lastVisitedAt); const pr = useThreadPr(thread, props.projectCwd); const timestamp = relativeTime( thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 6af5795a94a..3d986ff5a8c 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -44,6 +44,7 @@ const STATUS_LABEL_BY_STATUS: Partial< input: { label: "Input", className: "text-indigo-600 dark:text-indigo-300" }, working: { label: "Working", className: "text-sky-600 dark:text-sky-400" }, failed: { label: "Failed", className: "text-red-700 dark:text-red-300" }, + done: { label: "Done", className: "text-emerald-700 dark:text-emerald-300" }, }; function threadTimeLabel(thread: EnvironmentThreadShell): string { @@ -212,6 +213,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly project: EnvironmentProject | null; readonly projectTitle?: string; readonly providerDriver: string | null; + readonly lastVisitedAt?: string | null; /** Which machine hosts the thread. Null when only one environment is connected — repeating the same label on every row is noise. Mirrors the web sidebar's remote-environment cloud icon, but as text since @@ -277,7 +279,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const sidebarPane = props.pane === "sidebar"; const selected = props.selected === true; - const status = resolveThreadListV2Status(thread); + const status = resolveThreadListV2Status(thread, { lastVisitedAt: props.lastVisitedAt }); const statusLabel = STATUS_LABEL_BY_STATUS[status]; const timeLabel = threadTimeLabel(thread); diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 90b5897f18e..708ed669fcf 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -94,6 +94,100 @@ describe("resolveThreadListV2Status", () => { "ready", ); }); + + it("resolves done when a turn completed after the thread was last visited", () => { + const thread = makeThread({ + id: ThreadId.make("t"), + title: "t", + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "completed", + requestedAt: "2026-06-01T12:00:00.000Z", + startedAt: "2026-06-01T12:00:01.000Z", + completedAt: "2026-06-01T12:01:00.000Z", + assistantMessageId: MessageId.make("assistant-1"), + }, + }); + + expect( + resolveThreadListV2Status(thread, { + lastVisitedAt: "2026-06-01T12:00:30.000Z", + }), + ).toBe("done"); + }); + + it("keeps a completion read when the thread was visited at completion", () => { + const thread = makeThread({ + id: ThreadId.make("t"), + title: "t", + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "completed", + requestedAt: "2026-06-01T12:00:00.000Z", + startedAt: "2026-06-01T12:00:01.000Z", + completedAt: "2026-06-01T12:01:00.000Z", + assistantMessageId: MessageId.make("assistant-1"), + }, + }); + + expect( + resolveThreadListV2Status(thread, { + lastVisitedAt: "2026-06-01T12:01:00.000Z", + }), + ).toBe("ready"); + }); + + it("does not label an interrupted turn Done", () => { + const thread = makeThread({ + id: ThreadId.make("t"), + title: "t", + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "interrupted", + requestedAt: "2026-06-01T12:00:00.000Z", + startedAt: "2026-06-01T12:00:01.000Z", + completedAt: "2026-06-01T12:01:00.000Z", + assistantMessageId: null, + }, + }); + + expect( + resolveThreadListV2Status(thread, { + lastVisitedAt: "2026-06-01T12:00:30.000Z", + }), + ).toBe("ready"); + }); + + it("prioritizes an active session over an unread completion", () => { + const thread = makeThread({ + id: ThreadId.make("t"), + title: "t", + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "completed", + requestedAt: "2026-06-01T12:00:00.000Z", + startedAt: "2026-06-01T12:00:01.000Z", + completedAt: "2026-06-01T12:01:00.000Z", + assistantMessageId: MessageId.make("assistant-1"), + }, + session: { + threadId: ThreadId.make("t"), + status: "running", + providerName: "Codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }, + }); + + expect( + resolveThreadListV2Status(thread, { + lastVisitedAt: "2026-06-01T12:00:30.000Z", + }), + ).toBe("working"); + }); }); describe("sortThreadsForListV2", () => { diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 920b7f0b53a..497516e54b7 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -4,6 +4,7 @@ import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-searc import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; +import { hasUnseenCompletion } from "./threadPresentation"; /** * Thread List v2 model, ported from the web sidebar v2 @@ -13,7 +14,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks"; * (approval), "in motion" (working), and "broken" (failed). Ready is the * unlabeled resting state. */ -export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready"; +export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "done" | "ready"; // Settled-tail paging: recent history is the common lookup; the deep tail // stays behind an explicit Show more. Shared by the compact Home list and @@ -42,7 +43,11 @@ export function resolveThreadListV2Enabled(input: { } export function resolveThreadListV2Status( - thread: Pick, + thread: Pick< + EnvironmentThreadShell, + "hasPendingApprovals" | "hasPendingUserInput" | "latestTurn" | "session" + >, + input?: { readonly lastVisitedAt?: string | null }, ): ThreadListV2Status { if (thread.hasPendingApprovals) { return "approval"; @@ -56,6 +61,14 @@ export function resolveThreadListV2Status( if (thread.session?.status === "error") { return "failed"; } + if ( + hasUnseenCompletion({ + latestTurn: thread.latestTurn, + lastVisitedAt: input?.lastVisitedAt, + }) + ) { + return "done"; + } return "ready"; } diff --git a/apps/mobile/src/features/threads/threadPresentation.test.ts b/apps/mobile/src/features/threads/threadPresentation.test.ts new file mode 100644 index 00000000000..ba27c747235 --- /dev/null +++ b/apps/mobile/src/features/threads/threadPresentation.test.ts @@ -0,0 +1,64 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { + EnvironmentId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolveThreadStatus } from "./threadPresentation"; + +function makeCompletedThread(): EnvironmentThreadShell { + return { + environmentId: EnvironmentId.make("environment-1"), + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Completed thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "completed", + requestedAt: "2026-06-01T12:00:00.000Z", + startedAt: "2026-06-01T12:00:01.000Z", + completedAt: "2026-06-01T12:01:00.000Z", + assistantMessageId: MessageId.make("assistant-1"), + }, + createdAt: "2026-06-01T11:00:00.000Z", + updatedAt: "2026-06-01T12:01:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: "2026-06-01T12:00:00.000Z", + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }; +} + +describe("resolveThreadStatus completion state", () => { + it("shows Done after a turn completes while the thread is away", () => { + expect(resolveThreadStatus(makeCompletedThread(), "2026-06-01T12:00:30.000Z")).toMatchObject({ + kind: "done", + label: "Done", + }); + }); + + it("clears Done once the completion has been visited", () => { + expect(resolveThreadStatus(makeCompletedThread(), "2026-06-01T12:01:00.000Z")).toBeNull(); + }); + + it("does not mark existing history unread on first launch", () => { + expect(resolveThreadStatus(makeCompletedThread())).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/threads/threadPresentation.ts b/apps/mobile/src/features/threads/threadPresentation.ts index 9de3d4d3089..aa7eab75075 100644 --- a/apps/mobile/src/features/threads/threadPresentation.ts +++ b/apps/mobile/src/features/threads/threadPresentation.ts @@ -1,6 +1,6 @@ import type { StatusTone } from "../../components/StatusPill"; import type { OrchestrationLatestTurn, OrchestrationSession } from "@t3tools/contracts"; -import { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; export function threadSortValue(thread: EnvironmentThreadShell): number { const candidate = Date.parse(thread.updatedAt ?? thread.createdAt); @@ -13,7 +13,8 @@ export type ThreadStatusKind = | "working" | "connecting" | "error" - | "plan-ready"; + | "plan-ready" + | "done"; export interface ThreadStatusPresentation extends StatusTone { readonly kind: ThreadStatusKind; @@ -41,6 +42,20 @@ function isLatestTurnSettled( return session.status !== "running"; } +export function hasUnseenCompletion(input: { + readonly latestTurn: OrchestrationLatestTurn | null; + readonly lastVisitedAt: string | null | undefined; +}): boolean { + if (input.latestTurn?.state !== "completed" || !input.latestTurn.completedAt) return false; + const completedAt = Date.parse(input.latestTurn.completedAt); + if (Number.isNaN(completedAt)) return false; + if (!input.lastVisitedAt) return false; + + const lastVisitedAt = Date.parse(input.lastVisitedAt); + if (Number.isNaN(lastVisitedAt)) return true; + return completedAt > lastVisitedAt; +} + /** * Resolves the user-facing status of a thread, in priority order. Returns * `null` for quiescent threads so rows stay free of "Idle"-style noise. @@ -48,6 +63,7 @@ function isLatestTurnSettled( */ export function resolveThreadStatus( thread: EnvironmentThreadShell, + lastVisitedAt?: string | null, ): ThreadStatusPresentation | null { if (thread.hasPendingApprovals) { return { @@ -125,5 +141,17 @@ export function resolveThreadStatus( }; } + if (hasUnseenCompletion({ latestTurn: thread.latestTurn, lastVisitedAt })) { + return { + kind: "done", + label: "Done", + pillClassName: "bg-emerald-500/12 dark:bg-emerald-500/16", + textClassName: "text-emerald-700 dark:text-emerald-300", + iconColor: "#30d158", + iconBackground: "rgba(48,209,88,0.22)", + pulse: false, + }; + } + return null; } diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 6b1018e2a0a..659ed3cac44 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -23,6 +23,7 @@ export interface Preferences { readonly connectOnboardingOptOutAccounts?: ReadonlyArray; readonly collapsedProjectGroups?: readonly string[]; readonly projectGroupingEnabled?: boolean; + readonly threadLastVisitedAtById?: Readonly>; /** * Device-local mirror of the web beta's `sidebarV2Enabled`. Mobile has no * client-settings sync, so the flat v2 thread list is opted out of per @@ -80,6 +81,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { connectOnboardingOptOutAccounts?: ReadonlyArray; collapsedProjectGroups?: readonly string[]; projectGroupingEnabled?: boolean; + threadLastVisitedAtById?: Readonly>; threadListV2Enabled?: boolean; } = {}; @@ -110,6 +112,18 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (typeof parsed.projectGroupingEnabled === "boolean") { preferences.projectGroupingEnabled = parsed.projectGroupingEnabled; } + if ( + typeof parsed.threadLastVisitedAtById === "object" && + parsed.threadLastVisitedAtById !== null && + !Array.isArray(parsed.threadLastVisitedAtById) + ) { + preferences.threadLastVisitedAtById = Object.fromEntries( + Object.entries(parsed.threadLastVisitedAtById).filter( + (entry): entry is [string, string] => + typeof entry[1] === "string" && !Number.isNaN(Date.parse(entry[1])), + ), + ); + } if (typeof parsed.threadListV2Enabled === "boolean") { preferences.threadListV2Enabled = parsed.threadListV2Enabled; } diff --git a/apps/mobile/src/state/preferences.test.ts b/apps/mobile/src/state/preferences.test.ts index c53594eb230..f3d7751e80d 100644 --- a/apps/mobile/src/state/preferences.test.ts +++ b/apps/mobile/src/state/preferences.test.ts @@ -124,6 +124,58 @@ describe("mobile preferences state", () => { }), ); + it.effect("persists a functional update made before the initial load finishes", () => + Effect.gen(function* () { + const pendingLoad = deferred(); + let stored: Preferences = { + threadLastVisitedAtById: { + existing: "2026-06-01T12:00:00.000Z", + }, + }; + const state = makePreferencesState({ + load: Effect.promise(() => pendingLoad.promise), + savePatch: (patch) => Effect.succeed(patch), + update: (transform) => + Effect.sync(() => { + stored = { ...stored, ...transform(stored) }; + return stored; + }), + }); + const registry = AtomRegistry.make(); + const unmountPreferences = registry.mount(state.preferencesAtom); + const unmountUpdate = registry.mount(state.updatePreferencesAtom); + + registry.set(state.updatePreferencesAtom, (current) => ({ + threadLastVisitedAtById: { + ...current.threadLastVisitedAtById, + opened: "2026-06-01T12:01:00.000Z", + }, + })); + pendingLoad.resolve({ + threadLastVisitedAtById: { + existing: "2026-06-01T12:00:00.000Z", + }, + }); + + yield* Effect.promise(() => + vi.waitFor(() => { + expect(Option.getOrThrow(AsyncResult.value(registry.get(state.preferencesAtom)))).toEqual( + { + threadLastVisitedAtById: { + existing: "2026-06-01T12:00:00.000Z", + opened: "2026-06-01T12:01:00.000Z", + }, + }, + ); + }), + ); + + unmountUpdate(); + unmountPreferences(); + registry.dispose(); + }), + ); + it.effect("falls back to empty preferences when secure storage cannot be read", () => Effect.gen(function* () { const state = makePreferencesState({ @@ -188,6 +240,61 @@ describe("mobile preferences state", () => { }), ); + it.effect("merges concurrent functional updates against the latest preferences", () => + Effect.gen(function* () { + let stored: Preferences = { + threadLastVisitedAtById: { + existing: "2026-06-01T12:00:00.000Z", + }, + }; + const state = makePreferencesState({ + load: Effect.sync(() => stored), + savePatch: (patch) => Effect.succeed(patch), + update: (transform) => + Effect.sync(() => { + stored = { ...stored, ...transform(stored) }; + return stored; + }), + }); + const registry = AtomRegistry.make(); + const unmountPreferences = registry.mount(state.preferencesAtom); + const unmountUpdate = registry.mount(state.updatePreferencesAtom); + + yield* AtomRegistry.getResult(registry, state.preferencesAtom, { + suspendOnWaiting: true, + }); + registry.set(state.updatePreferencesAtom, (current) => ({ + threadLastVisitedAtById: { + ...current.threadLastVisitedAtById, + first: "2026-06-01T12:01:00.000Z", + }, + })); + registry.set(state.updatePreferencesAtom, (current) => ({ + threadLastVisitedAtById: { + ...current.threadLastVisitedAtById, + second: "2026-06-01T12:02:00.000Z", + }, + })); + + yield* Effect.promise(() => + vi.waitFor(() => { + expect(stored.threadLastVisitedAtById).toEqual({ + existing: "2026-06-01T12:00:00.000Z", + first: "2026-06-01T12:01:00.000Z", + second: "2026-06-01T12:02:00.000Z", + }); + expect(Option.getOrThrow(AsyncResult.value(registry.get(state.preferencesAtom)))).toEqual( + stored, + ); + }), + ); + + unmountUpdate(); + unmountPreferences(); + registry.dispose(); + }), + ); + it.effect("rolls back an optimistic field when its save fails", () => Effect.gen(function* () { const state = makePreferencesState({ diff --git a/apps/mobile/src/state/preferences.ts b/apps/mobile/src/state/preferences.ts index d173cf55be5..df38c1c5a63 100644 --- a/apps/mobile/src/state/preferences.ts +++ b/apps/mobile/src/state/preferences.ts @@ -15,6 +15,8 @@ interface OptimisticPreferences { readonly versions: Partial>; } +type PreferencesUpdate = Partial | ((current: Preferences) => Partial); + /** * Owns the device preference blob for the lifetime of the app registry. * Optimistic patches are kept separately so writes made while persistence is @@ -57,7 +59,24 @@ export function createMobilePreferencesState(runtime: Atom.AtomRuntime, get) => { + (update: PreferencesUpdate, get) => { + const currentPreferences = get(preferencesAtom); + let patch: Partial; + if (typeof update === "function") { + if (!AsyncResult.isSuccess(currentPreferences)) { + return MobilePreferencesStore.pipe( + Effect.flatMap((store) => store.update(update)), + Effect.tap((saved) => + Effect.sync(() => { + get.set(confirmedPreferencesAtom, saved); + }), + ), + ); + } + patch = update(currentPreferences.value); + } else { + patch = update; + } const version = ++nextPatchVersion; const current = get(optimisticPatchAtom); const versions = { ...current.versions }; @@ -69,7 +88,9 @@ export function createMobilePreferencesState(runtime: Atom.AtomRuntime store.savePatch(patch)), + Effect.flatMap((store) => + typeof update === "function" ? store.update(update) : store.savePatch(patch), + ), Effect.tap((saved) => Effect.sync(() => { get.set(confirmedPreferencesAtom, saved); diff --git a/apps/mobile/src/state/thread-visits.logic.ts b/apps/mobile/src/state/thread-visits.logic.ts new file mode 100644 index 00000000000..61a4e4cd6fb --- /dev/null +++ b/apps/mobile/src/state/thread-visits.logic.ts @@ -0,0 +1,34 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; + +export function markThreadVisited( + current: Readonly>, + threadKey: string, + visitedAt: string, +): Readonly> { + const visitedAtMs = Date.parse(visitedAt); + if (Number.isNaN(visitedAtMs)) return current; + + const previous = current[threadKey]; + const previousMs = previous === undefined ? Number.NaN : Date.parse(previous); + if (!Number.isNaN(previousMs) && previousMs >= visitedAtMs) return current; + + return { ...current, [threadKey]: visitedAt }; +} + +export function resolveOpenThreadVisitedAt( + thread: Pick, +): string { + const candidates = [ + thread.latestTurn?.completedAt, + thread.latestTurn?.startedAt, + thread.latestTurn?.requestedAt, + thread.updatedAt, + thread.createdAt, + ]; + for (const candidate of candidates) { + if (candidate !== null && candidate !== undefined && !Number.isNaN(Date.parse(candidate))) { + return candidate; + } + } + return thread.createdAt; +} diff --git a/apps/mobile/src/state/thread-visits.test.ts b/apps/mobile/src/state/thread-visits.test.ts new file mode 100644 index 00000000000..957bab09d0f --- /dev/null +++ b/apps/mobile/src/state/thread-visits.test.ts @@ -0,0 +1,45 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { TurnId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { markThreadVisited, resolveOpenThreadVisitedAt } from "./thread-visits.logic"; + +describe("markThreadVisited", () => { + it("keeps the newest valid visit timestamp", () => { + const initial = { thread: "2026-06-01T12:00:00.000Z" }; + expect(markThreadVisited(initial, "thread", "2026-06-01T11:59:00.000Z")).toBe(initial); + expect(markThreadVisited(initial, "thread", "invalid")).toBe(initial); + expect(markThreadVisited(initial, "thread", "2026-06-01T12:01:00.000Z")).toEqual({ + thread: "2026-06-01T12:01:00.000Z", + }); + }); +}); + +describe("resolveOpenThreadVisitedAt", () => { + it("advances from the running turn start to its completion", () => { + const thread = { + createdAt: "2026-06-01T11:00:00.000Z", + updatedAt: "2026-06-01T12:00:00.000Z", + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "running", + requestedAt: "2026-06-01T12:01:00.000Z", + startedAt: "2026-06-01T12:01:01.000Z", + completedAt: null, + assistantMessageId: null, + }, + } satisfies Pick; + + expect(resolveOpenThreadVisitedAt(thread)).toBe("2026-06-01T12:01:01.000Z"); + expect( + resolveOpenThreadVisitedAt({ + ...thread, + latestTurn: { + ...thread.latestTurn, + state: "completed", + completedAt: "2026-06-01T12:02:00.000Z", + }, + }), + ).toBe("2026-06-01T12:02:00.000Z"); + }); +}); diff --git a/apps/mobile/src/state/thread-visits.ts b/apps/mobile/src/state/thread-visits.ts new file mode 100644 index 00000000000..26087ee3b19 --- /dev/null +++ b/apps/mobile/src/state/thread-visits.ts @@ -0,0 +1,35 @@ +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { useCallback } from "react"; + +import type { Preferences } from "../persistence/mobile-preferences"; +import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "./preferences"; +import { markThreadVisited } from "./thread-visits.logic"; + +export { resolveOpenThreadVisitedAt } from "./thread-visits.logic"; + +const EMPTY_VISITS: Readonly> = {}; + +export function useThreadVisits(): { + readonly lastVisitedAtByThreadKey: Readonly>; + readonly markVisited: (threadKey: string, visitedAt: string) => void; +} { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const lastVisitedAtByThreadKey = AsyncResult.isSuccess(preferencesResult) + ? (preferencesResult.value.threadLastVisitedAtById ?? EMPTY_VISITS) + : EMPTY_VISITS; + + const markVisited = useCallback( + (threadKey: string, visitedAt: string) => { + savePreferences((current: Preferences) => { + const visits = current.threadLastVisitedAtById ?? EMPTY_VISITS; + const next = markThreadVisited(visits, threadKey, visitedAt); + return next === visits ? {} : { threadLastVisitedAtById: next }; + }); + }, + [savePreferences], + ); + + return { lastVisitedAtByThreadKey, markVisited }; +} From 6c625c82a3c4e698c3b50d9a143992d47ca42f2e Mon Sep 17 00:00:00 2001 From: Daniel Vernon Date: Thu, 30 Jul 2026 15:51:09 +0100 Subject: [PATCH 2/2] fix(mobile): keep background completions unread --- .../features/threads/ThreadRouteScreen.tsx | 42 ++++++++++++++++--- apps/mobile/src/state/thread-visits.logic.ts | 8 ++++ apps/mobile/src/state/thread-visits.test.ts | 20 ++++++++- apps/mobile/src/state/thread-visits.ts | 2 +- 4 files changed, 65 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index d0a94e2f3c3..247ddf9e357 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -6,11 +6,19 @@ import { useNavigation, type StaticScreenProps, } from "@react-navigation/native"; -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, + type ReactNode, +} from "react"; import * as Option from "effect/Option"; import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; -import { Platform, ScrollView, View } from "react-native"; +import { AppState, Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useWorkspaceState } from "../../state/workspace"; import { useEnvironmentQuery } from "../../state/query"; @@ -59,7 +67,11 @@ import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-s import { useSelectedThreadRequests } from "../../state/use-selected-thread-requests"; import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; import { useThreadComposerState } from "../../state/use-thread-composer-state"; -import { resolveOpenThreadVisitedAt, useThreadVisits } from "../../state/thread-visits"; +import { + resolveOpenThreadVisitedAt, + shouldMarkThreadVisited, + useThreadVisits, +} from "../../state/thread-visits"; import { threadEnvironment } from "../../state/threads"; import { projectThreadContentPresentation } from "./threadContentPresentation"; import { @@ -81,6 +93,15 @@ interface ThreadInspectorSelection { type NativeHeaderItems = ReadonlyArray>; +function subscribeToAppState(onStoreChange: () => void): () => void { + const subscription = AppState.addEventListener("change", onStoreChange); + return () => subscription.remove(); +} + +function getAppStateSnapshot() { + return AppState.currentState; +} + function InspectorPaneRoleActivation() { useAdaptiveWorkspacePaneRole("inspector"); return null; @@ -191,6 +212,11 @@ function ThreadRouteContent( const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } = useThreadSelection(); const isFocused = useIsFocused(); + const appState = useSyncExternalStore( + subscribeToAppState, + getAppStateSnapshot, + getAppStateSnapshot, + ); const { markVisited } = useThreadVisits(); const selectedThreadDetailState = props.selectedThreadDetailState; const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data); @@ -210,9 +236,15 @@ function ThreadRouteContent( const openThreadVisitedAt = selectedThread === null ? null : resolveOpenThreadVisitedAt(selectedThread); useEffect(() => { - if (!isFocused || routeThreadIdentity === null || openThreadVisitedAt === null) return; + if ( + !shouldMarkThreadVisited({ appState, isFocused }) || + routeThreadIdentity === null || + openThreadVisitedAt === null + ) { + return; + } markVisited(routeThreadIdentity, openThreadVisitedAt); - }, [isFocused, markVisited, openThreadVisitedAt, routeThreadIdentity]); + }, [appState, isFocused, markVisited, openThreadVisitedAt, routeThreadIdentity]); const [inspectorSelection, setInspectorSelection] = useState( () => (props.renderInspector ? { routeThreadIdentity, mode: "route" } : null), ); diff --git a/apps/mobile/src/state/thread-visits.logic.ts b/apps/mobile/src/state/thread-visits.logic.ts index 61a4e4cd6fb..efa877be5e7 100644 --- a/apps/mobile/src/state/thread-visits.logic.ts +++ b/apps/mobile/src/state/thread-visits.logic.ts @@ -1,4 +1,12 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import type { AppStateStatus } from "react-native"; + +export function shouldMarkThreadVisited(props: { + readonly appState: AppStateStatus; + readonly isFocused: boolean; +}): boolean { + return props.appState === "active" && props.isFocused; +} export function markThreadVisited( current: Readonly>, diff --git a/apps/mobile/src/state/thread-visits.test.ts b/apps/mobile/src/state/thread-visits.test.ts index 957bab09d0f..8e9f84a5b72 100644 --- a/apps/mobile/src/state/thread-visits.test.ts +++ b/apps/mobile/src/state/thread-visits.test.ts @@ -2,7 +2,25 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell import { TurnId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { markThreadVisited, resolveOpenThreadVisitedAt } from "./thread-visits.logic"; +import { + markThreadVisited, + resolveOpenThreadVisitedAt, + shouldMarkThreadVisited, +} from "./thread-visits.logic"; + +describe("shouldMarkThreadVisited", () => { + const visibleThread = { + appState: "active", + isFocused: true, + } as const; + + it("only marks a focused thread while the app is active", () => { + expect(shouldMarkThreadVisited(visibleThread)).toBe(true); + expect(shouldMarkThreadVisited({ ...visibleThread, appState: "background" })).toBe(false); + expect(shouldMarkThreadVisited({ ...visibleThread, appState: "inactive" })).toBe(false); + expect(shouldMarkThreadVisited({ ...visibleThread, isFocused: false })).toBe(false); + }); +}); describe("markThreadVisited", () => { it("keeps the newest valid visit timestamp", () => { diff --git a/apps/mobile/src/state/thread-visits.ts b/apps/mobile/src/state/thread-visits.ts index 26087ee3b19..b5a021ebf1f 100644 --- a/apps/mobile/src/state/thread-visits.ts +++ b/apps/mobile/src/state/thread-visits.ts @@ -6,7 +6,7 @@ import type { Preferences } from "../persistence/mobile-preferences"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "./preferences"; import { markThreadVisited } from "./thread-visits.logic"; -export { resolveOpenThreadVisitedAt } from "./thread-visits.logic"; +export { resolveOpenThreadVisitedAt, shouldMarkThreadVisited } from "./thread-visits.logic"; const EMPTY_VISITS: Readonly> = {};