authorgravatar for me@pixp.ccAlex <me@pixp.cc> 2026-07-31 11:55:29-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-31 14:55:29-04:00
log916cff733cd00c85fe0419df5bcb9372a6384746
treea0be843dc1c3db78cca235f4146c063945bfee18
parenta041981276b4c789fed8132e3b8a320749bf25e8
signature Signed by PGP key B5690EEEBB952194

feat(mobile): make settled threads collapsible (#5056)


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 {
44import {44import {
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";
49import {50import {
...@@ -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-settle540 // 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 original589 // 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 );
662672
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 <ThreadListV2Row717 <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 <Pressable1059 <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 {
70import {70import {
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";
75import {76import {
...@@ -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 actually446 // now ticks per minute so the inactivity auto-settle boundary is actually
444 // crossed while the pane stays open; without a clock dependency the447 // 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.snoozeWakeLabelText795 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 <ThreadListV2Row878 <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 <Pressable949 <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});
151151
152export 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
152const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [188const 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
267export const ThreadListV2Row = memo(function ThreadListV2Row(props: {303export 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's308 /** 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: {
739774
740 return (775 return (
741 <>776 <>
742 {props.showSettledDivider ? (
743 <ThreadListV2SectionDivider label="Settled" pane={props.pane} />
744 ) : null}
745 <ThreadSwipeable777 <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 });
427427
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 });
449449
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 });
458509
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 });
662715
663 expect(716 expect(
...@@ -666,9 +719,11 @@ describe("buildThreadListV2ListItems", () => {...@@ -666,9 +719,11 @@ describe("buildThreadListV2ListItems", () => {
666 ? item.pendingTask.title719 ? item.pendingTask.title
667 : item.type === "v2-thread"720 : item.type === "v2-thread"
668 ? item.item.thread.id721 ? 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 });
692747
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 });
695755
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 });
730793
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
178export interface ThreadListV2Item {178export 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 is192 /** 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 arm199 /** Soonest wake time among snoozed threads, or null. Callers arm
198 a timeout at this boundary so the list re-partitions the moment a200 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}
225227
228export interface ThreadListV2SettledShelfListItem {
229 readonly type: "v2-settled-shelf";
230 readonly key: "v2-settled-shelf";
231 readonly count: number;
232 readonly expanded: boolean;
233}
234
226export type ThreadListV2ListItem =235export type ThreadListV2ListItem =
227 | ThreadListV2ThreadListItem236 | ThreadListV2ThreadListItem
228 | ThreadListV2PendingListItem237 | ThreadListV2PendingListItem
229 | ThreadListV2SnoozedShelfListItem;238 | ThreadListV2SnoozedShelfListItem
239 | ThreadListV2SettledShelfListItem;
230240
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) {
279293 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 === -1297 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}
287303
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 so342 /** 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 );
407435
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}