| author | |
| committer | |
| log | 8d846660cecfc69ef4e192322fced29fa07f374c |
| tree | e3a72e2791efe1b99167702e33cfa97c21ab9cfc |
| parent | a78760e5e7b11ea51e21a1640fec54bd3f210fd8 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>16 files changed, 587 insertions(+), 103 deletions(-)
apps/mobile/src/features/home/HomeScreen.tsx+37-2| ... | @@ -44,6 +44,7 @@ import { | ... | @@ -44,6 +44,7 @@ import { |
| 44 | ThreadListV2SettledShelfHeader, | 44 | ThreadListV2SettledShelfHeader, |
| 45 | ThreadListV2ShowMoreRow, | 45 | ThreadListV2ShowMoreRow, |
| 46 | ThreadListV2SnoozedShelfHeader, | 46 | ThreadListV2SnoozedShelfHeader, |
| 47 | ThreadListV2WorkingShelfHeader, | ||
| 47 | } from "../threads/thread-list-v2-items"; | 48 | } from "../threads/thread-list-v2-items"; |
| 48 | import { useThreadRowProviderInstanceResolver } from "../threads/thread-provider-instance"; | 49 | import { useThreadRowProviderInstanceResolver } from "../threads/thread-provider-instance"; |
| 49 | import { | 50 | import { |
| ... | @@ -51,6 +52,7 @@ import { | ... | @@ -51,6 +52,7 @@ import { |
| 51 | getThreadListV2OrderedSection, | 52 | getThreadListV2OrderedSection, |
| 52 | buildThreadListV2ListItems, | 53 | buildThreadListV2ListItems, |
| 53 | threadListV2ListItemsAreEqual, | 54 | threadListV2ListItemsAreEqual, |
| 55 | threadListInboxReturns, | ||
| 54 | THREAD_LIST_V2_SETTLED_INITIAL_COUNT, | 56 | THREAD_LIST_V2_SETTLED_INITIAL_COUNT, |
| 55 | THREAD_LIST_V2_SETTLED_PAGE_COUNT, | 57 | THREAD_LIST_V2_SETTLED_PAGE_COUNT, |
| 56 | type ThreadListV2ListItem, | 58 | type ThreadListV2ListItem, |
| ... | @@ -466,8 +468,11 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -466,8 +468,11 @@ export function HomeScreen(props: HomeScreenProps) { |
| 466 | loaded: shelfPreferencesLoaded, | 468 | loaded: shelfPreferencesLoaded, |
| 467 | settledShelfExpanded, | 469 | settledShelfExpanded, |
| 468 | snoozedShelfExpanded, | 470 | snoozedShelfExpanded, |
| 471 | workingShelfEnabled, | ||
| 472 | workingShelfExpanded, | ||
| 469 | toggleSettledShelf, | 473 | toggleSettledShelf, |
| 470 | toggleSnoozedShelf, | 474 | toggleSnoozedShelf, |
| 475 | toggleWorkingShelf, | ||
| 471 | } = useThreadListV2ShelfPreferences(); | 476 | } = useThreadListV2ShelfPreferences(); |
| 472 | // The queued-start and snooze helpers need a clock while the list stays open. | 477 | // The queued-start and snooze helpers need a clock while the list stays open. |
| 473 | const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); | 478 | const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); |
| ... | @@ -520,8 +525,13 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -520,8 +525,13 @@ export function HomeScreen(props: HomeScreenProps) { |
| 520 | queuedThreadKeys, | 525 | queuedThreadKeys, |
| 521 | }), | 526 | }), |
| 522 | }); | 527 | }); |
| 523 | return new Map([...sectionAvailability("pinned"), ...sectionAvailability("active")]); | 528 | // The Working beta orders the inbox by time, so only pins can move. |
| 529 | return new Map([ | ||
| 530 | ...sectionAvailability("pinned"), | ||
| 531 | ...(workingShelfEnabled ? [] : sectionAvailability("active")), | ||
| 532 | ]); | ||
| 524 | }, [ | 533 | }, [ |
| 534 | workingShelfEnabled, | ||
| 525 | pinReorderEnvironmentIds, | 535 | pinReorderEnvironmentIds, |
| 526 | activeReorderEnvironmentIds, | 536 | activeReorderEnvironmentIds, |
| 527 | props.threads, | 537 | props.threads, |
| ... | @@ -533,6 +543,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -533,6 +543,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 533 | snoozeWakeTick, | 543 | snoozeWakeTick, |
| 534 | ]); | 544 | ]); |
| 535 | const threadListV2Layout = useMemo(() => { | 545 | const threadListV2Layout = useMemo(() => { |
| 546 | threadListInboxReturns.observe(workingShelfEnabled ? props.threads : null); | ||
| 536 | // Settled threads are live shells; archived threads keep their original | 547 | // Settled threads are live shells; archived threads keep their original |
| 537 | // "hidden from lists" meaning. | 548 | // "hidden from lists" meaning. |
| 538 | return buildThreadListV2Items({ | 549 | return buildThreadListV2Items({ |
| ... | @@ -547,11 +558,16 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -547,11 +558,16 @@ export function HomeScreen(props: HomeScreenProps) { |
| 547 | queuedThreadKeys, | 558 | queuedThreadKeys, |
| 548 | settledLimit: settledVisibleCount, | 559 | settledLimit: settledVisibleCount, |
| 549 | now: new Date().toISOString(), | 560 | now: new Date().toISOString(), |
| 561 | workingShelfEnabled, | ||
| 562 | workingShelfExpanded, | ||
| 563 | inboxReturnAt: threadListInboxReturns.returnedAt, | ||
| 550 | snoozedShelfExpanded, | 564 | snoozedShelfExpanded, |
| 551 | settledShelfExpanded, | 565 | settledShelfExpanded, |
| 552 | selectedThreadKey: null, | 566 | selectedThreadKey: null, |
| 553 | }); | 567 | }); |
| 554 | }, [ | 568 | }, [ |
| 569 | workingShelfEnabled, | ||
| 570 | workingShelfExpanded, | ||
| 555 | pendingOrder, | 571 | pendingOrder, |
| 556 | queuedThreadKeys, | 572 | queuedThreadKeys, |
| 557 | nowMinute, | 573 | nowMinute, |
| ... | @@ -606,6 +622,9 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -606,6 +622,9 @@ export function HomeScreen(props: HomeScreenProps) { |
| 606 | buildThreadListV2ListItems({ | 622 | buildThreadListV2ListItems({ |
| 607 | items: threadListV2Layout.items, | 623 | items: threadListV2Layout.items, |
| 608 | pendingTasks: v2PendingTasks, | 624 | pendingTasks: v2PendingTasks, |
| 625 | workingCount: threadListV2Layout.workingCount, | ||
| 626 | workingShelfExpanded, | ||
| 627 | workingShelfHeaderIndex: threadListV2Layout.workingShelfHeaderIndex, | ||
| 609 | snoozedCount: threadListV2Layout.snoozedCount, | 628 | snoozedCount: threadListV2Layout.snoozedCount, |
| 610 | snoozedShelfExpanded, | 629 | snoozedShelfExpanded, |
| 611 | snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex, | 630 | snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex, |
| ... | @@ -628,6 +647,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -628,6 +647,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 628 | snoozeEnvironmentIds, | 647 | snoozeEnvironmentIds, |
| 629 | threadListV2Layout, | 648 | threadListV2Layout, |
| 630 | v2PendingTasks, | 649 | v2PendingTasks, |
| 650 | workingShelfExpanded, | ||
| 631 | ], | 651 | ], |
| 632 | ); | 652 | ); |
| 633 | 653 | ||
| ... | @@ -662,6 +682,16 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -662,6 +682,16 @@ export function HomeScreen(props: HomeScreenProps) { |
| 662 | /> | 682 | /> |
| 663 | ); | 683 | ); |
| 664 | } | 684 | } |
| 685 | if (item.type === "v2-working-shelf") { | ||
| 686 | return ( | ||
| 687 | <ThreadListV2WorkingShelfHeader | ||
| 688 | count={item.count} | ||
| 689 | disabled={item.disabled} | ||
| 690 | expanded={item.expanded} | ||
| 691 | onToggle={toggleWorkingShelf} | ||
| 692 | /> | ||
| 693 | ); | ||
| 694 | } | ||
| 665 | if (item.type === "v2-snoozed-shelf") { | 695 | if (item.type === "v2-snoozed-shelf") { |
| 666 | return ( | 696 | return ( |
| 667 | <ThreadListV2SnoozedShelfHeader | 697 | <ThreadListV2SnoozedShelfHeader |
| ... | @@ -730,7 +760,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -730,7 +760,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 730 | reorderSupported={ | 760 | reorderSupported={ |
| 731 | item.item.pinned | 761 | item.item.pinned |
| 732 | ? pinReorderEnvironmentIds.has(thread.environmentId) | 762 | ? pinReorderEnvironmentIds.has(thread.environmentId) |
| 733 | : activeReorderEnvironmentIds.has(thread.environmentId) | 763 | : !workingShelfEnabled && activeReorderEnvironmentIds.has(thread.environmentId) |
| 734 | } | 764 | } |
| 735 | canMoveUp={item.canMoveUp} | 765 | canMoveUp={item.canMoveUp} |
| 736 | canMoveDown={item.canMoveDown} | 766 | canMoveDown={item.canMoveDown} |
| ... | @@ -782,8 +812,10 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -782,8 +812,10 @@ export function HomeScreen(props: HomeScreenProps) { |
| 782 | titleRegenerationEnvironmentIds, | 812 | titleRegenerationEnvironmentIds, |
| 783 | toggleSettledShelf, | 813 | toggleSettledShelf, |
| 784 | toggleSnoozedShelf, | 814 | toggleSnoozedShelf, |
| 815 | toggleWorkingShelf, | ||
| 785 | v2ProjectTitleByProjectKey, | 816 | v2ProjectTitleByProjectKey, |
| 786 | props.searchQuery, | 817 | props.searchQuery, |
| 818 | workingShelfEnabled, | ||
| 787 | ], | 819 | ], |
| 788 | ); | 820 | ); |
| 789 | const v2KeyExtractor = useCallback((item: ThreadListV2ListItem) => item.key, []); | 821 | const v2KeyExtractor = useCallback((item: ThreadListV2ListItem) => item.key, []); |
| ... | @@ -800,6 +832,8 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -800,6 +832,8 @@ export function HomeScreen(props: HomeScreenProps) { |
| 800 | savedConnectionsById: props.savedConnectionsById, | 832 | savedConnectionsById: props.savedConnectionsById, |
| 801 | searchQuery: props.searchQuery, | 833 | searchQuery: props.searchQuery, |
| 802 | threadSearchMatchByKey, | 834 | threadSearchMatchByKey, |
| 835 | // Rows read it for their reorder menu items. | ||
| 836 | workingShelfEnabled, | ||
| 803 | }), | 837 | }), |
| 804 | [ | 838 | [ |
| 805 | projectByKey, | 839 | projectByKey, |
| ... | @@ -808,6 +842,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -808,6 +842,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 808 | listEnvironments, | 842 | listEnvironments, |
| 809 | threadSearchMatchByKey, | 843 | threadSearchMatchByKey, |
| 810 | v2ProjectTitleByProjectKey, | 844 | v2ProjectTitleByProjectKey, |
| 845 | workingShelfEnabled, | ||
| 811 | ], | 846 | ], |
| 812 | ); | 847 | ); |
| 813 | 848 |
apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx+30| ... | @@ -45,6 +45,7 @@ export function SettingsThreadsRouteScreen() { | ... | @@ -45,6 +45,7 @@ export function SettingsThreadsRouteScreen() { |
| 45 | contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} | 45 | contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} |
| 46 | > | 46 | > |
| 47 | <AutoSettleSettingsRows /> | 47 | <AutoSettleSettingsRows /> |
| 48 | <BetaSettingsSection /> | ||
| 48 | <LegacySettingsSection /> | 49 | <LegacySettingsSection /> |
| 49 | </ScrollView> | 50 | </ScrollView> |
| 50 | </SettingsScreen> | 51 | </SettingsScreen> |
| ... | @@ -239,6 +240,35 @@ function AutoSettleSettingsRows() { | ... | @@ -239,6 +240,35 @@ function AutoSettleSettingsRows() { |
| 239 | ); | 240 | ); |
| 240 | } | 241 | } |
| 241 | 242 | ||
| 243 | /** | ||
| 244 | * Device-local beta toggles, the counterpart of web's Working section (beta) | ||
| 245 | * in Settings → General. | ||
| 246 | */ | ||
| 247 | function BetaSettingsSection() { | ||
| 248 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); | ||
| 249 | const preferences = useAtomValue(mobilePreferencesAtom); | ||
| 250 | const workingShelfEnabled = | ||
| 251 | AsyncResult.isSuccess(preferences) && preferences.value.workingShelfEnabled === true; | ||
| 252 | |||
| 253 | return ( | ||
| 254 | <View className="gap-3"> | ||
| 255 | <SettingsSection title="Beta"> | ||
| 256 | <SettingsSwitchRow | ||
| 257 | icon="bolt.circle" | ||
| 258 | label="Working section" | ||
| 259 | value={workingShelfEnabled} | ||
| 260 | onValueChange={(value) => savePreferences({ workingShelfEnabled: value })} | ||
| 261 | /> | ||
| 262 | </SettingsSection> | ||
| 263 | <Text className="px-2 text-sm text-foreground-muted"> | ||
| 264 | Fold working and monitoring threads into a Working section. They return to the top of the | ||
| 265 | list when they need you. While this is on, active threads are ordered by time and cannot be | ||
| 266 | moved. | ||
| 267 | </Text> | ||
| 268 | </View> | ||
| 269 | ); | ||
| 270 | } | ||
| 271 | |||
| 242 | /** | 272 | /** |
| 243 | * Device-local legacy toggles. Mobile has no client-settings sync, so this is | 273 | * Device-local legacy toggles. Mobile has no client-settings sync, so this is |
| 244 | * the counterpart of web's Settings → General → Legacy features backed by | 274 | * the counterpart of web's Settings → General → Legacy features backed by |
apps/mobile/src/features/threads/ThreadArrangementSheet.tsx+13-5| ... | @@ -2,6 +2,7 @@ import { appAtomRegistry } from "../../state/atom-registry"; | ... | @@ -2,6 +2,7 @@ import { appAtomRegistry } from "../../state/atom-registry"; |
| 2 | import { useAtomValue } from "@effect/atom-react"; | 2 | import { useAtomValue } from "@effect/atom-react"; |
| 3 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | 3 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 4 | import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; | 4 | import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; |
| 5 | import { sortInboxThreadsByReturn } from "@t3tools/client-runtime/state/thread-inbox"; | ||
| 5 | import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; | 6 | import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; |
| 6 | import { Animated, FlatList, Modal, Pressable, View } from "react-native"; | 7 | import { Animated, FlatList, Modal, Pressable, View } from "react-native"; |
| 7 | import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler"; | 8 | import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler"; |
| ... | @@ -26,7 +27,8 @@ import { | ... | @@ -26,7 +27,8 @@ import { |
| 26 | threadDragAction, | 27 | threadDragAction, |
| 27 | type ThreadMoveDestination, | 28 | type ThreadMoveDestination, |
| 28 | } from "./threadOrder"; | 29 | } from "./threadOrder"; |
| 29 | import { getThreadListV2OrderedSection } from "./threadListV2"; | 30 | import { getThreadListV2OrderedSection, threadListInboxReturns } from "./threadListV2"; |
| 31 | import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; | ||
| 30 | 32 | ||
| 31 | const ROW_HEIGHT = 56; | 33 | const ROW_HEIGHT = 56; |
| 32 | const HEADER_HEIGHT = 48; | 34 | const HEADER_HEIGHT = 48; |
| ... | @@ -154,6 +156,7 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { | ... | @@ -154,6 +156,7 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { |
| 154 | const pendingOrder = useAtomValue(pendingThreadOrderAtom); | 156 | const pendingOrder = useAtomValue(pendingThreadOrderAtom); |
| 155 | const dropBusy = useAtomValue(threadDropBusyAtom); | 157 | const dropBusy = useAtomValue(threadDropBusyAtom); |
| 156 | const { moveThread } = useThreadListActions(); | 158 | const { moveThread } = useThreadListActions(); |
| 159 | const { workingShelfEnabled } = useThreadListV2ShelfPreferences(); | ||
| 157 | const [now, setNow] = useState(() => new Date().toISOString()); | 160 | const [now, setNow] = useState(() => new Date().toISOString()); |
| 158 | const [expanded, setExpanded] = useState({ snoozed: false, settled: false }); | 161 | const [expanded, setExpanded] = useState({ snoozed: false, settled: false }); |
| 159 | useEffect(() => { | 162 | useEffect(() => { |
| ... | @@ -195,23 +198,28 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { | ... | @@ -195,23 +198,28 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { |
| 195 | ); | 198 | ); |
| 196 | return { | 199 | return { |
| 197 | pinned, | 200 | pinned, |
| 198 | active, | 201 | // The Working beta orders the inbox by time; show that order here too. |
| 202 | active: workingShelfEnabled | ||
| 203 | ? sortInboxThreadsByReturn(active, threadListInboxReturns.returnedAt) | ||
| 204 | : active, | ||
| 199 | snoozed: parked.filter((thread) => effectiveSnoozed(thread, { now })), | 205 | snoozed: parked.filter((thread) => effectiveSnoozed(thread, { now })), |
| 200 | settled: parked.filter((thread) => !effectiveSnoozed(thread, { now })), | 206 | settled: parked.filter((thread) => !effectiveSnoozed(thread, { now })), |
| 201 | }; | 207 | }; |
| 202 | }, [threads, configs, now, queuedThreadKeys, pendingOrder]); | 208 | }, [threads, configs, now, queuedThreadKeys, pendingOrder, workingShelfEnabled]); |
| 203 | const planners = useMemo(() => { | 209 | const planners = useMemo(() => { |
| 204 | const planner = (section: "pinned" | "active") => | 210 | const planner = (section: "pinned" | "active") => |
| 205 | createThreadMovePlanner({ | 211 | createThreadMovePlanner({ |
| 206 | ordered: sections[section], | 212 | ordered: sections[section], |
| 207 | allThreads: threads, | 213 | allThreads: threads, |
| 208 | section, | 214 | section, |
| 215 | // A time-ordered inbox has no slots, so Active takes no drops while | ||
| 216 | // the Working beta is on. The saved arrangement stays untouched. | ||
| 209 | reorderableEnvironmentIds: new Set( | 217 | reorderableEnvironmentIds: new Set( |
| 210 | [...configs].flatMap(([id, config]) => | 218 | [...configs].flatMap(([id, config]) => |
| 211 | ( | 219 | ( |
| 212 | section === "pinned" | 220 | section === "pinned" |
| 213 | ? config.environment.capabilities.threadPinReorder | 221 | ? config.environment.capabilities.threadPinReorder |
| 214 | : config.environment.capabilities.threadActiveReorder | 222 | : !workingShelfEnabled && config.environment.capabilities.threadActiveReorder |
| 215 | ) | 223 | ) |
| 216 | ? [id] | 224 | ? [id] |
| 217 | : [], | 225 | : [], |
| ... | @@ -219,7 +227,7 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { | ... | @@ -219,7 +227,7 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { |
| 219 | ), | 227 | ), |
| 220 | }); | 228 | }); |
| 221 | return { pinned: planner("pinned"), active: planner("active") }; | 229 | return { pinned: planner("pinned"), active: planner("active") }; |
| 222 | }, [sections, threads, configs]); | 230 | }, [sections, threads, configs, workingShelfEnabled]); |
| 223 | const rows = useMemo(() => { | 231 | const rows = useMemo(() => { |
| 224 | const result: Row[] = []; | 232 | const result: Row[] = []; |
| 225 | let offset = 0; | 233 | let offset = 0; |
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+37-2| ... | @@ -61,6 +61,7 @@ import { | ... | @@ -61,6 +61,7 @@ import { |
| 61 | ThreadListV2SettledShelfHeader, | 61 | ThreadListV2SettledShelfHeader, |
| 62 | ThreadListV2ShowMoreRow, | 62 | ThreadListV2ShowMoreRow, |
| 63 | ThreadListV2SnoozedShelfHeader, | 63 | ThreadListV2SnoozedShelfHeader, |
| 64 | ThreadListV2WorkingShelfHeader, | ||
| 64 | } from "./thread-list-v2-items"; | 65 | } from "./thread-list-v2-items"; |
| 65 | import { useThreadRowProviderInstanceResolver } from "./thread-provider-instance"; | 66 | import { useThreadRowProviderInstanceResolver } from "./thread-provider-instance"; |
| 66 | import { | 67 | import { |
| ... | @@ -69,6 +70,7 @@ import { | ... | @@ -69,6 +70,7 @@ import { |
| 69 | buildThreadListV2ListItems, | 70 | buildThreadListV2ListItems, |
| 70 | isThreadListV2ListItem, | 71 | isThreadListV2ListItem, |
| 71 | threadListV2ListItemsAreEqual, | 72 | threadListV2ListItemsAreEqual, |
| 73 | threadListInboxReturns, | ||
| 72 | THREAD_LIST_V2_SETTLED_INITIAL_COUNT, | 74 | THREAD_LIST_V2_SETTLED_INITIAL_COUNT, |
| 73 | THREAD_LIST_V2_SETTLED_PAGE_COUNT, | 75 | THREAD_LIST_V2_SETTLED_PAGE_COUNT, |
| 74 | type ThreadListV2ListItem, | 76 | type ThreadListV2ListItem, |
| ... | @@ -291,8 +293,11 @@ function ThreadNavigationSidebarPane( | ... | @@ -291,8 +293,11 @@ function ThreadNavigationSidebarPane( |
| 291 | loaded: shelfPreferencesLoaded, | 293 | loaded: shelfPreferencesLoaded, |
| 292 | settledShelfExpanded, | 294 | settledShelfExpanded, |
| 293 | snoozedShelfExpanded, | 295 | snoozedShelfExpanded, |
| 296 | workingShelfEnabled, | ||
| 297 | workingShelfExpanded, | ||
| 294 | toggleSettledShelf, | 298 | toggleSettledShelf, |
| 295 | toggleSnoozedShelf, | 299 | toggleSnoozedShelf, |
| 300 | toggleWorkingShelf, | ||
| 296 | } = useThreadListV2ShelfPreferences(); | 301 | } = useThreadListV2ShelfPreferences(); |
| 297 | // The queued-start and snooze helpers need a clock while the pane stays open. | 302 | // The queued-start and snooze helpers need a clock while the pane stays open. |
| 298 | const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); | 303 | const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); |
| ... | @@ -343,8 +348,13 @@ function ThreadNavigationSidebarPane( | ... | @@ -343,8 +348,13 @@ function ThreadNavigationSidebarPane( |
| 343 | queuedThreadKeys, | 348 | queuedThreadKeys, |
| 344 | }), | 349 | }), |
| 345 | }); | 350 | }); |
| 346 | return new Map([...sectionAvailability("pinned"), ...sectionAvailability("active")]); | 351 | // The Working beta orders the inbox by time, so only pins can move. |
| 352 | return new Map([ | ||
| 353 | ...sectionAvailability("pinned"), | ||
| 354 | ...(workingShelfEnabled ? [] : sectionAvailability("active")), | ||
| 355 | ]); | ||
| 347 | }, [ | 356 | }, [ |
| 357 | workingShelfEnabled, | ||
| 348 | pinReorderEnvironmentIds, | 358 | pinReorderEnvironmentIds, |
| 349 | activeReorderEnvironmentIds, | 359 | activeReorderEnvironmentIds, |
| 350 | threads, | 360 | threads, |
| ... | @@ -356,6 +366,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -356,6 +366,7 @@ function ThreadNavigationSidebarPane( |
| 356 | snoozeWakeTick, | 366 | snoozeWakeTick, |
| 357 | ]); | 367 | ]); |
| 358 | const threadListV2Layout = useMemo(() => { | 368 | const threadListV2Layout = useMemo(() => { |
| 369 | threadListInboxReturns.observe(workingShelfEnabled ? threads : null); | ||
| 359 | return buildThreadListV2Items({ | 370 | return buildThreadListV2Items({ |
| 360 | pendingOrder, | 371 | pendingOrder, |
| 361 | threads: threads.filter((thread) => thread.archivedAt === null), | 372 | threads: threads.filter((thread) => thread.archivedAt === null), |
| ... | @@ -368,11 +379,16 @@ function ThreadNavigationSidebarPane( | ... | @@ -368,11 +379,16 @@ function ThreadNavigationSidebarPane( |
| 368 | queuedThreadKeys, | 379 | queuedThreadKeys, |
| 369 | settledLimit: settledVisibleCount, | 380 | settledLimit: settledVisibleCount, |
| 370 | now: new Date().toISOString(), | 381 | now: new Date().toISOString(), |
| 382 | workingShelfEnabled, | ||
| 383 | workingShelfExpanded, | ||
| 384 | inboxReturnAt: threadListInboxReturns.returnedAt, | ||
| 371 | snoozedShelfExpanded, | 385 | snoozedShelfExpanded, |
| 372 | settledShelfExpanded, | 386 | settledShelfExpanded, |
| 373 | selectedThreadKey: props.selectedThreadKey ?? null, | 387 | selectedThreadKey: props.selectedThreadKey ?? null, |
| 374 | }); | 388 | }); |
| 375 | }, [ | 389 | }, [ |
| 390 | workingShelfEnabled, | ||
| 391 | workingShelfExpanded, | ||
| 376 | pendingOrder, | 392 | pendingOrder, |
| 377 | queuedThreadKeys, | 393 | queuedThreadKeys, |
| 378 | nowMinute, | 394 | nowMinute, |
| ... | @@ -424,6 +440,9 @@ function ThreadNavigationSidebarPane( | ... | @@ -424,6 +440,9 @@ function ThreadNavigationSidebarPane( |
| 424 | const items: SidebarListItem[] = buildThreadListV2ListItems({ | 440 | const items: SidebarListItem[] = buildThreadListV2ListItems({ |
| 425 | items: threadListV2Layout.items, | 441 | items: threadListV2Layout.items, |
| 426 | pendingTasks: v2PendingTasks, | 442 | pendingTasks: v2PendingTasks, |
| 443 | workingCount: threadListV2Layout.workingCount, | ||
| 444 | workingShelfExpanded, | ||
| 445 | workingShelfHeaderIndex: threadListV2Layout.workingShelfHeaderIndex, | ||
| 427 | snoozedCount: threadListV2Layout.snoozedCount, | 446 | snoozedCount: threadListV2Layout.snoozedCount, |
| 428 | snoozedShelfExpanded, | 447 | snoozedShelfExpanded, |
| 429 | snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex, | 448 | snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex, |
| ... | @@ -457,6 +476,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -457,6 +476,7 @@ function ThreadNavigationSidebarPane( |
| 457 | snoozedShelfExpanded, | 476 | snoozedShelfExpanded, |
| 458 | snoozeEnvironmentIds, | 477 | snoozeEnvironmentIds, |
| 459 | threadListV2Layout, | 478 | threadListV2Layout, |
| 479 | workingShelfExpanded, | ||
| 460 | ]); | 480 | ]); |
| 461 | const listMenuActions = useMemo<MenuAction[]>( | 481 | const listMenuActions = useMemo<MenuAction[]>( |
| 462 | () => [ | 482 | () => [ |
| ... | @@ -587,6 +607,8 @@ function ThreadNavigationSidebarPane( | ... | @@ -587,6 +607,8 @@ function ThreadNavigationSidebarPane( |
| 587 | savedConnectionsById, | 607 | savedConnectionsById, |
| 588 | listEnvironments, | 608 | listEnvironments, |
| 589 | threadSearchMatchByKey, | 609 | threadSearchMatchByKey, |
| 610 | // Rows read it for their reorder menu items. | ||
| 611 | workingShelfEnabled, | ||
| 590 | }), | 612 | }), |
| 591 | [ | 613 | [ |
| 592 | props.selectedThreadKey, | 614 | props.selectedThreadKey, |
| ... | @@ -595,6 +617,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -595,6 +617,7 @@ function ThreadNavigationSidebarPane( |
| 595 | savedConnectionsById, | 617 | savedConnectionsById, |
| 596 | listEnvironments, | 618 | listEnvironments, |
| 597 | threadSearchMatchByKey, | 619 | threadSearchMatchByKey, |
| 620 | workingShelfEnabled, | ||
| 598 | ], | 621 | ], |
| 599 | ); | 622 | ); |
| 600 | useThreadJumpShortcuts(listItems, handleSelectThread); | 623 | useThreadJumpShortcuts(listItems, handleSelectThread); |
| ... | @@ -712,7 +735,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -712,7 +735,7 @@ function ThreadNavigationSidebarPane( |
| 712 | reorderSupported={ | 735 | reorderSupported={ |
| 713 | item.item.pinned | 736 | item.item.pinned |
| 714 | ? pinReorderEnvironmentIds.has(thread.environmentId) | 737 | ? pinReorderEnvironmentIds.has(thread.environmentId) |
| 715 | : activeReorderEnvironmentIds.has(thread.environmentId) | 738 | : !workingShelfEnabled && activeReorderEnvironmentIds.has(thread.environmentId) |
| 716 | } | 739 | } |
| 717 | canMoveUp={item.canMoveUp} | 740 | canMoveUp={item.canMoveUp} |
| 718 | canMoveDown={item.canMoveDown} | 741 | canMoveDown={item.canMoveDown} |
| ... | @@ -729,6 +752,16 @@ function ThreadNavigationSidebarPane( | ... | @@ -729,6 +752,16 @@ function ThreadNavigationSidebarPane( |
| 729 | /> | 752 | /> |
| 730 | ); | 753 | ); |
| 731 | } | 754 | } |
| 755 | case "v2-working-shelf": | ||
| 756 | return ( | ||
| 757 | <ThreadListV2WorkingShelfHeader | ||
| 758 | count={item.count} | ||
| 759 | disabled={item.disabled} | ||
| 760 | expanded={item.expanded} | ||
| 761 | onToggle={toggleWorkingShelf} | ||
| 762 | pane="sidebar" | ||
| 763 | /> | ||
| 764 | ); | ||
| 732 | case "v2-snoozed-shelf": | 765 | case "v2-snoozed-shelf": |
| 733 | return ( | 766 | return ( |
| 734 | <ThreadListV2SnoozedShelfHeader | 767 | <ThreadListV2SnoozedShelfHeader |
| ... | @@ -797,9 +830,11 @@ function ThreadNavigationSidebarPane( | ... | @@ -797,9 +830,11 @@ function ThreadNavigationSidebarPane( |
| 797 | snoozeThread, | 830 | snoozeThread, |
| 798 | toggleSettledShelf, | 831 | toggleSettledShelf, |
| 799 | toggleSnoozedShelf, | 832 | toggleSnoozedShelf, |
| 833 | toggleWorkingShelf, | ||
| 800 | unpinThread, | 834 | unpinThread, |
| 801 | unsettleThread, | 835 | unsettleThread, |
| 802 | unsnoozeThread, | 836 | unsnoozeThread, |
| 837 | workingShelfEnabled, | ||
| 803 | ], | 838 | ], |
| 804 | ); | 839 | ); |
| 805 | // The list ignores sort/group options, so only the environment and project | 840 | // The list ignores sort/group options, so only the environment and project |
apps/mobile/src/features/threads/thread-list-v2-items.tsx+10-2| ... | @@ -191,10 +191,12 @@ type ThreadListV2ShelfHeaderProps = { | ... | @@ -191,10 +191,12 @@ type ThreadListV2ShelfHeaderProps = { |
| 191 | readonly pane?: "screen" | "sidebar"; | 191 | readonly pane?: "screen" | "sidebar"; |
| 192 | }; | 192 | }; |
| 193 | 193 | ||
| 194 | const SHELF_LABEL = { working: "Working", snoozed: "Snoozed", settled: "Settled" } as const; | ||
| 195 | |||
| 194 | function ThreadListV2ShelfHeader( | 196 | function ThreadListV2ShelfHeader( |
| 195 | props: ThreadListV2ShelfHeaderProps & { readonly kind: "snoozed" | "settled" }, | 197 | props: ThreadListV2ShelfHeaderProps & { readonly kind: keyof typeof SHELF_LABEL }, |
| 196 | ) { | 198 | ) { |
| 197 | const label = props.kind === "snoozed" ? "Snoozed" : "Settled"; | 199 | const label = SHELF_LABEL[props.kind]; |
| 198 | return ( | 200 | return ( |
| 199 | <ThreadListV2Section | 201 | <ThreadListV2Section |
| 200 | label={props.expanded ? label : `${label} (${props.count})`} | 202 | label={props.expanded ? label : `${label} (${props.count})`} |
| ... | @@ -211,6 +213,12 @@ function ThreadListV2ShelfHeader( | ... | @@ -211,6 +213,12 @@ function ThreadListV2ShelfHeader( |
| 211 | ); | 213 | ); |
| 212 | } | 214 | } |
| 213 | 215 | ||
| 216 | export const ThreadListV2WorkingShelfHeader = memo(function ThreadListV2WorkingShelfHeader( | ||
| 217 | props: ThreadListV2ShelfHeaderProps, | ||
| 218 | ) { | ||
| 219 | return <ThreadListV2ShelfHeader {...props} kind="working" />; | ||
| 220 | }); | ||
| 221 | |||
| 214 | export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedShelfHeader( | 222 | export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedShelfHeader( |
| 215 | props: ThreadListV2ShelfHeaderProps, | 223 | props: ThreadListV2ShelfHeaderProps, |
| 216 | ) { | 224 | ) { |
apps/mobile/src/features/threads/threadListV2.test.ts+144| ... | @@ -2158,3 +2158,147 @@ describe("buildThreadListV2ListItems row-state stamps", () => { | ... | @@ -2158,3 +2158,147 @@ describe("buildThreadListV2ListItems row-state stamps", () => { |
| 2158 | expect(threadListV2ListItemsAreEqual(shelfLoading, shelfLoaded)).toBe(false); | 2158 | expect(threadListV2ListItemsAreEqual(shelfLoading, shelfLoaded)).toBe(false); |
| 2159 | }); | 2159 | }); |
| 2160 | }); | 2160 | }); |
| 2161 | |||
| 2162 | describe("Working section beta", () => { | ||
| 2163 | const running = { | ||
| 2164 | status: "running" as const, | ||
| 2165 | activeRunId: null, | ||
| 2166 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 2167 | providerName: "Codex", | ||
| 2168 | lastError: null, | ||
| 2169 | updatedAt: NOW, | ||
| 2170 | }; | ||
| 2171 | const finishedAt = (id: string, completedAt: string) => | ||
| 2172 | makeThread({ | ||
| 2173 | id: ThreadId.make(id), | ||
| 2174 | title: id, | ||
| 2175 | createdAt: "2026-06-01T00:00:00.000Z", | ||
| 2176 | latestRun: { | ||
| 2177 | runId: RunId.make(`run-${id}`), | ||
| 2178 | status: "completed", | ||
| 2179 | requestedAt: "2026-06-01T00:00:00.000Z", | ||
| 2180 | startedAt: "2026-06-01T00:00:00.000Z", | ||
| 2181 | completedAt, | ||
| 2182 | assistantMessageId: null, | ||
| 2183 | }, | ||
| 2184 | }); | ||
| 2185 | const threads = [ | ||
| 2186 | finishedAt("finished-early", "2026-06-01T01:00:00.000Z"), | ||
| 2187 | finishedAt("finished-late", "2026-06-01T03:00:00.000Z"), | ||
| 2188 | makeThread({ id: ThreadId.make("working"), title: "working", runtime: running }), | ||
| 2189 | makeThread({ | ||
| 2190 | id: ThreadId.make("asks-approval"), | ||
| 2191 | title: "asks-approval", | ||
| 2192 | createdAt: "2026-06-01T02:00:00.000Z", | ||
| 2193 | runtime: running, | ||
| 2194 | hasPendingApprovals: true, | ||
| 2195 | }), | ||
| 2196 | makeThread({ | ||
| 2197 | id: ThreadId.make("pinned-working"), | ||
| 2198 | title: "pinned-working", | ||
| 2199 | runtime: running, | ||
| 2200 | pinnedAt: "2026-06-01T00:00:00.000Z", | ||
| 2201 | }), | ||
| 2202 | ]; | ||
| 2203 | const build = (input: Partial<Parameters<typeof buildThreadListV2Items>[0]> = {}) => | ||
| 2204 | buildThreadListV2Items({ | ||
| 2205 | threads, | ||
| 2206 | environmentId: null, | ||
| 2207 | searchQuery: "", | ||
| 2208 | now: NOW, | ||
| 2209 | workingShelfEnabled: true, | ||
| 2210 | ...input, | ||
| 2211 | }); | ||
| 2212 | const ids = (layout: ReturnType<typeof buildThreadListV2Items>) => | ||
| 2213 | layout.items.map((item) => item.thread.id); | ||
| 2214 | |||
| 2215 | it("folds unpinned working threads into a collapsed shelf", () => { | ||
| 2216 | const layout = build(); | ||
| 2217 | expect(ids(layout)).toEqual([ | ||
| 2218 | "pinned-working", | ||
| 2219 | "finished-late", | ||
| 2220 | "asks-approval", | ||
| 2221 | "finished-early", | ||
| 2222 | ]); | ||
| 2223 | expect(layout.workingCount).toBe(1); | ||
| 2224 | expect(layout.workingShelfHeaderIndex).toBe(4); | ||
| 2225 | |||
| 2226 | const off = build({ workingShelfEnabled: false }); | ||
| 2227 | expect(ids(off)).toContain("working"); | ||
| 2228 | expect(off.workingCount).toBe(0); | ||
| 2229 | }); | ||
| 2230 | |||
| 2231 | it("shows working rows as cards when expanded, or only the selected one when collapsed", () => { | ||
| 2232 | expect(build({ workingShelfExpanded: true }).items.at(-1)).toMatchObject({ | ||
| 2233 | thread: { id: "working" }, | ||
| 2234 | variant: "card", | ||
| 2235 | }); | ||
| 2236 | expect(ids(build({ selectedThreadKey: `${environmentId}:working` })).at(-1)).toBe("working"); | ||
| 2237 | }); | ||
| 2238 | |||
| 2239 | it("orders the inbox by the latest return this device observed", () => { | ||
| 2240 | const layout = build({ | ||
| 2241 | inboxReturnAt: (thread) => | ||
| 2242 | thread.id === "finished-early" ? Date.parse("2026-06-01T04:00:00.000Z") : undefined, | ||
| 2243 | }); | ||
| 2244 | expect(ids(layout).slice(1)).toEqual(["finished-early", "finished-late", "asks-approval"]); | ||
| 2245 | }); | ||
| 2246 | |||
| 2247 | it("places the shelf after queued tasks and before snoozed and settled threads", () => { | ||
| 2248 | const layout = buildThreadListV2Items({ | ||
| 2249 | threads: [ | ||
| 2250 | makeThread({ id: ThreadId.make("active"), title: "active" }), | ||
| 2251 | makeThread({ id: ThreadId.make("working"), title: "working", runtime: running }), | ||
| 2252 | makeThread({ | ||
| 2253 | id: ThreadId.make("snoozed"), | ||
| 2254 | title: "snoozed", | ||
| 2255 | runtime: running, | ||
| 2256 | snoozedUntil: "2026-06-03T09:00:00.000Z", | ||
| 2257 | snoozedAt: "2026-06-01T12:00:00.000Z", | ||
| 2258 | }), | ||
| 2259 | makeThread({ | ||
| 2260 | id: ThreadId.make("settled"), | ||
| 2261 | title: "settled", | ||
| 2262 | settledOverride: "settled", | ||
| 2263 | settledAt: NOW, | ||
| 2264 | }), | ||
| 2265 | ], | ||
| 2266 | environmentId: null, | ||
| 2267 | searchQuery: "", | ||
| 2268 | now: NOW, | ||
| 2269 | workingShelfEnabled: true, | ||
| 2270 | workingShelfExpanded: true, | ||
| 2271 | snoozedShelfExpanded: true, | ||
| 2272 | }); | ||
| 2273 | const items = buildThreadListV2ListItems({ | ||
| 2274 | items: layout.items, | ||
| 2275 | pendingTasks: [makePendingTask("queued")], | ||
| 2276 | workingCount: layout.workingCount, | ||
| 2277 | workingShelfExpanded: true, | ||
| 2278 | workingShelfHeaderIndex: layout.workingShelfHeaderIndex, | ||
| 2279 | snoozedCount: layout.snoozedCount, | ||
| 2280 | snoozedShelfExpanded: true, | ||
| 2281 | snoozedShelfHeaderIndex: layout.snoozedShelfHeaderIndex, | ||
| 2282 | settledCount: layout.settledCount, | ||
| 2283 | settledShelfHeaderIndex: layout.settledShelfHeaderIndex, | ||
| 2284 | }); | ||
| 2285 | expect( | ||
| 2286 | items.map((item) => | ||
| 2287 | item.type === "v2-thread" | ||
| 2288 | ? item.item.thread.id | ||
| 2289 | : item.type === "v2-pending" | ||
| 2290 | ? item.pendingTask.title | ||
| 2291 | : item.type, | ||
| 2292 | ), | ||
| 2293 | ).toEqual([ | ||
| 2294 | "active", | ||
| 2295 | "queued", | ||
| 2296 | "v2-working-shelf", | ||
| 2297 | "working", | ||
| 2298 | "v2-snoozed-shelf", | ||
| 2299 | "snoozed", | ||
| 2300 | "v2-settled-shelf", | ||
| 2301 | "settled", | ||
| 2302 | ]); | ||
| 2303 | }); | ||
| 2304 | }); |
apps/mobile/src/features/threads/threadListV2.ts+91-5| ... | @@ -11,6 +11,11 @@ import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled" | ... | @@ -11,6 +11,11 @@ import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled" |
| 11 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | 11 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 12 | import { resolveThreadProviderStack } from "@t3tools/client-runtime/state/models"; | 12 | import { resolveThreadProviderStack } from "@t3tools/client-runtime/state/models"; |
| 13 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; | 13 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; |
| 14 | import { | ||
| 15 | createInboxReturnTracker, | ||
| 16 | isThreadWorking, | ||
| 17 | sortInboxThreadsByReturn, | ||
| 18 | } from "@t3tools/client-runtime/state/thread-inbox"; | ||
| 14 | import { | 19 | import { |
| 15 | sortActiveThreadsByOrderKey, | 20 | sortActiveThreadsByOrderKey, |
| 16 | resolveSettledThreadTimestamp, | 21 | resolveSettledThreadTimestamp, |
| ... | @@ -33,6 +38,11 @@ import { | ... | @@ -33,6 +38,11 @@ import { |
| 33 | 38 | ||
| 34 | export { snoozeWakeLabel }; | 39 | export { snoozeWakeLabel }; |
| 35 | 40 | ||
| 41 | /** Working section beta: when this device saw each thread leave the Working | ||
| 42 | shelf. One instance for Home and the iPad sidebar, so both lists order the | ||
| 43 | inbox the same way and the order survives screens that unmount. */ | ||
| 44 | export const threadListInboxReturns = createInboxReturnTracker(); | ||
| 45 | |||
| 36 | /** | 46 | /** |
| 37 | * Provider drivers for a row's trailing icon stack, back to front. Instances | 47 | * Provider drivers for a row's trailing icon stack, back to front. Instances |
| 38 | * missing from the environment's config are skipped, and an unresolved | 48 | * missing from the environment's config are skipped, and an unresolved |
| ... | @@ -269,6 +279,10 @@ export interface ThreadListV2Layout { | ... | @@ -269,6 +279,10 @@ export interface ThreadListV2Layout { |
| 269 | readonly items: ThreadListV2Item[]; | 279 | readonly items: ThreadListV2Item[]; |
| 270 | /** Settled threads beyond the render limit (behind "Show more"). */ | 280 | /** Settled threads beyond the render limit (behind "Show more"). */ |
| 271 | readonly hiddenSettledCount: number; | 281 | readonly hiddenSettledCount: number; |
| 282 | /** Working threads folded away by the Working section beta. */ | ||
| 283 | readonly workingCount: number; | ||
| 284 | /** Index in `items` where the Working shelf header belongs. */ | ||
| 285 | readonly workingShelfHeaderIndex: number | null; | ||
| 272 | /** Snoozed threads matching the current filters. */ | 286 | /** Snoozed threads matching the current filters. */ |
| 273 | readonly snoozedCount: number; | 287 | readonly snoozedCount: number; |
| 274 | /** Index in `items` where the Snoozed shelf header belongs. The header is | 288 | /** Index in `items` where the Snoozed shelf header belongs. The header is |
| ... | @@ -325,6 +339,15 @@ export interface ThreadListV2PendingListItem { | ... | @@ -325,6 +339,15 @@ export interface ThreadListV2PendingListItem { |
| 325 | readonly showTrailingDivider: boolean; | 339 | readonly showTrailingDivider: boolean; |
| 326 | } | 340 | } |
| 327 | 341 | ||
| 342 | export interface ThreadListV2WorkingShelfListItem { | ||
| 343 | readonly type: "v2-working-shelf"; | ||
| 344 | readonly key: "v2-working-shelf"; | ||
| 345 | readonly count: number; | ||
| 346 | readonly expanded: boolean; | ||
| 347 | /** See the snoozed shelf header's field. */ | ||
| 348 | readonly disabled: boolean; | ||
| 349 | } | ||
| 350 | |||
| 328 | export interface ThreadListV2SnoozedShelfListItem { | 351 | export interface ThreadListV2SnoozedShelfListItem { |
| 329 | readonly type: "v2-snoozed-shelf"; | 352 | readonly type: "v2-snoozed-shelf"; |
| 330 | readonly key: "v2-snoozed-shelf"; | 353 | readonly key: "v2-snoozed-shelf"; |
| ... | @@ -349,6 +372,7 @@ export interface ThreadListV2SettledShelfListItem { | ... | @@ -349,6 +372,7 @@ export interface ThreadListV2SettledShelfListItem { |
| 349 | export type ThreadListV2ListItem = | 372 | export type ThreadListV2ListItem = |
| 350 | | ThreadListV2ThreadListItem | 373 | | ThreadListV2ThreadListItem |
| 351 | | ThreadListV2PendingListItem | 374 | | ThreadListV2PendingListItem |
| 375 | | ThreadListV2WorkingShelfListItem | ||
| 352 | | ThreadListV2SnoozedShelfListItem | 376 | | ThreadListV2SnoozedShelfListItem |
| 353 | | ThreadListV2SettledShelfListItem; | 377 | | ThreadListV2SettledShelfListItem; |
| 354 | 378 | ||
| ... | @@ -360,6 +384,7 @@ export function isThreadListV2ListItem(value: { | ... | @@ -360,6 +384,7 @@ export function isThreadListV2ListItem(value: { |
| 360 | return ( | 384 | return ( |
| 361 | value.type === "v2-thread" || | 385 | value.type === "v2-thread" || |
| 362 | value.type === "v2-pending" || | 386 | value.type === "v2-pending" || |
| 387 | value.type === "v2-working-shelf" || | ||
| 363 | value.type === "v2-snoozed-shelf" || | 388 | value.type === "v2-snoozed-shelf" || |
| 364 | value.type === "v2-settled-shelf" | 389 | value.type === "v2-settled-shelf" |
| 365 | ); | 390 | ); |
| ... | @@ -401,6 +426,13 @@ export function threadListV2ListItemsAreEqual( | ... | @@ -401,6 +426,13 @@ export function threadListV2ListItemsAreEqual( |
| 401 | previous.showPendingDivider === item.showPendingDivider && | 426 | previous.showPendingDivider === item.showPendingDivider && |
| 402 | previous.showTrailingDivider === item.showTrailingDivider | 427 | previous.showTrailingDivider === item.showTrailingDivider |
| 403 | ); | 428 | ); |
| 429 | case "v2-working-shelf": | ||
| 430 | return ( | ||
| 431 | previous.type === "v2-working-shelf" && | ||
| 432 | previous.count === item.count && | ||
| 433 | previous.expanded === item.expanded && | ||
| 434 | previous.disabled === item.disabled | ||
| 435 | ); | ||
| 404 | case "v2-snoozed-shelf": | 436 | case "v2-snoozed-shelf": |
| 405 | return ( | 437 | return ( |
| 406 | previous.type === "v2-snoozed-shelf" && | 438 | previous.type === "v2-snoozed-shelf" && |
| ... | @@ -441,13 +473,17 @@ function resolveThreadListV2ItemTimeLabel( | ... | @@ -441,13 +473,17 @@ function resolveThreadListV2ItemTimeLabel( |
| 441 | } | 473 | } |
| 442 | 474 | ||
| 443 | /** | 475 | /** |
| 444 | * Builds the shared mobile order: active → pending → snoozed shelf → settled. | 476 | * Builds the shared mobile order: active → pending → working shelf (beta) → |
| 445 | * Pending tasks are waiting rather than asking, and parked work remains | 477 | * snoozed shelf → settled. Pending tasks are waiting rather than asking, and |
| 446 | * reachable without competing with either the inbox or settled history. | 478 | * busy or parked work remains reachable without competing with either the |
| 479 | * inbox or settled history. | ||
| 447 | */ | 480 | */ |
| 448 | export function buildThreadListV2ListItems(input: { | 481 | export function buildThreadListV2ListItems(input: { |
| 449 | readonly items: ReadonlyArray<ThreadListV2Item>; | 482 | readonly items: ReadonlyArray<ThreadListV2Item>; |
| 450 | readonly pendingTasks: ReadonlyArray<PendingNewTask>; | 483 | readonly pendingTasks: ReadonlyArray<PendingNewTask>; |
| 484 | readonly workingCount?: number; | ||
| 485 | readonly workingShelfExpanded?: boolean; | ||
| 486 | readonly workingShelfHeaderIndex?: number | null; | ||
| 451 | readonly snoozedCount?: number; | 487 | readonly snoozedCount?: number; |
| 452 | readonly snoozedShelfExpanded?: boolean; | 488 | readonly snoozedShelfExpanded?: boolean; |
| 453 | readonly snoozedShelfHeaderIndex?: number | null; | 489 | readonly snoozedShelfHeaderIndex?: number | null; |
| ... | @@ -514,14 +550,27 @@ export function buildThreadListV2ListItems(input: { | ... | @@ -514,14 +550,27 @@ export function buildThreadListV2ListItems(input: { |
| 514 | showPendingDivider: index === 0, | 550 | showPendingDivider: index === 0, |
| 515 | showTrailingDivider: false, | 551 | showTrailingDivider: false, |
| 516 | })); | 552 | })); |
| 553 | const workingCount = input.workingCount ?? 0; | ||
| 554 | const workingShelfHeaderIndex = input.workingShelfHeaderIndex ?? null; | ||
| 517 | const snoozedCount = input.snoozedCount ?? 0; | 555 | const snoozedCount = input.snoozedCount ?? 0; |
| 518 | const snoozedShelfHeaderIndex = input.snoozedShelfHeaderIndex ?? null; | 556 | const snoozedShelfHeaderIndex = input.snoozedShelfHeaderIndex ?? null; |
| 519 | const settledCount = input.settledCount ?? 0; | 557 | const settledCount = input.settledCount ?? 0; |
| 520 | const settledShelfHeaderIndex = input.settledShelfHeaderIndex ?? null; | 558 | const settledShelfHeaderIndex = input.settledShelfHeaderIndex ?? null; |
| 521 | const activeEnd = snoozedShelfHeaderIndex ?? settledShelfHeaderIndex ?? threadItems.length; | ||
| 522 | const snoozedEnd = settledShelfHeaderIndex ?? threadItems.length; | 559 | const snoozedEnd = settledShelfHeaderIndex ?? threadItems.length; |
| 560 | const workingEnd = snoozedShelfHeaderIndex ?? snoozedEnd; | ||
| 561 | const activeEnd = workingShelfHeaderIndex ?? workingEnd; | ||
| 523 | const result: ThreadListV2ListItem[] = [...threadItems.slice(0, activeEnd), ...pendingItems]; | 562 | const result: ThreadListV2ListItem[] = [...threadItems.slice(0, activeEnd), ...pendingItems]; |
| 524 | const shelfDisabled = input.shelfPreferencesLoading === true; | 563 | const shelfDisabled = input.shelfPreferencesLoading === true; |
| 564 | if (workingShelfHeaderIndex !== null && workingCount > 0) { | ||
| 565 | result.push({ | ||
| 566 | type: "v2-working-shelf", | ||
| 567 | key: "v2-working-shelf", | ||
| 568 | count: workingCount, | ||
| 569 | expanded: input.workingShelfExpanded === true, | ||
| 570 | disabled: shelfDisabled, | ||
| 571 | }); | ||
| 572 | result.push(...threadItems.slice(workingShelfHeaderIndex, workingEnd)); | ||
| 573 | } | ||
| 525 | if (snoozedShelfHeaderIndex !== null && snoozedCount > 0) { | 574 | if (snoozedShelfHeaderIndex !== null && snoozedCount > 0) { |
| 526 | result.push({ | 575 | result.push({ |
| 527 | type: "v2-snoozed-shelf", | 576 | type: "v2-snoozed-shelf", |
| ... | @@ -580,6 +629,15 @@ export function buildThreadListV2Items(input: { | ... | @@ -580,6 +629,15 @@ export function buildThreadListV2Items(input: { |
| 580 | readonly settledLimit?: number; | 629 | readonly settledLimit?: number; |
| 581 | /** Second-precise clock used for time-based classification. */ | 630 | /** Second-precise clock used for time-based classification. */ |
| 582 | readonly now: string; | 631 | readonly now: string; |
| 632 | /** Working section beta: unpinned working threads fold into the Working | ||
| 633 | shelf, and the inbox orders by when each thread came back to the user | ||
| 634 | instead of the saved arrangement. */ | ||
| 635 | readonly workingShelfEnabled?: boolean; | ||
| 636 | /** Expands the Working shelf into rows. Collapsed is the default. */ | ||
| 637 | readonly workingShelfExpanded?: boolean; | ||
| 638 | /** Returns this device observed but the server does not stamp, such as an | ||
| 639 | approval request mid-turn. Only read while the beta is on. */ | ||
| 640 | readonly inboxReturnAt?: (thread: EnvironmentThreadShell) => number | undefined; | ||
| 583 | /** Expands the snoozed shelf into rows. Collapsed is the default. */ | 641 | /** Expands the snoozed shelf into rows. Collapsed is the default. */ |
| 584 | readonly snoozedShelfExpanded?: boolean; | 642 | readonly snoozedShelfExpanded?: boolean; |
| 585 | /** Expands the settled shelf into rows. Expanded is the default. */ | 643 | /** Expands the settled shelf into rows. Expanded is the default. */ |
| ... | @@ -609,8 +667,10 @@ export function buildThreadListV2Items(input: { | ... | @@ -609,8 +667,10 @@ export function buildThreadListV2Items(input: { |
| 609 | ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) | 667 | ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) |
| 610 | : null; | 668 | : null; |
| 611 | 669 | ||
| 670 | const workingShelfEnabled = input.workingShelfEnabled === true; | ||
| 612 | const pinned: EnvironmentThreadShell[] = []; | 671 | const pinned: EnvironmentThreadShell[] = []; |
| 613 | const active: EnvironmentThreadShell[] = []; | 672 | const active: EnvironmentThreadShell[] = []; |
| 673 | const working: EnvironmentThreadShell[] = []; | ||
| 614 | const settled: EnvironmentThreadShell[] = []; | 674 | const settled: EnvironmentThreadShell[] = []; |
| 615 | const snoozed: EnvironmentThreadShell[] = []; | 675 | const snoozed: EnvironmentThreadShell[] = []; |
| 616 | let nextSnoozeWakeAt: string | null = null; | 676 | let nextSnoozeWakeAt: string | null = null; |
| ... | @@ -656,17 +716,31 @@ export function buildThreadListV2Items(input: { | ... | @@ -656,17 +716,31 @@ export function buildThreadListV2Items(input: { |
| 656 | settled.push(thread); | 716 | settled.push(thread); |
| 657 | } else if (thread.pinnedAt != null) { | 717 | } else if (thread.pinnedAt != null) { |
| 658 | pinned.push(thread); | 718 | pinned.push(thread); |
| 719 | } else if (workingShelfEnabled && isThreadWorking(thread)) { | ||
| 720 | working.push(thread); | ||
| 659 | } else { | 721 | } else { |
| 660 | active.push(thread); | 722 | active.push(thread); |
| 661 | } | 723 | } |
| 662 | } | 724 | } |
| 663 | 725 | ||
| 664 | const orderedActive = applyPendingThreadOrder(sortThreadsForListV2(active), "active", pending); | 726 | // The beta inbox is time-ordered, so the saved arrangement (and any move in |
| 727 | // flight) is kept but not applied until the beta is off again. | ||
| 728 | const orderedActive = workingShelfEnabled | ||
| 729 | ? sortInboxThreadsByReturn(active, input.inboxReturnAt) | ||
| 730 | : applyPendingThreadOrder(sortThreadsForListV2(active), "active", pending); | ||
| 731 | // Newest work first, by the same clock as the inbox. | ||
| 732 | const orderedWorking = sortInboxThreadsByReturn(working); | ||
| 665 | const orderedSnoozed = [...snoozed].sort( | 733 | const orderedSnoozed = [...snoozed].sort( |
| 666 | (left, right) => | 734 | (left, right) => |
| 667 | parseTimestampMs(left.snoozedUntil ?? "") - parseTimestampMs(right.snoozedUntil ?? ""), | 735 | parseTimestampMs(left.snoozedUntil ?? "") - parseTimestampMs(right.snoozedUntil ?? ""), |
| 668 | ); | 736 | ); |
| 669 | const selectedThreadKey = input.selectedThreadKey ?? null; | 737 | const selectedThreadKey = input.selectedThreadKey ?? null; |
| 738 | const visibleWorking = | ||
| 739 | input.workingShelfExpanded === true | ||
| 740 | ? orderedWorking | ||
| 741 | : orderedWorking.filter( | ||
| 742 | (thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey, | ||
| 743 | ); | ||
| 670 | const visibleSnoozed = | 744 | const visibleSnoozed = |
| 671 | input.snoozedShelfExpanded === true | 745 | input.snoozedShelfExpanded === true |
| 672 | ? orderedSnoozed | 746 | ? orderedSnoozed |
| ... | @@ -711,6 +785,16 @@ export function buildThreadListV2Items(input: { | ... | @@ -711,6 +785,16 @@ export function buildThreadListV2Items(input: { |
| 711 | isLast: false, | 785 | isLast: false, |
| 712 | }); | 786 | }); |
| 713 | } | 787 | } |
| 788 | const workingShelfHeaderIndex = orderedWorking.length > 0 ? items.length : null; | ||
| 789 | for (const thread of visibleWorking) { | ||
| 790 | items.push({ | ||
| 791 | thread, | ||
| 792 | variant: "card", | ||
| 793 | snoozed: false, | ||
| 794 | pinned: false, | ||
| 795 | isLast: false, | ||
| 796 | }); | ||
| 797 | } | ||
| 714 | const snoozedShelfHeaderIndex = orderedSnoozed.length > 0 ? items.length : null; | 798 | const snoozedShelfHeaderIndex = orderedSnoozed.length > 0 ? items.length : null; |
| 715 | for (const thread of visibleSnoozed) { | 799 | for (const thread of visibleSnoozed) { |
| 716 | items.push({ | 800 | items.push({ |
| ... | @@ -738,6 +822,8 @@ export function buildThreadListV2Items(input: { | ... | @@ -738,6 +822,8 @@ export function buildThreadListV2Items(input: { |
| 738 | return { | 822 | return { |
| 739 | items, | 823 | items, |
| 740 | hiddenSettledCount: orderedSettled.length - pagedSettled.length, | 824 | hiddenSettledCount: orderedSettled.length - pagedSettled.length, |
| 825 | workingCount: orderedWorking.length, | ||
| 826 | workingShelfHeaderIndex, | ||
| 741 | snoozedCount: orderedSnoozed.length, | 827 | snoozedCount: orderedSnoozed.length, |
| 742 | snoozedShelfHeaderIndex, | 828 | snoozedShelfHeaderIndex, |
| 743 | settledCount: orderedSettled.length, | 829 | settledCount: orderedSettled.length, |
apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts+15| ... | @@ -17,10 +17,16 @@ export function useThreadListV2ShelfPreferences() { | ... | @@ -17,10 +17,16 @@ export function useThreadListV2ShelfPreferences() { |
| 17 | loaded && preferencesResult.value.threadListSnoozedShelfExpanded === true; | 17 | loaded && preferencesResult.value.threadListSnoozedShelfExpanded === true; |
| 18 | const settledShelfExpanded = | 18 | const settledShelfExpanded = |
| 19 | loaded && preferencesResult.value.threadListSettledShelfExpanded === true; | 19 | loaded && preferencesResult.value.threadListSettledShelfExpanded === true; |
| 20 | // Working section beta: off until the preference loads and is enabled. | ||
| 21 | const workingShelfEnabled = loaded && preferencesResult.value.workingShelfEnabled === true; | ||
| 22 | const workingShelfExpanded = | ||
| 23 | loaded && preferencesResult.value.threadListWorkingShelfExpanded === true; | ||
| 20 | const snoozedShelfExpandedRef = useRef(snoozedShelfExpanded); | 24 | const snoozedShelfExpandedRef = useRef(snoozedShelfExpanded); |
| 21 | const settledShelfExpandedRef = useRef(settledShelfExpanded); | 25 | const settledShelfExpandedRef = useRef(settledShelfExpanded); |
| 26 | const workingShelfExpandedRef = useRef(workingShelfExpanded); | ||
| 22 | snoozedShelfExpandedRef.current = snoozedShelfExpanded; | 27 | snoozedShelfExpandedRef.current = snoozedShelfExpanded; |
| 23 | settledShelfExpandedRef.current = settledShelfExpanded; | 28 | settledShelfExpandedRef.current = settledShelfExpanded; |
| 29 | workingShelfExpandedRef.current = workingShelfExpanded; | ||
| 24 | 30 | ||
| 25 | const toggleSnoozedShelf = useCallback(() => { | 31 | const toggleSnoozedShelf = useCallback(() => { |
| 26 | if (!loaded) return; | 32 | if (!loaded) return; |
| ... | @@ -34,12 +40,21 @@ export function useThreadListV2ShelfPreferences() { | ... | @@ -34,12 +40,21 @@ export function useThreadListV2ShelfPreferences() { |
| 34 | settledShelfExpandedRef.current = expanded; | 40 | settledShelfExpandedRef.current = expanded; |
| 35 | savePreferences({ threadListSettledShelfExpanded: expanded }); | 41 | savePreferences({ threadListSettledShelfExpanded: expanded }); |
| 36 | }, [loaded, savePreferences]); | 42 | }, [loaded, savePreferences]); |
| 43 | const toggleWorkingShelf = useCallback(() => { | ||
| 44 | if (!loaded) return; | ||
| 45 | const expanded = !workingShelfExpandedRef.current; | ||
| 46 | workingShelfExpandedRef.current = expanded; | ||
| 47 | savePreferences({ threadListWorkingShelfExpanded: expanded }); | ||
| 48 | }, [loaded, savePreferences]); | ||
| 37 | 49 | ||
| 38 | return { | 50 | return { |
| 39 | loaded, | 51 | loaded, |
| 40 | settledShelfExpanded, | 52 | settledShelfExpanded, |
| 41 | snoozedShelfExpanded, | 53 | snoozedShelfExpanded, |
| 54 | workingShelfEnabled, | ||
| 55 | workingShelfExpanded, | ||
| 42 | toggleSettledShelf, | 56 | toggleSettledShelf, |
| 43 | toggleSnoozedShelf, | 57 | toggleSnoozedShelf, |
| 58 | toggleWorkingShelf, | ||
| 44 | } as const; | 59 | } as const; |
| 45 | } | 60 | } |
apps/mobile/src/lib/storage.test.ts+9-1| ... | @@ -232,24 +232,32 @@ describe("mobile connection storage", () => { | ... | @@ -232,24 +232,32 @@ describe("mobile connection storage", () => { |
| 232 | expect(fallback.updatedAt).toEqual(expect.any(Number)); | 232 | expect(fallback.updatedAt).toEqual(expect.any(Number)); |
| 233 | }); | 233 | }); |
| 234 | 234 | ||
| 235 | it("persists thread list shelf expansion preferences", async () => { | 235 | it("persists thread list shelf preferences", async () => { |
| 236 | await expect( | 236 | await expect( |
| 237 | savePreferencesPatch({ | 237 | savePreferencesPatch({ |
| 238 | threadListSettledShelfExpanded: false, | 238 | threadListSettledShelfExpanded: false, |
| 239 | threadListSnoozedShelfExpanded: true, | 239 | threadListSnoozedShelfExpanded: true, |
| 240 | threadListWorkingShelfExpanded: true, | ||
| 241 | workingShelfEnabled: true, | ||
| 240 | }), | 242 | }), |
| 241 | ).resolves.toEqual({ | 243 | ).resolves.toEqual({ |
| 242 | threadListSettledShelfExpanded: false, | 244 | threadListSettledShelfExpanded: false, |
| 243 | threadListSnoozedShelfExpanded: true, | 245 | threadListSnoozedShelfExpanded: true, |
| 246 | threadListWorkingShelfExpanded: true, | ||
| 247 | workingShelfEnabled: true, | ||
| 244 | }); | 248 | }); |
| 245 | 249 | ||
| 246 | await expect(loadPreferences()).resolves.toEqual({ | 250 | await expect(loadPreferences()).resolves.toEqual({ |
| 247 | threadListSettledShelfExpanded: false, | 251 | threadListSettledShelfExpanded: false, |
| 248 | threadListSnoozedShelfExpanded: true, | 252 | threadListSnoozedShelfExpanded: true, |
| 253 | threadListWorkingShelfExpanded: true, | ||
| 254 | workingShelfEnabled: true, | ||
| 249 | }); | 255 | }); |
| 250 | expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ | 256 | expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ |
| 251 | threadListSettledShelfExpanded: false, | 257 | threadListSettledShelfExpanded: false, |
| 252 | threadListSnoozedShelfExpanded: true, | 258 | threadListSnoozedShelfExpanded: true, |
| 259 | threadListWorkingShelfExpanded: true, | ||
| 260 | workingShelfEnabled: true, | ||
| 253 | }); | 261 | }); |
| 254 | }); | 262 | }); |
| 255 | 263 |
apps/mobile/src/persistence/mobile-preferences.ts+11| ... | @@ -41,6 +41,8 @@ export interface Preferences { | ... | @@ -41,6 +41,8 @@ export interface Preferences { |
| 41 | readonly projectGroupingMode?: SidebarProjectGroupingMode; | 41 | readonly projectGroupingMode?: SidebarProjectGroupingMode; |
| 42 | /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */ | 42 | /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */ |
| 43 | readonly planModeEnabled?: boolean; | 43 | readonly planModeEnabled?: boolean; |
| 44 | /** Device-local counterpart of web's `sidebarWorkingShelfEnabled` beta. */ | ||
| 45 | readonly workingShelfEnabled?: boolean; | ||
| 44 | /** Model favorites belong to this device, like the web client setting. */ | 46 | /** Model favorites belong to this device, like the web client setting. */ |
| 45 | readonly modelFavorites?: ReadonlyArray<{ | 47 | readonly modelFavorites?: ReadonlyArray<{ |
| 46 | readonly provider: ProviderInstanceId; | 48 | readonly provider: ProviderInstanceId; |
| ... | @@ -49,6 +51,7 @@ export interface Preferences { | ... | @@ -49,6 +51,7 @@ export interface Preferences { |
| 49 | /** Fresh keys reset both shelves to collapsed when users update. */ | 51 | /** Fresh keys reset both shelves to collapsed when users update. */ |
| 50 | readonly threadListSettledShelfExpanded?: boolean; | 52 | readonly threadListSettledShelfExpanded?: boolean; |
| 51 | readonly threadListSnoozedShelfExpanded?: boolean; | 53 | readonly threadListSnoozedShelfExpanded?: boolean; |
| 54 | readonly threadListWorkingShelfExpanded?: boolean; | ||
| 52 | } | 55 | } |
| 53 | 56 | ||
| 54 | export class MobilePreferencesLoadError extends Schema.TaggedError<MobilePreferencesLoadError>()( | 57 | export class MobilePreferencesLoadError extends Schema.TaggedError<MobilePreferencesLoadError>()( |
| ... | @@ -107,9 +110,11 @@ function sanitizePreferences(parsed: Preferences): Preferences { | ... | @@ -107,9 +110,11 @@ function sanitizePreferences(parsed: Preferences): Preferences { |
| 107 | projectGroupingEnabled?: boolean; | 110 | projectGroupingEnabled?: boolean; |
| 108 | projectGroupingMode?: SidebarProjectGroupingMode; | 111 | projectGroupingMode?: SidebarProjectGroupingMode; |
| 109 | planModeEnabled?: boolean; | 112 | planModeEnabled?: boolean; |
| 113 | workingShelfEnabled?: boolean; | ||
| 110 | modelFavorites?: Preferences["modelFavorites"]; | 114 | modelFavorites?: Preferences["modelFavorites"]; |
| 111 | threadListSettledShelfExpanded?: boolean; | 115 | threadListSettledShelfExpanded?: boolean; |
| 112 | threadListSnoozedShelfExpanded?: boolean; | 116 | threadListSnoozedShelfExpanded?: boolean; |
| 117 | threadListWorkingShelfExpanded?: boolean; | ||
| 113 | } = {}; | 118 | } = {}; |
| 114 | 119 | ||
| 115 | if (typeof parsed.liveActivitiesEnabled === "boolean") { | 120 | if (typeof parsed.liveActivitiesEnabled === "boolean") { |
| ... | @@ -180,6 +185,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { | ... | @@ -180,6 +185,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { |
| 180 | if (typeof parsed.planModeEnabled === "boolean") { | 185 | if (typeof parsed.planModeEnabled === "boolean") { |
| 181 | preferences.planModeEnabled = parsed.planModeEnabled; | 186 | preferences.planModeEnabled = parsed.planModeEnabled; |
| 182 | } | 187 | } |
| 188 | if (typeof parsed.workingShelfEnabled === "boolean") { | ||
| 189 | preferences.workingShelfEnabled = parsed.workingShelfEnabled; | ||
| 190 | } | ||
| 183 | if (Array.isArray(parsed.modelFavorites)) { | 191 | if (Array.isArray(parsed.modelFavorites)) { |
| 184 | preferences.modelFavorites = parsed.modelFavorites.filter( | 192 | preferences.modelFavorites = parsed.modelFavorites.filter( |
| 185 | (favorite) => | 193 | (favorite) => |
| ... | @@ -197,6 +205,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { | ... | @@ -197,6 +205,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { |
| 197 | if (typeof parsed.threadListSnoozedShelfExpanded === "boolean") { | 205 | if (typeof parsed.threadListSnoozedShelfExpanded === "boolean") { |
| 198 | preferences.threadListSnoozedShelfExpanded = parsed.threadListSnoozedShelfExpanded; | 206 | preferences.threadListSnoozedShelfExpanded = parsed.threadListSnoozedShelfExpanded; |
| 199 | } | 207 | } |
| 208 | if (typeof parsed.threadListWorkingShelfExpanded === "boolean") { | ||
| 209 | preferences.threadListWorkingShelfExpanded = parsed.threadListWorkingShelfExpanded; | ||
| 210 | } | ||
| 200 | return preferences; | 211 | return preferences; |
| 201 | } | 212 | } |
| 202 | 213 |
apps/web/src/components/Sidebar.logic.ts+5-45| ... | @@ -1008,21 +1008,6 @@ export function shouldShowSidebarV2Duration(status: SidebarThreadStatus): boolea | ... | @@ -1008,21 +1008,6 @@ export function shouldShowSidebarV2Duration(status: SidebarThreadStatus): boolea |
| 1008 | return status === "working"; | 1008 | return status === "working"; |
| 1009 | } | 1009 | } |
| 1010 | 1010 | ||
| 1011 | /** Working beta: threads busy with work that does not need the user fold into | ||
| 1012 | the Working shelf: a running run, or one stopped with background work | ||
| 1013 | that will wake it. Approvals, questions, plan prompts, and failures stay in the | ||
| 1014 | inbox. */ | ||
| 1015 | export function isSidebarThreadWorking(thread: ThreadStatusInput): boolean { | ||
| 1016 | const status = resolveSidebarThreadStatus(thread); | ||
| 1017 | if (status !== "working" && status !== "waiting") return false; | ||
| 1018 | // A plan prompt outranks lingering background work: the user has to act on it. | ||
| 1019 | return !( | ||
| 1020 | thread.interactionMode === "plan" && | ||
| 1021 | thread.hasActionableProposedPlan && | ||
| 1022 | isLatestRunSettled(thread.latestRun, thread.runtime) | ||
| 1023 | ); | ||
| 1024 | } | ||
| 1025 | |||
| 1026 | /** First VALID timestamp wins: `a ?? b` falls through on null, but a present- | 1011 | /** First VALID timestamp wins: `a ?? b` falls through on null, but a present- |
| 1027 | yet-malformed string must also fall through to the next candidate rather | 1012 | yet-malformed string must also fall through to the next candidate rather |
| 1028 | than sink the row to the epoch. */ | 1013 | than sink the row to the epoch. */ |
| ... | @@ -1038,6 +1023,11 @@ export function firstValidTimestampMs( | ... | @@ -1038,6 +1023,11 @@ export function firstValidTimestampMs( |
| 1038 | } | 1023 | } |
| 1039 | 1024 | ||
| 1040 | export { sortActiveThreadsByOrderKey as sortThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort"; | 1025 | export { sortActiveThreadsByOrderKey as sortThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort"; |
| 1026 | // The Working section beta folds and orders the inbox the same way on mobile. | ||
| 1027 | export { | ||
| 1028 | isThreadWorking as isSidebarThreadWorking, | ||
| 1029 | sortInboxThreadsByReturn, | ||
| 1030 | } from "@t3tools/client-runtime/state/thread-inbox"; | ||
| 1041 | 1031 | ||
| 1042 | // Pinned-reorder key math and the keyed sort live in client-runtime | 1032 | // Pinned-reorder key math and the keyed sort live in client-runtime |
| 1043 | // (state/thread-sort) so web and mobile compute identical pinned orders. | 1033 | // (state/thread-sort) so web and mobile compute identical pinned orders. |
| ... | @@ -1119,36 +1109,6 @@ export function reduceSidebarProjectScopeMenuState( | ... | @@ -1119,36 +1109,6 @@ export function reduceSidebarProjectScopeMenuState( |
| 1119 | } | 1109 | } |
| 1120 | } | 1110 | } |
| 1121 | 1111 | ||
| 1122 | /** Working beta: the inbox lists threads newest first by when each last came | ||
| 1123 | back to the user, so a thread that leaves the Working shelf lands on top. | ||
| 1124 | `observedReturnAt` adds returns the server does not stamp, such as an | ||
| 1125 | approval request mid-turn or background work ending. */ | ||
| 1126 | export function sortInboxThreadsByReturn< | ||
| 1127 | T extends Pick< | ||
| 1128 | SidebarThreadSummary, | ||
| 1129 | "id" | "environmentId" | "createdAt" | "unsettledAt" | "latestRun" | ||
| 1130 | >, | ||
| 1131 | >(threads: readonly T[], observedReturnAt?: (thread: T) => number | undefined): T[] { | ||
| 1132 | const timestamps = new Map( | ||
| 1133 | threads.map((thread) => [ | ||
| 1134 | thread, | ||
| 1135 | Math.max( | ||
| 1136 | toSortableTimestamp(thread.createdAt) ?? 0, | ||
| 1137 | toSortableTimestamp(thread.unsettledAt ?? undefined) ?? 0, | ||
| 1138 | toSortableTimestamp(thread.latestRun?.requestedAt ?? undefined) ?? 0, | ||
| 1139 | toSortableTimestamp(thread.latestRun?.completedAt ?? undefined) ?? 0, | ||
| 1140 | observedReturnAt?.(thread) ?? 0, | ||
| 1141 | ), | ||
| 1142 | ]), | ||
| 1143 | ); | ||
| 1144 | return [...threads].sort( | ||
| 1145 | (left, right) => | ||
| 1146 | timestamps.get(right)! - timestamps.get(left)! || | ||
| 1147 | left.id.localeCompare(right.id) || | ||
| 1148 | left.environmentId.localeCompare(right.environmentId), | ||
| 1149 | ); | ||
| 1150 | } | ||
| 1151 | |||
| 1152 | /** The timestamp a working thread's elapsed label counts from: when its | 1112 | /** The timestamp a working thread's elapsed label counts from: when its |
| 1153 | current work started (request time until adoption). Background wakes do | 1113 | current work started (request time until adoption). Background wakes do |
| 1154 | not reset it. Malformed timestamps fall through to the next candidate. */ | 1114 | not reset it. Malformed timestamps fall through to the next candidate. */ |
apps/web/src/components/Sidebar.tsx+5-36| ... | @@ -31,6 +31,7 @@ import { | ... | @@ -31,6 +31,7 @@ import { |
| 31 | effectiveSnoozed, | 31 | effectiveSnoozed, |
| 32 | threadWokeAt, | 32 | threadWokeAt, |
| 33 | } from "@t3tools/client-runtime/state/thread-settled"; | 33 | } from "@t3tools/client-runtime/state/thread-settled"; |
| 34 | import { createInboxReturnTracker } from "@t3tools/client-runtime/state/thread-inbox"; | ||
| 34 | import { | 35 | import { |
| 35 | resolveSettledThreadTimestamp, | 36 | resolveSettledThreadTimestamp, |
| 36 | sortSettledThreads, | 37 | sortSettledThreads, |
| ... | @@ -289,35 +290,7 @@ const WORKING_SHELF_EXPANDED_KEY = "t3code:sidebar:working-expanded"; | ... | @@ -289,35 +290,7 @@ const WORKING_SHELF_EXPANDED_KEY = "t3code:sidebar:working-expanded"; |
| 289 | 290 | ||
| 290 | // Working beta: when this client saw each thread leave the Working shelf. | 291 | // Working beta: when this client saw each thread leave the Working shelf. |
| 291 | // Module scope keeps the inbox order across routes that unmount the sidebar. | 292 | // Module scope keeps the inbox order across routes that unmount the sidebar. |
| 292 | let lastWorkingThreadKeys: ReadonlySet<string> | null = null; | 293 | const inboxReturns = createInboxReturnTracker(); |
| 293 | const observedInboxReturns = new Map<string, number>(); | ||
| 294 | |||
| 295 | /** Stamps threads that stopped working since the last call. The first call | ||
| 296 | only takes a baseline, so mounting never reshuffles the inbox. Pass null | ||
| 297 | to reset when the beta is off. */ | ||
| 298 | function observeInboxReturns(threads: readonly EnvironmentThreadShell[] | null): void { | ||
| 299 | if (threads === null) { | ||
| 300 | lastWorkingThreadKeys = null; | ||
| 301 | observedInboxReturns.clear(); | ||
| 302 | return; | ||
| 303 | } | ||
| 304 | const working = new Set<string>(); | ||
| 305 | const present = new Set<string>(); | ||
| 306 | for (const thread of threads) { | ||
| 307 | const key = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); | ||
| 308 | present.add(key); | ||
| 309 | if (isSidebarThreadWorking(thread)) working.add(key); | ||
| 310 | } | ||
| 311 | // Drop deleted threads so the map stays bounded by the live thread list. | ||
| 312 | for (const key of observedInboxReturns.keys()) { | ||
| 313 | if (!present.has(key)) observedInboxReturns.delete(key); | ||
| 314 | } | ||
| 315 | const now = Date.now(); | ||
| 316 | for (const key of lastWorkingThreadKeys ?? []) { | ||
| 317 | if (present.has(key) && !working.has(key)) observedInboxReturns.set(key, now); | ||
| 318 | } | ||
| 319 | lastWorkingThreadKeys = working; | ||
| 320 | } | ||
| 321 | 294 | ||
| 322 | function compactSidebarTimeLabel(label: string): string { | 295 | function compactSidebarTimeLabel(label: string): string { |
| 323 | if (label === "just now") return "now"; | 296 | if (label === "just now") return "now"; |
| ... | @@ -2669,7 +2642,7 @@ export default function Sidebar() { | ... | @@ -2669,7 +2642,7 @@ export default function Sidebar() { |
| 2669 | // Subagent child threads live in the parent's Agents surface, not the | 2642 | // Subagent child threads live in the parent's Agents surface, not the |
| 2670 | // sidebar roster (v2 models them as real threads with lineage). | 2643 | // sidebar roster (v2 models them as real threads with lineage). |
| 2671 | const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys); | 2644 | const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys); |
| 2672 | observeInboxReturns(workingShelfEnabled ? threads : null); | 2645 | inboxReturns.observe(workingShelfEnabled ? threads : null); |
| 2673 | const pinned: EnvironmentThreadShell[] = []; | 2646 | const pinned: EnvironmentThreadShell[] = []; |
| 2674 | const active: EnvironmentThreadShell[] = []; | 2647 | const active: EnvironmentThreadShell[] = []; |
| 2675 | const working: EnvironmentThreadShell[] = []; | 2648 | const working: EnvironmentThreadShell[] = []; |
| ... | @@ -2736,11 +2709,7 @@ export default function Sidebar() { | ... | @@ -2736,11 +2709,7 @@ export default function Sidebar() { |
| 2736 | // web and mobile from the same data. | 2709 | // web and mobile from the same data. |
| 2737 | const sortedPinned = sortPinnedThreadsForSidebar(pinned); | 2710 | const sortedPinned = sortPinnedThreadsForSidebar(pinned); |
| 2738 | const sortedActive = workingShelfEnabled | 2711 | const sortedActive = workingShelfEnabled |
| 2739 | ? sortInboxThreadsByReturn(active, (thread) => | 2712 | ? sortInboxThreadsByReturn(active, inboxReturns.returnedAt) |
| 2740 | observedInboxReturns.get( | ||
| 2741 | scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), | ||
| 2742 | ), | ||
| 2743 | ) | ||
| 2744 | : sortThreadsForSidebar(active); | 2713 | : sortThreadsForSidebar(active); |
| 2745 | return { | 2714 | return { |
| 2746 | pinnedThreads: | 2715 | pinnedThreads: |
| ... | @@ -3701,7 +3670,7 @@ export default function Sidebar() { | ... | @@ -3701,7 +3670,7 @@ export default function Sidebar() { |
| 3701 | ...activeThreads.filter((candidate) => key(candidate) !== dragState.activeKey), | 3670 | ...activeThreads.filter((candidate) => key(candidate) !== dragState.activeKey), |
| 3702 | applySidebarThreadDrop(thread, "active", dragState.occurredAt), | 3671 | applySidebarThreadDrop(thread, "active", dragState.occurredAt), |
| 3703 | ], | 3672 | ], |
| 3704 | (candidate) => observedInboxReturns.get(key(candidate)), | 3673 | inboxReturns.returnedAt, |
| 3705 | ).map(key); | 3674 | ).map(key); |
| 3706 | }, [activeThreads, dragState, threadByKey, workingShelfEnabled]); | 3675 | }, [activeThreads, dragState, threadByKey, workingShelfEnabled]); |
| 3707 | const sidebarSortingStrategy = useMemo( | 3676 | const sidebarSortingStrategy = useMemo( |
docs/user/thread-sidebar.md+6-5| ... | @@ -111,13 +111,14 @@ open. | ... | @@ -111,13 +111,14 @@ open. |
| 111 | 111 | ||
| 112 | ### Fold working threads (beta) | 112 | ### Fold working threads (beta) |
| 113 | 113 | ||
| 114 | On web and desktop, turn on **Settings → General → Working section (beta)** to move threads that | 114 | Turn on **Settings → General → Working section (beta)** on web and desktop, or **Settings → |
| 115 | are working or monitoring into a collapsed **Working** section at the bottom of the sidebar. A | 115 | Thread behavior → Working section** on iOS and Android, to move threads that are working or |
| 116 | thread returns to the top of the active list when it finishes, fails, or needs an approval or | 116 | monitoring into a collapsed **Working** section below the active list. A thread returns to the top |
| 117 | answer. Pinned threads stay in the pinned section. | 117 | of the active list when it finishes, fails, or needs an approval or answer. Pinned threads stay in |
| 118 | the pinned section. Each device keeps its own choice. | ||
| 118 | 119 | ||
| 119 | While this is on, the active list is ordered by when each thread last came back to you, so you | 120 | While this is on, the active list is ordered by when each thread last came back to you, so you |
| 120 | cannot drag to reorder it. Your saved order returns when you turn it off. | 121 | cannot drag or move threads within it. Your saved order returns when you turn it off. |
| 121 | 122 | ||
| 122 | ## Settle finished work | 123 | ## Settle finished work |
| 123 | 124 |
packages/client-runtime/package.json+4| ... | @@ -259,6 +259,10 @@ | ... | @@ -259,6 +259,10 @@ |
| 259 | "types": "./src/state/threadSort.ts", | 259 | "types": "./src/state/threadSort.ts", |
| 260 | "default": "./src/state/threadSort.ts" | 260 | "default": "./src/state/threadSort.ts" |
| 261 | }, | 261 | }, |
| 262 | "./state/thread-inbox": { | ||
| 263 | "types": "./src/state/threadInbox.ts", | ||
| 264 | "default": "./src/state/threadInbox.ts" | ||
| 265 | }, | ||
| 262 | "./state/thread-relationships": { | 266 | "./state/thread-relationships": { |
| 263 | "types": "./src/state/threadRelationships.ts", | 267 | "types": "./src/state/threadRelationships.ts", |
| 264 | "default": "./src/state/threadRelationships.ts" | 268 | "default": "./src/state/threadRelationships.ts" |
packages/client-runtime/src/state/threadInbox.test.ts created+59| ... | @@ -0,0 +1,59 @@ | ||
| 1 | import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { createInboxReturnTracker } from "./threadInbox.ts"; | ||
| 5 | |||
| 6 | const environmentId = EnvironmentId.make("environment-1"); | ||
| 7 | |||
| 8 | function thread(id: string, working: boolean) { | ||
| 9 | return { | ||
| 10 | id: ThreadId.make(id), | ||
| 11 | environmentId, | ||
| 12 | createdAt: "2026-06-01T00:00:00.000Z", | ||
| 13 | unsettledAt: null, | ||
| 14 | latestRun: null, | ||
| 15 | hasActionableProposedPlan: false, | ||
| 16 | hasPendingApprovals: false, | ||
| 17 | hasPendingUserInput: false, | ||
| 18 | interactionMode: "default" as const, | ||
| 19 | runtime: working | ||
| 20 | ? { | ||
| 21 | status: "running" as const, | ||
| 22 | activeRunId: null, | ||
| 23 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 24 | providerName: "Codex", | ||
| 25 | lastError: null, | ||
| 26 | updatedAt: "2026-06-01T00:00:00.000Z", | ||
| 27 | } | ||
| 28 | : null, | ||
| 29 | }; | ||
| 30 | } | ||
| 31 | |||
| 32 | describe("createInboxReturnTracker", () => { | ||
| 33 | it("stamps a thread when it stops working, but never on the first observation", () => { | ||
| 34 | const tracker = createInboxReturnTracker(); | ||
| 35 | tracker.observe([thread("a", true), thread("b", false)]); | ||
| 36 | expect(tracker.returnedAt(thread("a", true))).toBeUndefined(); | ||
| 37 | expect(tracker.returnedAt(thread("b", false))).toBeUndefined(); | ||
| 38 | |||
| 39 | tracker.observe([thread("a", false), thread("b", false)]); | ||
| 40 | expect(tracker.returnedAt(thread("a", false))).toBeDefined(); | ||
| 41 | expect(tracker.returnedAt(thread("b", false))).toBeUndefined(); | ||
| 42 | }); | ||
| 43 | |||
| 44 | it("forgets deleted threads and resets when the beta turns off", () => { | ||
| 45 | const tracker = createInboxReturnTracker(); | ||
| 46 | tracker.observe([thread("a", true), thread("b", true)]); | ||
| 47 | tracker.observe([thread("a", false), thread("b", false)]); | ||
| 48 | tracker.observe([thread("b", false)]); | ||
| 49 | expect(tracker.returnedAt(thread("a", false))).toBeUndefined(); | ||
| 50 | expect(tracker.returnedAt(thread("b", false))).toBeDefined(); | ||
| 51 | |||
| 52 | tracker.observe(null); | ||
| 53 | expect(tracker.returnedAt(thread("b", false))).toBeUndefined(); | ||
| 54 | // After a reset the next call is a fresh baseline again. | ||
| 55 | tracker.observe([thread("b", true)]); | ||
| 56 | tracker.observe([thread("b", false)]); | ||
| 57 | expect(tracker.returnedAt(thread("b", false))).toBeDefined(); | ||
| 58 | }); | ||
| 59 | }); | ||
packages/client-runtime/src/state/threadInbox.ts created+111| ... | @@ -0,0 +1,111 @@ | ||
| 1 | import { threadRuntimeIsActive, type EnvironmentThreadShell } from "./models.ts"; | ||
| 2 | import { toSortableTimestamp } from "./threadSort.ts"; | ||
| 3 | |||
| 4 | // Working section beta, shared so web and mobile fold and order the inbox the | ||
| 5 | // same way. Off by default; each client owns its own toggle. | ||
| 6 | |||
| 7 | type WorkingThreadInput = Pick< | ||
| 8 | EnvironmentThreadShell, | ||
| 9 | | "hasActionableProposedPlan" | ||
| 10 | | "hasPendingApprovals" | ||
| 11 | | "hasPendingUserInput" | ||
| 12 | | "interactionMode" | ||
| 13 | | "latestRun" | ||
| 14 | | "runtime" | ||
| 15 | >; | ||
| 16 | |||
| 17 | /** Threads busy with work that does not need the user fold into the Working | ||
| 18 | section: a running run, or one stopped with background work that will wake | ||
| 19 | it. Approvals, questions, plan prompts, and failures stay in the inbox. */ | ||
| 20 | export function isThreadWorking(thread: WorkingThreadInput): boolean { | ||
| 21 | if (thread.hasPendingApprovals || thread.hasPendingUserInput) return false; | ||
| 22 | if (!threadRuntimeIsActive(thread.runtime) && thread.runtime?.status !== "idle") return false; | ||
| 23 | // A plan prompt outranks lingering background work: the user has to act on it. | ||
| 24 | const run = thread.latestRun; | ||
| 25 | const runSettled = | ||
| 26 | run !== null && | ||
| 27 | run.status !== "preparing" && | ||
| 28 | run.status !== "queued" && | ||
| 29 | run.status !== "starting" && | ||
| 30 | run.status !== "running" && | ||
| 31 | run.status !== "waiting" && | ||
| 32 | thread.runtime?.activeRunId !== run.runId; | ||
| 33 | return !(thread.interactionMode === "plan" && thread.hasActionableProposedPlan && runSettled); | ||
| 34 | } | ||
| 35 | |||
| 36 | type InboxThreadInput = Pick< | ||
| 37 | EnvironmentThreadShell, | ||
| 38 | "id" | "environmentId" | "createdAt" | "unsettledAt" | "latestRun" | ||
| 39 | >; | ||
| 40 | |||
| 41 | /** The inbox lists threads newest first by when each last came back to the | ||
| 42 | user, so a thread that leaves the Working section lands on top. | ||
| 43 | `observedReturnAt` adds returns the server does not stamp, such as an | ||
| 44 | approval request mid-turn or background work ending. */ | ||
| 45 | export function sortInboxThreadsByReturn<T extends InboxThreadInput>( | ||
| 46 | threads: readonly T[], | ||
| 47 | observedReturnAt?: (thread: T) => number | undefined, | ||
| 48 | ): T[] { | ||
| 49 | const timestamps = new Map( | ||
| 50 | threads.map((thread) => [ | ||
| 51 | thread, | ||
| 52 | Math.max( | ||
| 53 | toSortableTimestamp(thread.createdAt) ?? 0, | ||
| 54 | toSortableTimestamp(thread.unsettledAt ?? undefined) ?? 0, | ||
| 55 | toSortableTimestamp(thread.latestRun?.requestedAt ?? undefined) ?? 0, | ||
| 56 | toSortableTimestamp(thread.latestRun?.completedAt ?? undefined) ?? 0, | ||
| 57 | observedReturnAt?.(thread) ?? 0, | ||
| 58 | ), | ||
| 59 | ]), | ||
| 60 | ); | ||
| 61 | return [...threads].sort( | ||
| 62 | (left, right) => | ||
| 63 | timestamps.get(right)! - timestamps.get(left)! || | ||
| 64 | left.id.localeCompare(right.id) || | ||
| 65 | left.environmentId.localeCompare(right.environmentId), | ||
| 66 | ); | ||
| 67 | } | ||
| 68 | |||
| 69 | /** | ||
| 70 | * Remembers when this client saw each thread leave the Working section. Keep | ||
| 71 | * one at module scope so the inbox order survives routes that unmount the | ||
| 72 | * list. Call `observe` with every thread shell on each list rebuild, or with | ||
| 73 | * null to reset while the beta is off. The first call only takes a baseline, | ||
| 74 | * so mounting never reshuffles the inbox. | ||
| 75 | */ | ||
| 76 | export function createInboxReturnTracker() { | ||
| 77 | const keyOf = (thread: Pick<EnvironmentThreadShell, "environmentId" | "id">) => | ||
| 78 | `${thread.environmentId}:${thread.id}`; | ||
| 79 | let lastWorkingKeys: ReadonlySet<string> | null = null; | ||
| 80 | const returns = new Map<string, number>(); | ||
| 81 | return { | ||
| 82 | observe(threads: ReadonlyArray<WorkingThreadInput & InboxThreadInput> | null): void { | ||
| 83 | if (threads === null) { | ||
| 84 | lastWorkingKeys = null; | ||
| 85 | returns.clear(); | ||
| 86 | return; | ||
| 87 | } | ||
| 88 | const working = new Set<string>(); | ||
| 89 | const present = new Set<string>(); | ||
| 90 | for (const thread of threads) { | ||
| 91 | const key = keyOf(thread); | ||
| 92 | present.add(key); | ||
| 93 | if (isThreadWorking(thread)) working.add(key); | ||
| 94 | } | ||
| 95 | // Drop deleted threads so the map stays bounded by the live thread list. | ||
| 96 | for (const key of returns.keys()) { | ||
| 97 | if (!present.has(key)) returns.delete(key); | ||
| 98 | } | ||
| 99 | // The moment this client saw the change is the data; there is no | ||
| 100 | // server stamp to read instead. | ||
| 101 | // @effect-diagnostics-next-line globalDate:off | ||
| 102 | const at = Date.now(); | ||
| 103 | for (const key of lastWorkingKeys ?? []) { | ||
| 104 | if (present.has(key) && !working.has(key)) returns.set(key, at); | ||
| 105 | } | ||
| 106 | lastWorkingKeys = working; | ||
| 107 | }, | ||
| 108 | returnedAt: (thread: Pick<EnvironmentThreadShell, "environmentId" | "id">) => | ||
| 109 | returns.get(keyOf(thread)), | ||
| 110 | }; | ||
| 111 | } | ||