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 {...@@ -56,6 +56,7 @@ import {
56 THREAD_LIST_V2_SETTLED_PAGE_COUNT,56 THREAD_LIST_V2_SETTLED_PAGE_COUNT,
57 type ThreadListV2ListItem,57 type ThreadListV2ListItem,
58} from "../threads/threadListV2";58} from "../threads/threadListV2";
59import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences";
59import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu";60import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu";
60import {61import {
61 buildHomeListLayout,62 buildHomeListLayout,
...@@ -572,10 +573,13 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -572,10 +573,13 @@ export function HomeScreen(props: HomeScreenProps) {
572 () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT),573 () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT),
573 [],574 [],
574 );575 );
575 const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useState(false);576 const {
576 const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []);577 loaded: shelfPreferencesLoaded,
577 const [settledShelfExpanded, setSettledShelfExpanded] = useState(true);578 settledShelfExpanded,
578 const toggleSettledShelf = useCallback(() => setSettledShelfExpanded((value) => !value), []);579 snoozedShelfExpanded,
580 toggleSettledShelf,
581 toggleSnoozedShelf,
582 } = useThreadListV2ShelfPreferences();
579 // now is quantized to the minute and ticks so the inactivity auto-settle583 // now is quantized to the minute and ticks so the inactivity auto-settle
580 // boundary is actually crossed while the app stays open (mirrors web);584 // boundary is actually crossed while the app stays open (mirrors web);
581 // without a clock dependency the partition memoizes a frozen "now".585 // without a clock dependency the partition memoizes a frozen "now".
...@@ -785,6 +789,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -785,6 +789,7 @@ export function HomeScreen(props: HomeScreenProps) {
785 return (789 return (
786 <ThreadListV2SnoozedShelfHeader790 <ThreadListV2SnoozedShelfHeader
787 count={item.count}791 count={item.count}
792 disabled={!shelfPreferencesLoaded}
788 expanded={item.expanded}793 expanded={item.expanded}
789 onToggle={toggleSnoozedShelf}794 onToggle={toggleSnoozedShelf}
790 />795 />
...@@ -794,6 +799,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -794,6 +799,7 @@ export function HomeScreen(props: HomeScreenProps) {
794 return (799 return (
795 <ThreadListV2SettledShelfHeader800 <ThreadListV2SettledShelfHeader
796 count={item.count}801 count={item.count}
802 disabled={!shelfPreferencesLoaded}
797 expanded={item.expanded}803 expanded={item.expanded}
798 onToggle={toggleSettledShelf}804 onToggle={toggleSettledShelf}
799 />805 />
...@@ -890,6 +896,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -890,6 +896,7 @@ export function HomeScreen(props: HomeScreenProps) {
890 props.onSelectThread,896 props.onSelectThread,
891 props.savedConnectionsById,897 props.savedConnectionsById,
892 serverConfigs,898 serverConfigs,
899 shelfPreferencesLoaded,
893 settlementEnvironmentIds,900 settlementEnvironmentIds,
894 snoozeEnvironmentIds,901 snoozeEnvironmentIds,
895 threadListV2Items,902 threadListV2Items,
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+11-4
...@@ -34,6 +34,7 @@ import { useProjects, useThreadShells } from "../../state/entities";...@@ -34,6 +34,7 @@ import { useProjects, useThreadShells } from "../../state/entities";
34import { mobilePreferencesAtom } from "../../state/preferences";34import { mobilePreferencesAtom } from "../../state/preferences";
35import { useThreadSearch } from "../../state/queries";35import { useThreadSearch } from "../../state/queries";
36import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled";36import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled";
37import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences";
37import { environmentServerConfigsAtom } from "../../state/server";38import { environmentServerConfigsAtom } from "../../state/server";
38import { usePendingNewTasks } from "../../state/use-pending-new-tasks";39import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
39import { useWorkspaceState } from "../../state/workspace";40import { useWorkspaceState } from "../../state/workspace";
...@@ -460,10 +461,13 @@ function ThreadNavigationSidebarPane(...@@ -460,10 +461,13 @@ function ThreadNavigationSidebarPane(
460 () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT),461 () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT),
461 [],462 [],
462 );463 );
463 const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useState(false);464 const {
464 const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []);465 loaded: shelfPreferencesLoaded,
465 const [settledShelfExpanded, setSettledShelfExpanded] = useState(true);466 settledShelfExpanded,
466 const toggleSettledShelf = useCallback(() => setSettledShelfExpanded((value) => !value), []);467 snoozedShelfExpanded,
468 toggleSettledShelf,
469 toggleSnoozedShelf,
470 } = useThreadListV2ShelfPreferences();
467 // now ticks per minute so the inactivity auto-settle boundary is actually471 // now ticks per minute so the inactivity auto-settle boundary is actually
468 // crossed while the pane stays open; without a clock dependency the472 // crossed while the pane stays open; without a clock dependency the
469 // partition memoizes a frozen "now".473 // partition memoizes a frozen "now".
...@@ -1008,6 +1012,7 @@ function ThreadNavigationSidebarPane(...@@ -1008,6 +1012,7 @@ function ThreadNavigationSidebarPane(
1008 return (1012 return (
1009 <ThreadListV2SnoozedShelfHeader1013 <ThreadListV2SnoozedShelfHeader
1010 count={item.count}1014 count={item.count}
1015 disabled={!shelfPreferencesLoaded}
1011 expanded={item.expanded}1016 expanded={item.expanded}
1012 onToggle={toggleSnoozedShelf}1017 onToggle={toggleSnoozedShelf}
1013 pane="sidebar"1018 pane="sidebar"
...@@ -1017,6 +1022,7 @@ function ThreadNavigationSidebarPane(...@@ -1017,6 +1022,7 @@ function ThreadNavigationSidebarPane(
1017 return (1022 return (
1018 <ThreadListV2SettledShelfHeader1023 <ThreadListV2SettledShelfHeader
1019 count={item.count}1024 count={item.count}
1025 disabled={!shelfPreferencesLoaded}
1020 expanded={item.expanded}1026 expanded={item.expanded}
1021 onToggle={toggleSettledShelf}1027 onToggle={toggleSettledShelf}
1022 pane="sidebar"1028 pane="sidebar"
...@@ -1140,6 +1146,7 @@ function ThreadNavigationSidebarPane(...@@ -1140,6 +1146,7 @@ function ThreadNavigationSidebarPane(
1140 props.width,1146 props.width,
1141 savedConnectionsById,1147 savedConnectionsById,
1142 serverConfigs,1148 serverConfigs,
1149 shelfPreferencesLoaded,
1143 threadSearchMatchByKey,1150 threadSearchMatchByKey,
1144 titleRegenerationEnvironmentIds,1151 titleRegenerationEnvironmentIds,
1145 settleThread,1152 settleThread,
apps/mobile/src/features/threads/thread-list-v2-items.tsx+6-2
...@@ -114,6 +114,7 @@ const SNOOZE_ACCENT_DARK = "#60a5fa";...@@ -114,6 +114,7 @@ const SNOOZE_ACCENT_DARK = "#60a5fa";
114114
115export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedShelfHeader(props: {115export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedShelfHeader(props: {
116 readonly count: number;116 readonly count: number;
117 readonly disabled?: boolean;
117 readonly expanded: boolean;118 readonly expanded: boolean;
118 readonly onToggle: () => void;119 readonly onToggle: () => void;
119 readonly pane?: "screen" | "sidebar";120 readonly pane?: "screen" | "sidebar";
...@@ -126,11 +127,12 @@ export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedS...@@ -126,11 +127,12 @@ export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedS
126 }127 }
127 accessibilityLabel={props.count === 1 ? "1 snoozed thread" : `${props.count} snoozed threads`}128 accessibilityLabel={props.count === 1 ? "1 snoozed thread" : `${props.count} snoozed threads`}
128 accessibilityRole="button"129 accessibilityRole="button"
129 accessibilityState={{ expanded: props.expanded }}130 accessibilityState={{ disabled: props.disabled, expanded: props.expanded }}
130 className={cn(131 className={cn(
131 "mb-1.5 mt-4 flex-row items-center gap-2.5",132 "mb-1.5 mt-4 flex-row items-center gap-2.5",
132 props.pane === "sidebar" ? "px-3" : "px-5",133 props.pane === "sidebar" ? "px-3" : "px-5",
133 )}134 )}
135 disabled={props.disabled}
134 onPress={props.onToggle}136 onPress={props.onToggle}
135 style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}137 style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
136 >138 >
...@@ -151,6 +153,7 @@ export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedS...@@ -151,6 +153,7 @@ export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedS
151153
152export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledShelfHeader(props: {154export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledShelfHeader(props: {
153 readonly count: number;155 readonly count: number;
156 readonly disabled?: boolean;
154 readonly expanded: boolean;157 readonly expanded: boolean;
155 readonly onToggle: () => void;158 readonly onToggle: () => void;
156 readonly pane?: "screen" | "sidebar";159 readonly pane?: "screen" | "sidebar";
...@@ -163,11 +166,12 @@ export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledS...@@ -163,11 +166,12 @@ export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledS
163 }166 }
164 accessibilityLabel={props.count === 1 ? "1 settled thread" : `${props.count} settled threads`}167 accessibilityLabel={props.count === 1 ? "1 settled thread" : `${props.count} settled threads`}
165 accessibilityRole="button"168 accessibilityRole="button"
166 accessibilityState={{ expanded: props.expanded }}169 accessibilityState={{ disabled: props.disabled, expanded: props.expanded }}
167 className={cn(170 className={cn(
168 "mb-1.5 mt-4 flex-row items-center gap-2.5",171 "mb-1.5 mt-4 flex-row items-center gap-2.5",
169 props.pane === "sidebar" ? "px-3" : "px-5",172 props.pane === "sidebar" ? "px-3" : "px-5",
170 )}173 )}
174 disabled={props.disabled}
171 onPress={props.onToggle}175 onPress={props.onToggle}
172 style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}176 style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
173 >177 >
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", () => {...@@ -207,6 +207,40 @@ describe("mobile connection storage", () => {
207 expect(fallback.updatedAt).toEqual(expect.any(Number));207 expect(fallback.updatedAt).toEqual(expect.any(Number));
208 });208 });
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
210 it("reconciles fallback preferences after SQLite recovers", async () => {244 it("reconciles fallback preferences after SQLite recovers", async () => {
211 mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 15 }), 10);245 mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 15 }), 10);
212 await mocks.setItemAsync(246 await mocks.setItemAsync(
apps/mobile/src/persistence/mobile-preferences.ts+12
...@@ -42,6 +42,10 @@ export interface Preferences {...@@ -42,6 +42,10 @@ export interface Preferences {
42 readonly legacyThreadListEnabled?: boolean;42 readonly legacyThreadListEnabled?: boolean;
43 /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */43 /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */
44 readonly planModeEnabled?: boolean;44 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;
45}49}
4650
47export class MobilePreferencesLoadError extends Schema.TaggedErrorClass<MobilePreferencesLoadError>()(51export class MobilePreferencesLoadError extends Schema.TaggedErrorClass<MobilePreferencesLoadError>()(
...@@ -100,6 +104,8 @@ function sanitizePreferences(parsed: Preferences): Preferences {...@@ -100,6 +104,8 @@ function sanitizePreferences(parsed: Preferences): Preferences {
100 autoSettleOnMerge?: boolean;104 autoSettleOnMerge?: boolean;
101 legacyThreadListEnabled?: boolean;105 legacyThreadListEnabled?: boolean;
102 planModeEnabled?: boolean;106 planModeEnabled?: boolean;
107 threadListV2SettledShelfExpanded?: boolean;
108 threadListV2SnoozedShelfExpanded?: boolean;
103 } = {};109 } = {};
104110
105 if (typeof parsed.liveActivitiesEnabled === "boolean") {111 if (typeof parsed.liveActivitiesEnabled === "boolean") {
...@@ -170,6 +176,12 @@ function sanitizePreferences(parsed: Preferences): Preferences {...@@ -170,6 +176,12 @@ function sanitizePreferences(parsed: Preferences): Preferences {
170 if (typeof parsed.planModeEnabled === "boolean") {176 if (typeof parsed.planModeEnabled === "boolean") {
171 preferences.planModeEnabled = parsed.planModeEnabled;177 preferences.planModeEnabled = parsed.planModeEnabled;
172 }178 }
179 if (typeof parsed.threadListV2SettledShelfExpanded === "boolean") {
180 preferences.threadListV2SettledShelfExpanded = parsed.threadListV2SettledShelfExpanded;
181 }
182 if (typeof parsed.threadListV2SnoozedShelfExpanded === "boolean") {
183 preferences.threadListV2SnoozedShelfExpanded = parsed.threadListV2SnoozedShelfExpanded;
184 }
173 return preferences;185 return preferences;
174}186}
175187