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