| author | |
| committer | |
| log | 916cff733cd00c85fe0419df5bcb9372a6384746 |
| tree | a0be843dc1c3db78cca235f4146c063945bfee18 |
| parent | a041981276b4c789fed8132e3b8a320749bf25e8 |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 215 insertions(+), 48 deletions(-)
apps/mobile/src/features/home/HomeScreen.tsx+22-3| ... | @@ -44,6 +44,7 @@ import { | ... | @@ -44,6 +44,7 @@ import { |
| 44 | import { | 44 | import { |
| 45 | ThreadListV2PendingRow, | 45 | ThreadListV2PendingRow, |
| 46 | ThreadListV2Row, | 46 | ThreadListV2Row, |
| 47 | ThreadListV2SettledShelfHeader, | ||
| 47 | ThreadListV2SnoozedShelfHeader, | 48 | ThreadListV2SnoozedShelfHeader, |
| 48 | } from "../threads/thread-list-v2-items"; | 49 | } from "../threads/thread-list-v2-items"; |
| 49 | import { | 50 | import { |
| ... | @@ -534,6 +535,8 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -534,6 +535,8 @@ export function HomeScreen(props: HomeScreenProps) { |
| 534 | ); | 535 | ); |
| 535 | const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useState(false); | 536 | const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useState(false); |
| 536 | const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []); | 537 | const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []); |
| 538 | const [settledShelfExpanded, setSettledShelfExpanded] = useState(true); | ||
| 539 | const toggleSettledShelf = useCallback(() => setSettledShelfExpanded((value) => !value), []); | ||
| 537 | // now is quantized to the minute and ticks so the inactivity auto-settle | 540 | // now is quantized to the minute and ticks so the inactivity auto-settle |
| 538 | // boundary is actually crossed while the app stays open (mirrors web); | 541 | // boundary is actually crossed while the app stays open (mirrors web); |
| 539 | // without a clock dependency the partition memoizes a frozen "now". | 542 | // without a clock dependency the partition memoizes a frozen "now". |
| ... | @@ -579,6 +582,8 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -579,6 +582,8 @@ export function HomeScreen(props: HomeScreenProps) { |
| 579 | hiddenSettledCount: 0, | 582 | hiddenSettledCount: 0, |
| 580 | snoozedCount: 0, | 583 | snoozedCount: 0, |
| 581 | snoozedShelfHeaderIndex: null, | 584 | snoozedShelfHeaderIndex: null, |
| 585 | settledCount: 0, | ||
| 586 | settledShelfHeaderIndex: null, | ||
| 582 | nextSnoozeWakeAt: null, | 587 | nextSnoozeWakeAt: null, |
| 583 | }; | 588 | }; |
| 584 | // Settled threads are live shells; archived threads keep their original | 589 | // Settled threads are live shells; archived threads keep their original |
| ... | @@ -596,6 +601,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -596,6 +601,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 596 | now: `${nowMinute}:00.000Z`, | 601 | now: `${nowMinute}:00.000Z`, |
| 597 | snoozeNow: new Date().toISOString(), | 602 | snoozeNow: new Date().toISOString(), |
| 598 | snoozedShelfExpanded, | 603 | snoozedShelfExpanded, |
| 604 | settledShelfExpanded, | ||
| 599 | selectedThreadKey: null, | 605 | selectedThreadKey: null, |
| 600 | }); | 606 | }); |
| 601 | }, [ | 607 | }, [ |
| ... | @@ -603,6 +609,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -603,6 +609,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 603 | nowMinute, | 609 | nowMinute, |
| 604 | snoozeWakeTick, | 610 | snoozeWakeTick, |
| 605 | snoozedShelfExpanded, | 611 | snoozedShelfExpanded, |
| 612 | settledShelfExpanded, | ||
| 606 | settledVisibleCount, | 613 | settledVisibleCount, |
| 607 | settlementEnvironmentIds, | 614 | settlementEnvironmentIds, |
| 608 | snoozeEnvironmentIds, | 615 | snoozeEnvironmentIds, |
| ... | @@ -655,9 +662,12 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -655,9 +662,12 @@ export function HomeScreen(props: HomeScreenProps) { |
| 655 | snoozedCount: threadListV2Layout.snoozedCount, | 662 | snoozedCount: threadListV2Layout.snoozedCount, |
| 656 | snoozedShelfExpanded, | 663 | snoozedShelfExpanded, |
| 657 | snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex, | 664 | snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex, |
| 665 | settledCount: threadListV2Layout.settledCount, | ||
| 666 | settledShelfExpanded, | ||
| 667 | settledShelfHeaderIndex: threadListV2Layout.settledShelfHeaderIndex, | ||
| 658 | snoozeLabelNow: `${nowMinute}:00.000Z`, | 668 | snoozeLabelNow: `${nowMinute}:00.000Z`, |
| 659 | }), | 669 | }), |
| 660 | [snoozedShelfExpanded, threadListV2Layout, v2PendingTasks], | 670 | [settledShelfExpanded, snoozedShelfExpanded, threadListV2Layout, v2PendingTasks], |
| 661 | ); | 671 | ); |
| 662 | 672 | ||
| 663 | const renderV2Item = useCallback( | 673 | const renderV2Item = useCallback( |
| ... | @@ -693,12 +703,20 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -693,12 +703,20 @@ export function HomeScreen(props: HomeScreenProps) { |
| 693 | /> | 703 | /> |
| 694 | ); | 704 | ); |
| 695 | } | 705 | } |
| 706 | if (item.type === "v2-settled-shelf") { | ||
| 707 | return ( | ||
| 708 | <ThreadListV2SettledShelfHeader | ||
| 709 | count={item.count} | ||
| 710 | expanded={item.expanded} | ||
| 711 | onToggle={toggleSettledShelf} | ||
| 712 | /> | ||
| 713 | ); | ||
| 714 | } | ||
| 696 | const thread = item.item.thread; | 715 | const thread = item.item.thread; |
| 697 | return ( | 716 | return ( |
| 698 | <ThreadListV2Row | 717 | <ThreadListV2Row |
| 699 | thread={thread} | 718 | thread={thread} |
| 700 | variant={item.item.variant} | 719 | variant={item.item.variant} |
| 701 | showSettledDivider={item.item.showSettledDivider} | ||
| 702 | snoozed={item.item.snoozed} | 720 | snoozed={item.item.snoozed} |
| 703 | snoozePresetMinute={nowMinute} | 721 | snoozePresetMinute={nowMinute} |
| 704 | snoozeWakeLabelText={item.snoozeWakeLabelText} | 722 | snoozeWakeLabelText={item.snoozeWakeLabelText} |
| ... | @@ -767,6 +785,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -767,6 +785,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 767 | settlementEnvironmentIds, | 785 | settlementEnvironmentIds, |
| 768 | snoozeEnvironmentIds, | 786 | snoozeEnvironmentIds, |
| 769 | threadSearchMatchByKey, | 787 | threadSearchMatchByKey, |
| 788 | toggleSettledShelf, | ||
| 770 | toggleSnoozedShelf, | 789 | toggleSnoozedShelf, |
| 771 | v2ProjectTitleByProjectKey, | 790 | v2ProjectTitleByProjectKey, |
| 772 | props.searchQuery, | 791 | props.searchQuery, |
| ... | @@ -1036,7 +1055,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -1036,7 +1055,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 1036 | extraData={v2ExtraData} | 1055 | extraData={v2ExtraData} |
| 1037 | ListHeaderComponent={v2ListHeader} | 1056 | ListHeaderComponent={v2ListHeader} |
| 1038 | ListFooterComponent={ | 1057 | ListFooterComponent={ |
| 1039 | threadListV2Layout.hiddenSettledCount > 0 ? ( | 1058 | settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0 ? ( |
| 1040 | <Pressable | 1059 | <Pressable |
| 1041 | accessibilityRole="button" | 1060 | accessibilityRole="button" |
| 1042 | accessibilityLabel={`Show ${Math.min(threadListV2Layout.hiddenSettledCount, THREAD_LIST_V2_SETTLED_PAGE_COUNT)} more settled threads`} | 1061 | accessibilityLabel={`Show ${Math.min(threadListV2Layout.hiddenSettledCount, THREAD_LIST_V2_SETTLED_PAGE_COUNT)} more settled threads`} |
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+28-4| ... | @@ -70,6 +70,7 @@ import { | ... | @@ -70,6 +70,7 @@ import { |
| 70 | import { | 70 | import { |
| 71 | ThreadListV2PendingRow, | 71 | ThreadListV2PendingRow, |
| 72 | ThreadListV2Row, | 72 | ThreadListV2Row, |
| 73 | ThreadListV2SettledShelfHeader, | ||
| 73 | ThreadListV2SnoozedShelfHeader, | 74 | ThreadListV2SnoozedShelfHeader, |
| 74 | } from "./thread-list-v2-items"; | 75 | } from "./thread-list-v2-items"; |
| 75 | import { | 76 | import { |
| ... | @@ -440,6 +441,8 @@ function ThreadNavigationSidebarPane( | ... | @@ -440,6 +441,8 @@ function ThreadNavigationSidebarPane( |
| 440 | ); | 441 | ); |
| 441 | const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useState(false); | 442 | const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useState(false); |
| 442 | const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []); | 443 | const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []); |
| 444 | const [settledShelfExpanded, setSettledShelfExpanded] = useState(true); | ||
| 445 | const toggleSettledShelf = useCallback(() => setSettledShelfExpanded((value) => !value), []); | ||
| 443 | // now ticks per minute so the inactivity auto-settle boundary is actually | 446 | // now ticks per minute so the inactivity auto-settle boundary is actually |
| 444 | // crossed while the pane stays open; without a clock dependency the | 447 | // crossed while the pane stays open; without a clock dependency the |
| 445 | // partition memoizes a frozen "now". | 448 | // partition memoizes a frozen "now". |
| ... | @@ -485,6 +488,8 @@ function ThreadNavigationSidebarPane( | ... | @@ -485,6 +488,8 @@ function ThreadNavigationSidebarPane( |
| 485 | hiddenSettledCount: 0, | 488 | hiddenSettledCount: 0, |
| 486 | snoozedCount: 0, | 489 | snoozedCount: 0, |
| 487 | snoozedShelfHeaderIndex: null, | 490 | snoozedShelfHeaderIndex: null, |
| 491 | settledCount: 0, | ||
| 492 | settledShelfHeaderIndex: null, | ||
| 488 | nextSnoozeWakeAt: null, | 493 | nextSnoozeWakeAt: null, |
| 489 | }; | 494 | }; |
| 490 | return buildThreadListV2Items({ | 495 | return buildThreadListV2Items({ |
| ... | @@ -500,6 +505,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -500,6 +505,7 @@ function ThreadNavigationSidebarPane( |
| 500 | now: `${nowMinute}:00.000Z`, | 505 | now: `${nowMinute}:00.000Z`, |
| 501 | snoozeNow: new Date().toISOString(), | 506 | snoozeNow: new Date().toISOString(), |
| 502 | snoozedShelfExpanded, | 507 | snoozedShelfExpanded, |
| 508 | settledShelfExpanded, | ||
| 503 | selectedThreadKey: props.selectedThreadKey ?? null, | 509 | selectedThreadKey: props.selectedThreadKey ?? null, |
| 504 | }); | 510 | }); |
| 505 | }, [ | 511 | }, [ |
| ... | @@ -507,6 +513,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -507,6 +513,7 @@ function ThreadNavigationSidebarPane( |
| 507 | nowMinute, | 513 | nowMinute, |
| 508 | snoozeWakeTick, | 514 | snoozeWakeTick, |
| 509 | snoozedShelfExpanded, | 515 | snoozedShelfExpanded, |
| 516 | settledShelfExpanded, | ||
| 510 | props.selectedThreadKey, | 517 | props.selectedThreadKey, |
| 511 | options.selectedEnvironmentId, | 518 | options.selectedEnvironmentId, |
| 512 | props.searchQuery, | 519 | props.searchQuery, |
| ... | @@ -557,9 +564,12 @@ function ThreadNavigationSidebarPane( | ... | @@ -557,9 +564,12 @@ function ThreadNavigationSidebarPane( |
| 557 | snoozedCount: threadListV2Layout.snoozedCount, | 564 | snoozedCount: threadListV2Layout.snoozedCount, |
| 558 | snoozedShelfExpanded, | 565 | snoozedShelfExpanded, |
| 559 | snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex, | 566 | snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex, |
| 567 | settledCount: threadListV2Layout.settledCount, | ||
| 568 | settledShelfExpanded, | ||
| 569 | settledShelfHeaderIndex: threadListV2Layout.settledShelfHeaderIndex, | ||
| 560 | snoozeLabelNow: `${nowMinute}:00.000Z`, | 570 | snoozeLabelNow: `${nowMinute}:00.000Z`, |
| 561 | }); | 571 | }); |
| 562 | if (threadListV2Layout.hiddenSettledCount > 0) { | 572 | if (settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0) { |
| 563 | items.push({ | 573 | items.push({ |
| 564 | type: "v2-show-more", | 574 | type: "v2-show-more", |
| 565 | key: "v2-show-more", | 575 | key: "v2-show-more", |
| ... | @@ -574,6 +584,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -574,6 +584,7 @@ function ThreadNavigationSidebarPane( |
| 574 | pendingTasks, | 584 | pendingTasks, |
| 575 | props.searchQuery, | 585 | props.searchQuery, |
| 576 | selectedProjectRefs, | 586 | selectedProjectRefs, |
| 587 | settledShelfExpanded, | ||
| 577 | snoozedShelfExpanded, | 588 | snoozedShelfExpanded, |
| 578 | threadListV2Enabled, | 589 | threadListV2Enabled, |
| 579 | threadListV2Layout, | 590 | threadListV2Layout, |
| ... | @@ -780,7 +791,6 @@ function ThreadNavigationSidebarPane( | ... | @@ -780,7 +791,6 @@ function ThreadNavigationSidebarPane( |
| 780 | previous.key === item.key && | 791 | previous.key === item.key && |
| 781 | previous.item.thread === item.item.thread && | 792 | previous.item.thread === item.item.thread && |
| 782 | previous.item.variant === item.item.variant && | 793 | previous.item.variant === item.item.variant && |
| 783 | previous.item.showSettledDivider === item.item.showSettledDivider && | ||
| 784 | previous.item.snoozed === item.item.snoozed && | 794 | previous.item.snoozed === item.item.snoozed && |
| 785 | previous.snoozeWakeLabelText === item.snoozeWakeLabelText | 795 | previous.snoozeWakeLabelText === item.snoozeWakeLabelText |
| 786 | ); | 796 | ); |
| ... | @@ -797,15 +807,20 @@ function ThreadNavigationSidebarPane( | ... | @@ -797,15 +807,20 @@ function ThreadNavigationSidebarPane( |
| 797 | if (previous.type === "v2-snoozed-shelf" && item.type === "v2-snoozed-shelf") { | 807 | if (previous.type === "v2-snoozed-shelf" && item.type === "v2-snoozed-shelf") { |
| 798 | return previous.count === item.count && previous.expanded === item.expanded; | 808 | return previous.count === item.count && previous.expanded === item.expanded; |
| 799 | } | 809 | } |
| 810 | if (previous.type === "v2-settled-shelf" && item.type === "v2-settled-shelf") { | ||
| 811 | return previous.count === item.count && previous.expanded === item.expanded; | ||
| 812 | } | ||
| 800 | if ( | 813 | if ( |
| 801 | previous.type === "v2-thread" || | 814 | previous.type === "v2-thread" || |
| 802 | previous.type === "v2-show-more" || | 815 | previous.type === "v2-show-more" || |
| 803 | previous.type === "v2-pending" || | 816 | previous.type === "v2-pending" || |
| 804 | previous.type === "v2-snoozed-shelf" || | 817 | previous.type === "v2-snoozed-shelf" || |
| 818 | previous.type === "v2-settled-shelf" || | ||
| 805 | item.type === "v2-thread" || | 819 | item.type === "v2-thread" || |
| 806 | item.type === "v2-show-more" || | 820 | item.type === "v2-show-more" || |
| 807 | item.type === "v2-pending" || | 821 | item.type === "v2-pending" || |
| 808 | item.type === "v2-snoozed-shelf" | 822 | item.type === "v2-snoozed-shelf" || |
| 823 | item.type === "v2-settled-shelf" | ||
| 809 | ) { | 824 | ) { |
| 810 | return false; | 825 | return false; |
| 811 | } | 826 | } |
| ... | @@ -863,7 +878,6 @@ function ThreadNavigationSidebarPane( | ... | @@ -863,7 +878,6 @@ function ThreadNavigationSidebarPane( |
| 863 | <ThreadListV2Row | 878 | <ThreadListV2Row |
| 864 | thread={thread} | 879 | thread={thread} |
| 865 | variant={item.item.variant} | 880 | variant={item.item.variant} |
| 866 | showSettledDivider={item.item.showSettledDivider} | ||
| 867 | snoozed={item.item.snoozed} | 881 | snoozed={item.item.snoozed} |
| 868 | snoozePresetMinute={nowMinute} | 882 | snoozePresetMinute={nowMinute} |
| 869 | snoozeWakeLabelText={item.snoozeWakeLabelText} | 883 | snoozeWakeLabelText={item.snoozeWakeLabelText} |
| ... | @@ -921,6 +935,15 @@ function ThreadNavigationSidebarPane( | ... | @@ -921,6 +935,15 @@ function ThreadNavigationSidebarPane( |
| 921 | pane="sidebar" | 935 | pane="sidebar" |
| 922 | /> | 936 | /> |
| 923 | ); | 937 | ); |
| 938 | case "v2-settled-shelf": | ||
| 939 | return ( | ||
| 940 | <ThreadListV2SettledShelfHeader | ||
| 941 | count={item.count} | ||
| 942 | expanded={item.expanded} | ||
| 943 | onToggle={toggleSettledShelf} | ||
| 944 | pane="sidebar" | ||
| 945 | /> | ||
| 946 | ); | ||
| 924 | case "v2-show-more": | 947 | case "v2-show-more": |
| 925 | return ( | 948 | return ( |
| 926 | <Pressable | 949 | <Pressable |
| ... | @@ -1039,6 +1062,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -1039,6 +1062,7 @@ function ThreadNavigationSidebarPane( |
| 1039 | snoozeEnvironmentIds, | 1062 | snoozeEnvironmentIds, |
| 1040 | snoozeThread, | 1063 | snoozeThread, |
| 1041 | nowMinute, | 1064 | nowMinute, |
| 1065 | toggleSettledShelf, | ||
| 1042 | toggleSnoozedShelf, | 1066 | toggleSnoozedShelf, |
| 1043 | unsettleThread, | 1067 | unsettleThread, |
| 1044 | unsnoozeThread, | 1068 | unsnoozeThread, |
apps/mobile/src/features/threads/thread-list-v2-items.tsx+36-4| ... | @@ -149,6 +149,42 @@ export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedS | ... | @@ -149,6 +149,42 @@ export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedS |
| 149 | ); | 149 | ); |
| 150 | }); | 150 | }); |
| 151 | 151 | ||
| 152 | export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledShelfHeader(props: { | ||
| 153 | readonly count: number; | ||
| 154 | readonly expanded: boolean; | ||
| 155 | readonly onToggle: () => void; | ||
| 156 | readonly pane?: "screen" | "sidebar"; | ||
| 157 | }) { | ||
| 158 | const mutedColor = useThemeColor("--color-foreground-muted"); | ||
| 159 | return ( | ||
| 160 | <Pressable | ||
| 161 | accessibilityHint={ | ||
| 162 | props.expanded ? "Collapses the settled threads." : "Expands the settled threads." | ||
| 163 | } | ||
| 164 | accessibilityLabel={props.count === 1 ? "1 settled thread" : `${props.count} settled threads`} | ||
| 165 | accessibilityRole="button" | ||
| 166 | accessibilityState={{ expanded: props.expanded }} | ||
| 167 | className={cn( | ||
| 168 | "mb-1.5 mt-4 flex-row items-center gap-2.5", | ||
| 169 | props.pane === "sidebar" ? "px-3" : "px-5", | ||
| 170 | )} | ||
| 171 | onPress={props.onToggle} | ||
| 172 | style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} | ||
| 173 | > | ||
| 174 | <Text className="text-xs font-t3-medium text-foreground-tertiary"> | ||
| 175 | {props.expanded ? "Settled" : `Settled (${props.count})`} | ||
| 176 | </Text> | ||
| 177 | <View className="h-px flex-1 bg-border" /> | ||
| 178 | <SymbolView | ||
| 179 | name={props.expanded ? "chevron.up" : "chevron.down"} | ||
| 180 | size={10} | ||
| 181 | tintColor={mutedColor} | ||
| 182 | type="monochrome" | ||
| 183 | /> | ||
| 184 | </Pressable> | ||
| 185 | ); | ||
| 186 | }); | ||
| 187 | |||
| 152 | const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [ | 188 | const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [ |
| 153 | { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, | 189 | { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, |
| 154 | ]; | 190 | ]; |
| ... | @@ -267,7 +303,6 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props | ... | @@ -267,7 +303,6 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props |
| 267 | export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | 303 | export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 268 | readonly thread: EnvironmentThreadShell; | 304 | readonly thread: EnvironmentThreadShell; |
| 269 | readonly variant: "card" | "slim"; | 305 | readonly variant: "card" | "slim"; |
| 270 | readonly showSettledDivider: boolean; | ||
| 271 | /** Snoozed-shelf row: shows its wake time and offers Wake. */ | 306 | /** Snoozed-shelf row: shows its wake time and offers Wake. */ |
| 272 | readonly snoozed?: boolean; | 307 | readonly snoozed?: boolean; |
| 273 | /** Preformatted against the parent minute tick so this memoized row's | 308 | /** Preformatted against the parent minute tick so this memoized row's |
| ... | @@ -739,9 +774,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -739,9 +774,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 739 | 774 | ||
| 740 | return ( | 775 | return ( |
| 741 | <> | 776 | <> |
| 742 | {props.showSettledDivider ? ( | ||
| 743 | <ThreadListV2SectionDivider label="Settled" pane={props.pane} /> | ||
| 744 | ) : null} | ||
| 745 | <ThreadSwipeable | 777 | <ThreadSwipeable |
| 746 | backgroundColor={sidebarPane ? drawerColor : screenColor} | 778 | backgroundColor={sidebarPane ? drawerColor : screenColor} |
| 747 | compactActions={variant === "slim"} | 779 | compactActions={variant === "slim"} |
apps/mobile/src/features/threads/threadListV2.test.ts+73-9| ... | @@ -425,8 +425,8 @@ describe("buildThreadListV2Items", () => { | ... | @@ -425,8 +425,8 @@ describe("buildThreadListV2Items", () => { |
| 425 | expect(layout.snoozedCount).toBe(0); | 425 | expect(layout.snoozedCount).toBe(0); |
| 426 | }); | 426 | }); |
| 427 | 427 | ||
| 428 | it("partitions settled threads into a slim tail with one divider", () => { | 428 | it("partitions settled threads into a slim shelf", () => { |
| 429 | const { items } = buildThreadListV2Items({ | 429 | const layout = buildThreadListV2Items({ |
| 430 | threads: [ | 430 | threads: [ |
| 431 | makeThread({ id: ThreadId.make("active"), title: "Active" }), | 431 | makeThread({ id: ThreadId.make("active"), title: "Active" }), |
| 432 | makeThread({ | 432 | makeThread({ |
| ... | @@ -447,13 +447,64 @@ describe("buildThreadListV2Items", () => { | ... | @@ -447,13 +447,64 @@ describe("buildThreadListV2Items", () => { |
| 447 | now: NOW, | 447 | now: NOW, |
| 448 | }); | 448 | }); |
| 449 | 449 | ||
| 450 | expect(items.map((item) => [item.thread.id, item.variant])).toEqual([ | 450 | expect(layout.items.map((item) => [item.thread.id, item.variant])).toEqual([ |
| 451 | ["active", "card"], | 451 | ["active", "card"], |
| 452 | ["settled", "slim"], | 452 | ["settled", "slim"], |
| 453 | ["settled-2", "slim"], | 453 | ["settled-2", "slim"], |
| 454 | ]); | 454 | ]); |
| 455 | expect(items.map((item) => item.showSettledDivider)).toEqual([false, true, false]); | 455 | expect(layout.items.map((item) => item.isLast)).toEqual([false, false, true]); |
| 456 | expect(items.map((item) => item.isLast)).toEqual([false, false, true]); | 456 | expect(layout.settledCount).toBe(2); |
| 457 | expect(layout.settledShelfHeaderIndex).toBe(1); | ||
| 458 | }); | ||
| 459 | |||
| 460 | it("collapses settled threads to a counted shelf header", () => { | ||
| 461 | const layout = buildThreadListV2Items({ | ||
| 462 | threads: [ | ||
| 463 | makeThread({ id: ThreadId.make("active"), title: "Active" }), | ||
| 464 | makeThread({ | ||
| 465 | id: ThreadId.make("settled"), | ||
| 466 | title: "Settled", | ||
| 467 | settledOverride: "settled", | ||
| 468 | settledAt: NOW, | ||
| 469 | }), | ||
| 470 | ], | ||
| 471 | environmentId: null, | ||
| 472 | searchQuery: "", | ||
| 473 | now: NOW, | ||
| 474 | settledShelfExpanded: false, | ||
| 475 | }); | ||
| 476 | |||
| 477 | expect(layout.items.map((item) => item.thread.id)).toEqual(["active"]); | ||
| 478 | expect(layout.settledCount).toBe(1); | ||
| 479 | expect(layout.settledShelfHeaderIndex).toBe(1); | ||
| 480 | }); | ||
| 481 | |||
| 482 | it("keeps the selected settled thread visible when its shelf is collapsed", () => { | ||
| 483 | const layout = buildThreadListV2Items({ | ||
| 484 | threads: [ | ||
| 485 | makeThread({ | ||
| 486 | id: ThreadId.make("selected"), | ||
| 487 | title: "Selected", | ||
| 488 | settledOverride: "settled", | ||
| 489 | settledAt: NOW, | ||
| 490 | }), | ||
| 491 | makeThread({ | ||
| 492 | id: ThreadId.make("other"), | ||
| 493 | title: "Other", | ||
| 494 | settledOverride: "settled", | ||
| 495 | settledAt: NOW, | ||
| 496 | }), | ||
| 497 | ], | ||
| 498 | environmentId: null, | ||
| 499 | searchQuery: "", | ||
| 500 | now: NOW, | ||
| 501 | settledShelfExpanded: false, | ||
| 502 | selectedThreadKey: `${environmentId}:selected`, | ||
| 503 | }); | ||
| 504 | |||
| 505 | expect(layout.items.map((item) => item.thread.id)).toEqual(["selected"]); | ||
| 506 | expect(layout.settledCount).toBe(2); | ||
| 507 | expect(layout.settledShelfHeaderIndex).toBe(0); | ||
| 457 | }); | 508 | }); |
| 458 | 509 | ||
| 459 | it("keeps cards in creation order while settled sorts by recency", () => { | 510 | it("keeps cards in creation order while settled sorts by recency", () => { |
| ... | @@ -658,6 +709,8 @@ describe("buildThreadListV2ListItems", () => { | ... | @@ -658,6 +709,8 @@ describe("buildThreadListV2ListItems", () => { |
| 658 | const items = buildThreadListV2ListItems({ | 709 | const items = buildThreadListV2ListItems({ |
| 659 | items: layout.items, | 710 | items: layout.items, |
| 660 | pendingTasks: [makePendingTask("queued-1"), makePendingTask("queued-2")], | 711 | pendingTasks: [makePendingTask("queued-1"), makePendingTask("queued-2")], |
| 712 | settledCount: layout.settledCount, | ||
| 713 | settledShelfHeaderIndex: layout.settledShelfHeaderIndex, | ||
| 661 | }); | 714 | }); |
| 662 | 715 | ||
| 663 | expect( | 716 | expect( |
| ... | @@ -666,9 +719,11 @@ describe("buildThreadListV2ListItems", () => { | ... | @@ -666,9 +719,11 @@ describe("buildThreadListV2ListItems", () => { |
| 666 | ? item.pendingTask.title | 719 | ? item.pendingTask.title |
| 667 | : item.type === "v2-thread" | 720 | : item.type === "v2-thread" |
| 668 | ? item.item.thread.id | 721 | ? item.item.thread.id |
| 669 | : "snoozed-shelf", | 722 | : item.type === "v2-snoozed-shelf" |
| 723 | ? "snoozed-shelf" | ||
| 724 | : "settled-shelf", | ||
| 670 | ), | 725 | ), |
| 671 | ).toEqual(["active", "queued-1", "queued-2", "settled"]); | 726 | ).toEqual(["active", "queued-1", "queued-2", "settled-shelf", "settled"]); |
| 672 | // Only the leading queued row labels the section, exactly like Settled. | 727 | // Only the leading queued row labels the section, exactly like Settled. |
| 673 | expect( | 728 | expect( |
| 674 | items.filter((item) => item.type === "v2-pending" && item.showPendingDivider), | 729 | items.filter((item) => item.type === "v2-pending" && item.showPendingDivider), |
| ... | @@ -690,11 +745,17 @@ describe("buildThreadListV2ListItems", () => { | ... | @@ -690,11 +745,17 @@ describe("buildThreadListV2ListItems", () => { |
| 690 | expect(items.map((item) => item.type)).toEqual(["v2-thread", "v2-pending"]); | 745 | expect(items.map((item) => item.type)).toEqual(["v2-thread", "v2-pending"]); |
| 691 | }); | 746 | }); |
| 692 | 747 | ||
| 693 | it("leaves the thread order untouched when nothing is queued", () => { | 748 | it("keeps the settled shelf between active and settled rows when nothing is queued", () => { |
| 694 | const items = buildThreadListV2ListItems({ items: layout.items, pendingTasks: [] }); | 749 | const items = buildThreadListV2ListItems({ |
| 750 | items: layout.items, | ||
| 751 | pendingTasks: [], | ||
| 752 | settledCount: layout.settledCount, | ||
| 753 | settledShelfHeaderIndex: layout.settledShelfHeaderIndex, | ||
| 754 | }); | ||
| 695 | 755 | ||
| 696 | expect(items.map((item) => item.key)).toEqual([ | 756 | expect(items.map((item) => item.key)).toEqual([ |
| 697 | `v2-thread:${environmentId}:active`, | 757 | `v2-thread:${environmentId}:active`, |
| 758 | "v2-settled-shelf", | ||
| 698 | `v2-thread:${environmentId}:settled`, | 759 | `v2-thread:${environmentId}:settled`, |
| 699 | ]); | 760 | ]); |
| 700 | }); | 761 | }); |
| ... | @@ -726,12 +787,15 @@ describe("buildThreadListV2ListItems", () => { | ... | @@ -726,12 +787,15 @@ describe("buildThreadListV2ListItems", () => { |
| 726 | snoozedCount: snoozedLayout.snoozedCount, | 787 | snoozedCount: snoozedLayout.snoozedCount, |
| 727 | snoozedShelfExpanded: false, | 788 | snoozedShelfExpanded: false, |
| 728 | snoozedShelfHeaderIndex: snoozedLayout.snoozedShelfHeaderIndex, | 789 | snoozedShelfHeaderIndex: snoozedLayout.snoozedShelfHeaderIndex, |
| 790 | settledCount: snoozedLayout.settledCount, | ||
| 791 | settledShelfHeaderIndex: snoozedLayout.settledShelfHeaderIndex, | ||
| 729 | }); | 792 | }); |
| 730 | 793 | ||
| 731 | expect(items.map((item) => item.type)).toEqual([ | 794 | expect(items.map((item) => item.type)).toEqual([ |
| 732 | "v2-thread", | 795 | "v2-thread", |
| 733 | "v2-pending", | 796 | "v2-pending", |
| 734 | "v2-snoozed-shelf", | 797 | "v2-snoozed-shelf", |
| 798 | "v2-settled-shelf", | ||
| 735 | "v2-thread", | 799 | "v2-thread", |
| 736 | ]); | 800 | ]); |
| 737 | }); | 801 | }); |
apps/mobile/src/features/threads/threadListV2.ts+56-28| ... | @@ -178,8 +178,6 @@ export function sortThreadsForListV2<T extends { readonly id: string; readonly c | ... | @@ -178,8 +178,6 @@ export function sortThreadsForListV2<T extends { readonly id: string; readonly c |
| 178 | export interface ThreadListV2Item { | 178 | export interface ThreadListV2Item { |
| 179 | readonly thread: EnvironmentThreadShell; | 179 | readonly thread: EnvironmentThreadShell; |
| 180 | readonly variant: "card" | "slim"; | 180 | readonly variant: "card" | "slim"; |
| 181 | /** First settled row after the card block draws the SETTLED divider. */ | ||
| 182 | readonly showSettledDivider: boolean; | ||
| 183 | /** Snoozed-shelf row: shows the wake countdown and offers Wake. */ | 181 | /** Snoozed-shelf row: shows the wake countdown and offers Wake. */ |
| 184 | readonly snoozed: boolean; | 182 | readonly snoozed: boolean; |
| 185 | readonly isLast: boolean; | 183 | readonly isLast: boolean; |
| ... | @@ -194,6 +192,10 @@ export interface ThreadListV2Layout { | ... | @@ -194,6 +192,10 @@ export interface ThreadListV2Layout { |
| 194 | /** Index in `items` where the Snoozed shelf header belongs. The header is | 192 | /** Index in `items` where the Snoozed shelf header belongs. The header is |
| 195 | still rendered when the shelf is collapsed and no snoozed rows exist. */ | 193 | still rendered when the shelf is collapsed and no snoozed rows exist. */ |
| 196 | readonly snoozedShelfHeaderIndex: number | null; | 194 | readonly snoozedShelfHeaderIndex: number | null; |
| 195 | /** Total settled threads in scope, including rows hidden by collapse/paging. */ | ||
| 196 | readonly settledCount: number; | ||
| 197 | /** Index in `items` where the Settled shelf header belongs. */ | ||
| 198 | readonly settledShelfHeaderIndex: number | null; | ||
| 197 | /** Soonest wake time among snoozed threads, or null. Callers arm | 199 | /** Soonest wake time among snoozed threads, or null. Callers arm |
| 198 | a timeout at this boundary so the list re-partitions the moment a | 200 | a timeout at this boundary so the list re-partitions the moment a |
| 199 | snooze expires instead of on the next minute tick. */ | 201 | snooze expires instead of on the next minute tick. */ |
| ... | @@ -223,10 +225,18 @@ export interface ThreadListV2SnoozedShelfListItem { | ... | @@ -223,10 +225,18 @@ export interface ThreadListV2SnoozedShelfListItem { |
| 223 | readonly expanded: boolean; | 225 | readonly expanded: boolean; |
| 224 | } | 226 | } |
| 225 | 227 | ||
| 228 | export interface ThreadListV2SettledShelfListItem { | ||
| 229 | readonly type: "v2-settled-shelf"; | ||
| 230 | readonly key: "v2-settled-shelf"; | ||
| 231 | readonly count: number; | ||
| 232 | readonly expanded: boolean; | ||
| 233 | } | ||
| 234 | |||
| 226 | export type ThreadListV2ListItem = | 235 | export type ThreadListV2ListItem = |
| 227 | | ThreadListV2ThreadListItem | 236 | | ThreadListV2ThreadListItem |
| 228 | | ThreadListV2PendingListItem | 237 | | ThreadListV2PendingListItem |
| 229 | | ThreadListV2SnoozedShelfListItem; | 238 | | ThreadListV2SnoozedShelfListItem |
| 239 | | ThreadListV2SettledShelfListItem; | ||
| 230 | 240 | ||
| 231 | /** | 241 | /** |
| 232 | * Builds the shared mobile order: active → pending → snoozed shelf → settled. | 242 | * Builds the shared mobile order: active → pending → snoozed shelf → settled. |
| ... | @@ -239,6 +249,9 @@ export function buildThreadListV2ListItems(input: { | ... | @@ -239,6 +249,9 @@ export function buildThreadListV2ListItems(input: { |
| 239 | readonly snoozedCount?: number; | 249 | readonly snoozedCount?: number; |
| 240 | readonly snoozedShelfExpanded?: boolean; | 250 | readonly snoozedShelfExpanded?: boolean; |
| 241 | readonly snoozedShelfHeaderIndex?: number | null; | 251 | readonly snoozedShelfHeaderIndex?: number | null; |
| 252 | readonly settledCount?: number; | ||
| 253 | readonly settledShelfExpanded?: boolean; | ||
| 254 | readonly settledShelfHeaderIndex?: number | null; | ||
| 242 | readonly snoozeLabelNow?: string; | 255 | readonly snoozeLabelNow?: string; |
| 243 | }): ThreadListV2ListItem[] { | 256 | }): ThreadListV2ListItem[] { |
| 244 | const threadItems = input.items.map( | 257 | const threadItems = input.items.map( |
| ... | @@ -262,27 +275,30 @@ export function buildThreadListV2ListItems(input: { | ... | @@ -262,27 +275,30 @@ export function buildThreadListV2ListItems(input: { |
| 262 | ); | 275 | ); |
| 263 | const snoozedCount = input.snoozedCount ?? 0; | 276 | const snoozedCount = input.snoozedCount ?? 0; |
| 264 | const snoozedShelfHeaderIndex = input.snoozedShelfHeaderIndex ?? null; | 277 | const snoozedShelfHeaderIndex = input.snoozedShelfHeaderIndex ?? null; |
| 278 | const settledCount = input.settledCount ?? 0; | ||
| 279 | const settledShelfHeaderIndex = input.settledShelfHeaderIndex ?? null; | ||
| 280 | const activeEnd = snoozedShelfHeaderIndex ?? settledShelfHeaderIndex ?? threadItems.length; | ||
| 281 | const snoozedEnd = settledShelfHeaderIndex ?? threadItems.length; | ||
| 282 | const result: ThreadListV2ListItem[] = [...threadItems.slice(0, activeEnd), ...pendingItems]; | ||
| 265 | if (snoozedShelfHeaderIndex !== null && snoozedCount > 0) { | 283 | if (snoozedShelfHeaderIndex !== null && snoozedCount > 0) { |
| 266 | return [ | 284 | result.push({ |
| 267 | ...threadItems.slice(0, snoozedShelfHeaderIndex), | 285 | type: "v2-snoozed-shelf", |
| 268 | ...pendingItems, | 286 | key: "v2-snoozed-shelf", |
| 269 | { | 287 | count: snoozedCount, |
| 270 | type: "v2-snoozed-shelf", | 288 | expanded: input.snoozedShelfExpanded === true, |
| 271 | key: "v2-snoozed-shelf", | 289 | }); |
| 272 | count: snoozedCount, | 290 | result.push(...threadItems.slice(snoozedShelfHeaderIndex, snoozedEnd)); |
| 273 | expanded: input.snoozedShelfExpanded === true, | ||
| 274 | }, | ||
| 275 | ...threadItems.slice(snoozedShelfHeaderIndex), | ||
| 276 | ]; | ||
| 277 | } | 291 | } |
| 278 | if (pendingItems.length === 0) return threadItems; | 292 | if (settledShelfHeaderIndex !== null && settledCount > 0) { |
| 279 | 293 | result.push({ | |
| 280 | const settledStart = threadItems.findIndex( | 294 | type: "v2-settled-shelf", |
| 281 | (entry) => entry.type === "v2-thread" && entry.item.showSettledDivider, | 295 | key: "v2-settled-shelf", |
| 282 | ); | 296 | count: settledCount, |
| 283 | return settledStart === -1 | 297 | expanded: input.settledShelfExpanded !== false, |
| 284 | ? [...threadItems, ...pendingItems] | 298 | }); |
| 285 | : [...threadItems.slice(0, settledStart), ...pendingItems, ...threadItems.slice(settledStart)]; | 299 | result.push(...threadItems.slice(settledShelfHeaderIndex)); |
| 300 | } | ||
| 301 | return result; | ||
| 286 | } | 302 | } |
| 287 | 303 | ||
| 288 | /** | 304 | /** |
| ... | @@ -321,6 +337,8 @@ export function buildThreadListV2Items(input: { | ... | @@ -321,6 +337,8 @@ export function buildThreadListV2Items(input: { |
| 321 | readonly snoozeNow?: string; | 337 | readonly snoozeNow?: string; |
| 322 | /** Expands the snoozed shelf into rows. Collapsed is the default. */ | 338 | /** Expands the snoozed shelf into rows. Collapsed is the default. */ |
| 323 | readonly snoozedShelfExpanded?: boolean; | 339 | readonly snoozedShelfExpanded?: boolean; |
| 340 | /** Expands the settled shelf into rows. Expanded is the default. */ | ||
| 341 | readonly settledShelfExpanded?: boolean; | ||
| 324 | /** The selected thread remains visible on an otherwise collapsed shelf so | 342 | /** The selected thread remains visible on an otherwise collapsed shelf so |
| 325 | a split-view detail can never lose its navigation row. */ | 343 | a split-view detail can never lose its navigation row. */ |
| 326 | readonly selectedThreadKey?: string | null; | 344 | readonly selectedThreadKey?: string | null; |
| ... | @@ -402,15 +420,24 @@ export function buildThreadListV2Items(input: { | ... | @@ -402,15 +420,24 @@ export function buildThreadListV2Items(input: { |
| 402 | firstValidTimestampMs(left.latestUserMessageAt, left.updatedAt), | 420 | firstValidTimestampMs(left.latestUserMessageAt, left.updatedAt), |
| 403 | ); | 421 | ); |
| 404 | const settledLimit = input.settledLimit ?? Number.POSITIVE_INFINITY; | 422 | const settledLimit = input.settledLimit ?? Number.POSITIVE_INFINITY; |
| 405 | const visibleSettled = | 423 | const pagedSettled = |
| 406 | orderedSettled.length > settledLimit ? orderedSettled.slice(0, settledLimit) : orderedSettled; | 424 | orderedSettled.length > settledLimit ? orderedSettled.slice(0, settledLimit) : orderedSettled; |
| 425 | const selectedSettled = orderedSettled | ||
| 426 | .slice(pagedSettled.length) | ||
| 427 | .find((thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey); | ||
| 428 | if (selectedSettled !== undefined) pagedSettled.push(selectedSettled); | ||
| 429 | const visibleSettled = | ||
| 430 | input.settledShelfExpanded !== false | ||
| 431 | ? pagedSettled | ||
| 432 | : pagedSettled.filter( | ||
| 433 | (thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey, | ||
| 434 | ); | ||
| 407 | 435 | ||
| 408 | const items: ThreadListV2Item[] = []; | 436 | const items: ThreadListV2Item[] = []; |
| 409 | for (const thread of orderedActive) { | 437 | for (const thread of orderedActive) { |
| 410 | items.push({ | 438 | items.push({ |
| 411 | thread, | 439 | thread, |
| 412 | variant: "card", | 440 | variant: "card", |
| 413 | showSettledDivider: false, | ||
| 414 | snoozed: false, | 441 | snoozed: false, |
| 415 | isLast: false, | 442 | isLast: false, |
| 416 | }); | 443 | }); |
| ... | @@ -420,16 +447,15 @@ export function buildThreadListV2Items(input: { | ... | @@ -420,16 +447,15 @@ export function buildThreadListV2Items(input: { |
| 420 | items.push({ | 447 | items.push({ |
| 421 | thread, | 448 | thread, |
| 422 | variant: "slim", | 449 | variant: "slim", |
| 423 | showSettledDivider: false, | ||
| 424 | snoozed: true, | 450 | snoozed: true, |
| 425 | isLast: false, | 451 | isLast: false, |
| 426 | }); | 452 | }); |
| 427 | } | 453 | } |
| 428 | for (const [index, thread] of visibleSettled.entries()) { | 454 | const settledShelfHeaderIndex = orderedSettled.length > 0 ? items.length : null; |
| 455 | for (const thread of visibleSettled) { | ||
| 429 | items.push({ | 456 | items.push({ |
| 430 | thread, | 457 | thread, |
| 431 | variant: "slim", | 458 | variant: "slim", |
| 432 | showSettledDivider: index === 0, | ||
| 433 | snoozed: false, | 459 | snoozed: false, |
| 434 | isLast: false, | 460 | isLast: false, |
| 435 | }); | 461 | }); |
| ... | @@ -440,9 +466,11 @@ export function buildThreadListV2Items(input: { | ... | @@ -440,9 +466,11 @@ export function buildThreadListV2Items(input: { |
| 440 | } | 466 | } |
| 441 | return { | 467 | return { |
| 442 | items, | 468 | items, |
| 443 | hiddenSettledCount: orderedSettled.length - visibleSettled.length, | 469 | hiddenSettledCount: orderedSettled.length - pagedSettled.length, |
| 444 | snoozedCount: orderedSnoozed.length, | 470 | snoozedCount: orderedSnoozed.length, |
| 445 | snoozedShelfHeaderIndex, | 471 | snoozedShelfHeaderIndex, |
| 472 | settledCount: orderedSettled.length, | ||
| 473 | settledShelfHeaderIndex, | ||
| 446 | nextSnoozeWakeAt, | 474 | nextSnoozeWakeAt, |
| 447 | }; | 475 | }; |
| 448 | } | 476 | } |