authorgravatar for me@pixp.ccAlex <me@pixp.cc> 2026-08-24 02:51:52-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-24 02:51:52-07:00
log9eba1252c1f8264b6951068d2e2396363412299c
tree2fe9b039b681e87d75753b42acb331cc9534d3f9
parent58ba5594439c984b08d13b6d4efb5407adb06943
signature Signed by PGP key B5690EEEBB952194

fix(mobile): persist thread shelf collapse state (#5152)


6 files changed, 119 insertions(+), 10 deletions(-)

apps/mobile/src/features/home/HomeScreen.tsx+11-4
......@@ -56,6 +56,7 @@ import {
5656 THREAD_LIST_V2_SETTLED_PAGE_COUNT,
5757 type ThreadListV2ListItem,
5858} from "../threads/threadListV2";
59import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences";
5960import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu";
6061import {
6162 buildHomeListLayout,
......@@ -572,10 +573,13 @@ export function HomeScreen(props: HomeScreenProps) {
572573 () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT),
573574 [],
574575 );
575 const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useState(false);
576 const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []);
577 const [settledShelfExpanded, setSettledShelfExpanded] = useState(true);
578 const toggleSettledShelf = useCallback(() => setSettledShelfExpanded((value) => !value), []);
576 const {
577 loaded: shelfPreferencesLoaded,
578 settledShelfExpanded,
579 snoozedShelfExpanded,
580 toggleSettledShelf,
581 toggleSnoozedShelf,
582 } = useThreadListV2ShelfPreferences();
579583 // now is quantized to the minute and ticks so the inactivity auto-settle
580584 // boundary is actually crossed while the app stays open (mirrors web);
581585 // without a clock dependency the partition memoizes a frozen "now".
......@@ -785,6 +789,7 @@ export function HomeScreen(props: HomeScreenProps) {
785789 return (
786790 <ThreadListV2SnoozedShelfHeader
787791 count={item.count}
792 disabled={!shelfPreferencesLoaded}
788793 expanded={item.expanded}
789794 onToggle={toggleSnoozedShelf}
790795 />
......@@ -794,6 +799,7 @@ export function HomeScreen(props: HomeScreenProps) {
794799 return (
795800 <ThreadListV2SettledShelfHeader
796801 count={item.count}
802 disabled={!shelfPreferencesLoaded}
797803 expanded={item.expanded}
798804 onToggle={toggleSettledShelf}
799805 />
......@@ -890,6 +896,7 @@ export function HomeScreen(props: HomeScreenProps) {
890896 props.onSelectThread,
891897 props.savedConnectionsById,
892898 serverConfigs,
899 shelfPreferencesLoaded,
893900 settlementEnvironmentIds,
894901 snoozeEnvironmentIds,
895902 threadListV2Items,
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+11-4
......@@ -34,6 +34,7 @@ import { useProjects, useThreadShells } from "../../state/entities";
3434import { mobilePreferencesAtom } from "../../state/preferences";
3535import { useThreadSearch } from "../../state/queries";
3636import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled";
37import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences";
3738import { environmentServerConfigsAtom } from "../../state/server";
3839import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
3940import { useWorkspaceState } from "../../state/workspace";
......@@ -460,10 +461,13 @@ function ThreadNavigationSidebarPane(
460461 () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT),
461462 [],
462463 );
463 const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useState(false);
464 const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []);
465 const [settledShelfExpanded, setSettledShelfExpanded] = useState(true);
466 const toggleSettledShelf = useCallback(() => setSettledShelfExpanded((value) => !value), []);
464 const {
465 loaded: shelfPreferencesLoaded,
466 settledShelfExpanded,
467 snoozedShelfExpanded,
468 toggleSettledShelf,
469 toggleSnoozedShelf,
470 } = useThreadListV2ShelfPreferences();
467471 // now ticks per minute so the inactivity auto-settle boundary is actually
468472 // crossed while the pane stays open; without a clock dependency the
469473 // partition memoizes a frozen "now".
......@@ -1008,6 +1012,7 @@ function ThreadNavigationSidebarPane(
10081012 return (
10091013 <ThreadListV2SnoozedShelfHeader
10101014 count={item.count}
1015 disabled={!shelfPreferencesLoaded}
10111016 expanded={item.expanded}
10121017 onToggle={toggleSnoozedShelf}
10131018 pane="sidebar"
......@@ -1017,6 +1022,7 @@ function ThreadNavigationSidebarPane(
10171022 return (
10181023 <ThreadListV2SettledShelfHeader
10191024 count={item.count}
1025 disabled={!shelfPreferencesLoaded}
10201026 expanded={item.expanded}
10211027 onToggle={toggleSettledShelf}
10221028 pane="sidebar"
......@@ -1140,6 +1146,7 @@ function ThreadNavigationSidebarPane(
11401146 props.width,
11411147 savedConnectionsById,
11421148 serverConfigs,
1149 shelfPreferencesLoaded,
11431150 threadSearchMatchByKey,
11441151 titleRegenerationEnvironmentIds,
11451152 settleThread,
apps/mobile/src/features/threads/thread-list-v2-items.tsx+6-2
......@@ -114,6 +114,7 @@ const SNOOZE_ACCENT_DARK = "#60a5fa";
114114
115115export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedShelfHeader(props: {
116116 readonly count: number;
117 readonly disabled?: boolean;
117118 readonly expanded: boolean;
118119 readonly onToggle: () => void;
119120 readonly pane?: "screen" | "sidebar";
......@@ -126,11 +127,12 @@ export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedS
126127 }
127128 accessibilityLabel={props.count === 1 ? "1 snoozed thread" : `${props.count} snoozed threads`}
128129 accessibilityRole="button"
129 accessibilityState={{ expanded: props.expanded }}
130 accessibilityState={{ disabled: props.disabled, expanded: props.expanded }}
130131 className={cn(
131132 "mb-1.5 mt-4 flex-row items-center gap-2.5",
132133 props.pane === "sidebar" ? "px-3" : "px-5",
133134 )}
135 disabled={props.disabled}
134136 onPress={props.onToggle}
135137 style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
136138 >
......@@ -151,6 +153,7 @@ export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedS
151153
152154export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledShelfHeader(props: {
153155 readonly count: number;
156 readonly disabled?: boolean;
154157 readonly expanded: boolean;
155158 readonly onToggle: () => void;
156159 readonly pane?: "screen" | "sidebar";
......@@ -163,11 +166,12 @@ export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledS
163166 }
164167 accessibilityLabel={props.count === 1 ? "1 settled thread" : `${props.count} settled threads`}
165168 accessibilityRole="button"
166 accessibilityState={{ expanded: props.expanded }}
169 accessibilityState={{ disabled: props.disabled, expanded: props.expanded }}
167170 className={cn(
168171 "mb-1.5 mt-4 flex-row items-center gap-2.5",
169172 props.pane === "sidebar" ? "px-3" : "px-5",
170173 )}
174 disabled={props.disabled}
171175 onPress={props.onToggle}
172176 style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
173177 >
apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts created+45
......@@ -0,0 +1,45 @@
1import { useAtomSet, useAtomValue } from "@effect/atom-react";
2import { AsyncResult } from "effect/unstable/reactivity";
3import { useCallback, useRef } from "react";
4
5import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
6
7/**
8 * Shared persisted shelf state for the compact Home list and iPad sidebar.
9 * Refs advance before persistence starts so consecutive presses always toggle
10 * the latest value, even if React has not rendered the optimistic patch yet.
11 */
12export function useThreadListV2ShelfPreferences() {
13 const preferencesResult = useAtomValue(mobilePreferencesAtom);
14 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
15 const loaded = AsyncResult.isSuccess(preferencesResult);
16 const snoozedShelfExpanded =
17 loaded && preferencesResult.value.threadListV2SnoozedShelfExpanded === true;
18 const settledShelfExpanded =
19 !loaded || preferencesResult.value.threadListV2SettledShelfExpanded !== false;
20 const snoozedShelfExpandedRef = useRef(snoozedShelfExpanded);
21 const settledShelfExpandedRef = useRef(settledShelfExpanded);
22 snoozedShelfExpandedRef.current = snoozedShelfExpanded;
23 settledShelfExpandedRef.current = settledShelfExpanded;
24
25 const toggleSnoozedShelf = useCallback(() => {
26 if (!loaded) return;
27 const expanded = !snoozedShelfExpandedRef.current;
28 snoozedShelfExpandedRef.current = expanded;
29 savePreferences({ threadListV2SnoozedShelfExpanded: expanded });
30 }, [loaded, savePreferences]);
31 const toggleSettledShelf = useCallback(() => {
32 if (!loaded) return;
33 const expanded = !settledShelfExpandedRef.current;
34 settledShelfExpandedRef.current = expanded;
35 savePreferences({ threadListV2SettledShelfExpanded: expanded });
36 }, [loaded, savePreferences]);
37
38 return {
39 loaded,
40 settledShelfExpanded,
41 snoozedShelfExpanded,
42 toggleSettledShelf,
43 toggleSnoozedShelf,
44 } as const;
45}
apps/mobile/src/lib/storage.test.ts+34
......@@ -207,6 +207,40 @@ describe("mobile connection storage", () => {
207207 expect(fallback.updatedAt).toEqual(expect.any(Number));
208208 });
209209
210 it("persists Thread List v2 shelf expansion preferences", async () => {
211 await expect(
212 savePreferencesPatch({
213 threadListV2SettledShelfExpanded: false,
214 threadListV2SnoozedShelfExpanded: true,
215 }),
216 ).resolves.toEqual({
217 threadListV2SettledShelfExpanded: false,
218 threadListV2SnoozedShelfExpanded: true,
219 });
220
221 await expect(loadPreferences()).resolves.toEqual({
222 threadListV2SettledShelfExpanded: false,
223 threadListV2SnoozedShelfExpanded: true,
224 });
225 expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({
226 threadListV2SettledShelfExpanded: false,
227 threadListV2SnoozedShelfExpanded: true,
228 });
229 });
230
231 it("ignores invalid Thread List v2 shelf expansion preference types", async () => {
232 mocks.setPreferencesJson(
233 JSON.stringify({
234 baseFontSize: 17,
235 threadListV2SettledShelfExpanded: "false",
236 threadListV2SnoozedShelfExpanded: 1,
237 }),
238 10,
239 );
240
241 await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 17 });
242 });
243
210244 it("reconciles fallback preferences after SQLite recovers", async () => {
211245 mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 15 }), 10);
212246 await mocks.setItemAsync(
apps/mobile/src/persistence/mobile-preferences.ts+12
......@@ -42,6 +42,10 @@ export interface Preferences {
4242 readonly legacyThreadListEnabled?: boolean;
4343 /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */
4444 readonly planModeEnabled?: boolean;
45 /** Undefined preserves the default expanded Settled shelf. */
46 readonly threadListV2SettledShelfExpanded?: boolean;
47 /** Undefined preserves the default collapsed Snoozed shelf. */
48 readonly threadListV2SnoozedShelfExpanded?: boolean;
4549}
4650
4751export class MobilePreferencesLoadError extends Schema.TaggedErrorClass<MobilePreferencesLoadError>()(
......@@ -100,6 +104,8 @@ function sanitizePreferences(parsed: Preferences): Preferences {
100104 autoSettleOnMerge?: boolean;
101105 legacyThreadListEnabled?: boolean;
102106 planModeEnabled?: boolean;
107 threadListV2SettledShelfExpanded?: boolean;
108 threadListV2SnoozedShelfExpanded?: boolean;
103109 } = {};
104110
105111 if (typeof parsed.liveActivitiesEnabled === "boolean") {
......@@ -170,6 +176,12 @@ function sanitizePreferences(parsed: Preferences): Preferences {
170176 if (typeof parsed.planModeEnabled === "boolean") {
171177 preferences.planModeEnabled = parsed.planModeEnabled;
172178 }
179 if (typeof parsed.threadListV2SettledShelfExpanded === "boolean") {
180 preferences.threadListV2SettledShelfExpanded = parsed.threadListV2SettledShelfExpanded;
181 }
182 if (typeof parsed.threadListV2SnoozedShelfExpanded === "boolean") {
183 preferences.threadListV2SnoozedShelfExpanded = parsed.threadListV2SnoozedShelfExpanded;
184 }
173185 return preferences;
174186}
175187