| 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,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"; |
| 59 | import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences"; | ||
| 59 | import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu"; | 60 | import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu"; |
| 60 | import { | 61 | import { |
| 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-settle | 583 | // 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 | <ThreadListV2SnoozedShelfHeader | 790 | <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 | <ThreadListV2SettledShelfHeader | 800 | <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"; |
| 34 | import { mobilePreferencesAtom } from "../../state/preferences"; | 34 | import { mobilePreferencesAtom } from "../../state/preferences"; |
| 35 | import { useThreadSearch } from "../../state/queries"; | 35 | import { useThreadSearch } from "../../state/queries"; |
| 36 | import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; | 36 | import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; |
| 37 | import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; | ||
| 37 | import { environmentServerConfigsAtom } from "../../state/server"; | 38 | import { environmentServerConfigsAtom } from "../../state/server"; |
| 38 | import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; | 39 | import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; |
| 39 | import { useWorkspaceState } from "../../state/workspace"; | 40 | import { 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 actually | 471 | // now ticks per minute so the inactivity auto-settle boundary is actually |
| 468 | // crossed while the pane stays open; without a clock dependency the | 472 | // 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 | <ThreadListV2SnoozedShelfHeader | 1013 | <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 | <ThreadListV2SettledShelfHeader | 1023 | <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"; |
| 114 | 114 | ||
| 115 | export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedShelfHeader(props: { | 115 | export 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 |
| 151 | 153 | ||
| 152 | export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledShelfHeader(props: { | 154 | export 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 @@ | ||
| 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,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 | }); |
| 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 | 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 | } |
| 46 | 50 | ||
| 47 | export class MobilePreferencesLoadError extends Schema.TaggedErrorClass<MobilePreferencesLoadError>()( | 51 | export 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 | } = {}; |
| 104 | 110 | ||
| 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 | } |
| 175 | 187 |