diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index d54fb7d4890..a998ce39916 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -54,6 +54,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, @@ -533,10 +534,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". @@ -698,6 +702,7 @@ export function HomeScreen(props: HomeScreenProps) { return ( @@ -707,6 +712,7 @@ export function HomeScreen(props: HomeScreenProps) { return ( @@ -782,6 +788,7 @@ export function HomeScreen(props: HomeScreenProps) { props.onSelectThread, props.savedConnectionsById, serverConfigs, + shelfPreferencesLoaded, settlementEnvironmentIds, snoozeEnvironmentIds, threadSearchMatchByKey, diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 8a7fc2ed6df..f7cb87d6aab 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -30,6 +30,7 @@ import { useThemeColor } from "../../lib/useThemeColor"; import { useProjects, useThreadShells } from "../../state/entities"; 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"; @@ -439,10 +440,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". @@ -930,6 +934,7 @@ function ThreadNavigationSidebarPane( return ( void; readonly pane?: "screen" | "sidebar"; @@ -127,11 +128,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 00000000000..d4599336472 --- /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 a97252c7b72..78c33b42e7a 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -188,6 +188,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 6b1018e2a0a..db2384c2b7d 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -30,6 +30,10 @@ export interface Preferences { * see `resolveThreadListV2Enabled`. */ readonly threadListV2Enabled?: 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()( @@ -81,6 +85,8 @@ function sanitizePreferences(parsed: Preferences): Preferences { collapsedProjectGroups?: readonly string[]; projectGroupingEnabled?: boolean; threadListV2Enabled?: boolean; + threadListV2SettledShelfExpanded?: boolean; + threadListV2SnoozedShelfExpanded?: boolean; } = {}; if (typeof parsed.liveActivitiesEnabled === "boolean") { @@ -113,6 +119,12 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (typeof parsed.threadListV2Enabled === "boolean") { preferences.threadListV2Enabled = parsed.threadListV2Enabled; } + if (typeof parsed.threadListV2SettledShelfExpanded === "boolean") { + preferences.threadListV2SettledShelfExpanded = parsed.threadListV2SettledShelfExpanded; + } + if (typeof parsed.threadListV2SnoozedShelfExpanded === "boolean") { + preferences.threadListV2SnoozedShelfExpanded = parsed.threadListV2SnoozedShelfExpanded; + } return preferences; }