Skip to content

Commit 9eba125

Browse files
authored
fix(mobile): persist thread shelf collapse state (#5152)
1 parent 58ba559 commit 9eba125

6 files changed

Lines changed: 119 additions & 10 deletions

File tree

‎apps/mobile/src/features/home/HomeScreen.tsx‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import {
5656
THREAD_LIST_V2_SETTLED_PAGE_COUNT,
5757
type ThreadListV2ListItem,
5858
} from "../threads/threadListV2";
59+
import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences";
5960
import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu";
6061
import {
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‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { useProjects, useThreadShells } from "../../state/entities";
3434
import { mobilePreferencesAtom } from "../../state/preferences";
3535
import { useThreadSearch } from "../../state/queries";
3636
import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled";
37+
import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences";
3738
import { environmentServerConfigsAtom } from "../../state/server";
3839
import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
3940
import { 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‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ const SNOOZE_ACCENT_DARK = "#60a5fa";
114114

115115
export 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

152154
export 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
>
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { useAtomSet, useAtomValue } from "@effect/atom-react";
2+
import { AsyncResult } from "effect/unstable/reactivity";
3+
import { useCallback, useRef } from "react";
4+
5+
import { 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+
*/
12+
export 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‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

4751
export 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

0 commit comments

Comments
 (0)