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;
}