From 9eba1252c1f8264b6951068d2e2396363412299c Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 24 Aug 2026 02:51:52 -0700 Subject: [PATCH] fix(mobile): persist thread shelf collapse state (#5152) --- apps/mobile/src/features/home/HomeScreen.tsx | 15 +++++-- .../threads/ThreadNavigationSidebar.tsx | 15 +++++-- .../features/threads/thread-list-v2-items.tsx | 8 +++- .../use-thread-list-v2-shelf-preferences.ts | 45 +++++++++++++++++++ apps/mobile/src/lib/storage.test.ts | 34 ++++++++++++++ .../src/persistence/mobile-preferences.ts | 12 +++++ 6 files changed, 119 insertions(+), 10 deletions(-) create mode 100644 apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 642f7afe12ba11e46b8750f8dba6d91a43dfc322..0026876696d6df507a61c2eb5718cbdaa49be989 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -56,6 +56,7 @@ import { THREAD_LIST_V2_SETTLED_PAGE_COUNT, type ThreadListV2ListItem, } from "../threads/threadListV2"; +import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences"; import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu"; import { buildHomeListLayout, @@ -572,10 +573,13 @@ export function HomeScreen(props: HomeScreenProps) { () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT), [], ); - const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useState(false); - const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []); - const [settledShelfExpanded, setSettledShelfExpanded] = useState(true); - const toggleSettledShelf = useCallback(() => setSettledShelfExpanded((value) => !value), []); + const { + loaded: shelfPreferencesLoaded, + settledShelfExpanded, + snoozedShelfExpanded, + toggleSettledShelf, + toggleSnoozedShelf, + } = useThreadListV2ShelfPreferences(); // now is quantized to the minute and ticks so the inactivity auto-settle // boundary is actually crossed while the app stays open (mirrors web); // without a clock dependency the partition memoizes a frozen "now". @@ -785,6 +789,7 @@ export function HomeScreen(props: HomeScreenProps) { return ( @@ -794,6 +799,7 @@ export function HomeScreen(props: HomeScreenProps) { return ( @@ -890,6 +896,7 @@ export function HomeScreen(props: HomeScreenProps) { props.onSelectThread, props.savedConnectionsById, serverConfigs, + shelfPreferencesLoaded, settlementEnvironmentIds, snoozeEnvironmentIds, threadListV2Items, diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 2e8186fa8e25c67a69416d9136fb5dba2dece8b0..856e5ac23435740d827db7a15c235a0ed164624f 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -34,6 +34,7 @@ import { useProjects, useThreadShells } from "../../state/entities"; import { mobilePreferencesAtom } from "../../state/preferences"; import { useThreadSearch } from "../../state/queries"; import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; +import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; import { environmentServerConfigsAtom } from "../../state/server"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; import { useWorkspaceState } from "../../state/workspace"; @@ -460,10 +461,13 @@ function ThreadNavigationSidebarPane( () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT), [], ); - const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useState(false); - const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []); - const [settledShelfExpanded, setSettledShelfExpanded] = useState(true); - const toggleSettledShelf = useCallback(() => setSettledShelfExpanded((value) => !value), []); + const { + loaded: shelfPreferencesLoaded, + settledShelfExpanded, + snoozedShelfExpanded, + toggleSettledShelf, + toggleSnoozedShelf, + } = useThreadListV2ShelfPreferences(); // now ticks per minute so the inactivity auto-settle boundary is actually // crossed while the pane stays open; without a clock dependency the // partition memoizes a frozen "now". @@ -1008,6 +1012,7 @@ function ThreadNavigationSidebarPane( return ( void; readonly pane?: "screen" | "sidebar"; @@ -126,11 +127,12 @@ export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedS } accessibilityLabel={props.count === 1 ? "1 snoozed thread" : `${props.count} snoozed threads`} accessibilityRole="button" - accessibilityState={{ expanded: props.expanded }} + accessibilityState={{ disabled: props.disabled, expanded: props.expanded }} className={cn( "mb-1.5 mt-4 flex-row items-center gap-2.5", props.pane === "sidebar" ? "px-3" : "px-5", )} + disabled={props.disabled} onPress={props.onToggle} style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} > @@ -151,6 +153,7 @@ export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedS export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledShelfHeader(props: { readonly count: number; + readonly disabled?: boolean; readonly expanded: boolean; readonly onToggle: () => void; readonly pane?: "screen" | "sidebar"; @@ -163,11 +166,12 @@ export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledS } accessibilityLabel={props.count === 1 ? "1 settled thread" : `${props.count} settled threads`} accessibilityRole="button" - accessibilityState={{ expanded: props.expanded }} + accessibilityState={{ disabled: props.disabled, expanded: props.expanded }} className={cn( "mb-1.5 mt-4 flex-row items-center gap-2.5", props.pane === "sidebar" ? "px-3" : "px-5", )} + disabled={props.disabled} onPress={props.onToggle} style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} > diff --git a/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts b/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts new file mode 100644 index 0000000000000000000000000000000000000000..d459933647210bbb5cb2d57540027bfdbcb84d57 --- /dev/null +++ b/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts @@ -0,0 +1,45 @@ +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { useCallback, useRef } from "react"; + +import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; + +/** + * Shared persisted shelf state for the compact Home list and iPad sidebar. + * Refs advance before persistence starts so consecutive presses always toggle + * the latest value, even if React has not rendered the optimistic patch yet. + */ +export function useThreadListV2ShelfPreferences() { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const loaded = AsyncResult.isSuccess(preferencesResult); + const snoozedShelfExpanded = + loaded && preferencesResult.value.threadListV2SnoozedShelfExpanded === true; + const settledShelfExpanded = + !loaded || preferencesResult.value.threadListV2SettledShelfExpanded !== false; + const snoozedShelfExpandedRef = useRef(snoozedShelfExpanded); + const settledShelfExpandedRef = useRef(settledShelfExpanded); + snoozedShelfExpandedRef.current = snoozedShelfExpanded; + settledShelfExpandedRef.current = settledShelfExpanded; + + const toggleSnoozedShelf = useCallback(() => { + if (!loaded) return; + const expanded = !snoozedShelfExpandedRef.current; + snoozedShelfExpandedRef.current = expanded; + savePreferences({ threadListV2SnoozedShelfExpanded: expanded }); + }, [loaded, savePreferences]); + const toggleSettledShelf = useCallback(() => { + if (!loaded) return; + const expanded = !settledShelfExpandedRef.current; + settledShelfExpandedRef.current = expanded; + savePreferences({ threadListV2SettledShelfExpanded: expanded }); + }, [loaded, savePreferences]); + + return { + loaded, + settledShelfExpanded, + snoozedShelfExpanded, + toggleSettledShelf, + toggleSnoozedShelf, + } as const; +} diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index 7b94dc6291547fdd1a8f8db7023bb1f4fd36cd12..fe022c1191ae13a851e20b43de2fb554f4f2cc2b 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -207,6 +207,40 @@ describe("mobile connection storage", () => { expect(fallback.updatedAt).toEqual(expect.any(Number)); }); + it("persists Thread List v2 shelf expansion preferences", async () => { + await expect( + savePreferencesPatch({ + threadListV2SettledShelfExpanded: false, + threadListV2SnoozedShelfExpanded: true, + }), + ).resolves.toEqual({ + threadListV2SettledShelfExpanded: false, + threadListV2SnoozedShelfExpanded: true, + }); + + await expect(loadPreferences()).resolves.toEqual({ + threadListV2SettledShelfExpanded: false, + threadListV2SnoozedShelfExpanded: true, + }); + expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ + threadListV2SettledShelfExpanded: false, + threadListV2SnoozedShelfExpanded: true, + }); + }); + + it("ignores invalid Thread List v2 shelf expansion preference types", async () => { + mocks.setPreferencesJson( + JSON.stringify({ + baseFontSize: 17, + threadListV2SettledShelfExpanded: "false", + threadListV2SnoozedShelfExpanded: 1, + }), + 10, + ); + + await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 17 }); + }); + it("reconciles fallback preferences after SQLite recovers", async () => { mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 15 }), 10); await mocks.setItemAsync( diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index dfaeab9cd6ba45c26565d9a1c83f4b7fb52dedef..5d0bd8a3c9dc65f95a9e8ab653396f1c65577074 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -42,6 +42,10 @@ export interface Preferences { readonly legacyThreadListEnabled?: boolean; /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */ readonly planModeEnabled?: boolean; + /** Undefined preserves the default expanded Settled shelf. */ + readonly threadListV2SettledShelfExpanded?: boolean; + /** Undefined preserves the default collapsed Snoozed shelf. */ + readonly threadListV2SnoozedShelfExpanded?: boolean; } export class MobilePreferencesLoadError extends Schema.TaggedErrorClass()( @@ -100,6 +104,8 @@ function sanitizePreferences(parsed: Preferences): Preferences { autoSettleOnMerge?: boolean; legacyThreadListEnabled?: boolean; planModeEnabled?: boolean; + threadListV2SettledShelfExpanded?: boolean; + threadListV2SnoozedShelfExpanded?: boolean; } = {}; if (typeof parsed.liveActivitiesEnabled === "boolean") { @@ -170,6 +176,12 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (typeof parsed.planModeEnabled === "boolean") { preferences.planModeEnabled = parsed.planModeEnabled; } + if (typeof parsed.threadListV2SettledShelfExpanded === "boolean") { + preferences.threadListV2SettledShelfExpanded = parsed.threadListV2SettledShelfExpanded; + } + if (typeof parsed.threadListV2SnoozedShelfExpanded === "boolean") { + preferences.threadListV2SnoozedShelfExpanded = parsed.threadListV2SnoozedShelfExpanded; + } return preferences; } -- 2.54.0