diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 78907cf21ca..d54fb7d4890 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -44,6 +44,7 @@ import { import { ThreadListV2PendingRow, ThreadListV2Row, + ThreadListV2SettledShelfHeader, ThreadListV2SnoozedShelfHeader, } from "../threads/thread-list-v2-items"; import { @@ -534,6 +535,8 @@ export function HomeScreen(props: HomeScreenProps) { ); const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useState(false); const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []); + const [settledShelfExpanded, setSettledShelfExpanded] = useState(true); + const toggleSettledShelf = useCallback(() => setSettledShelfExpanded((value) => !value), []); // now is quantized to the minute and ticks so the inactivity auto-settle // boundary is actually crossed while the app stays open (mirrors web); // without a clock dependency the partition memoizes a frozen "now". @@ -579,6 +582,8 @@ export function HomeScreen(props: HomeScreenProps) { hiddenSettledCount: 0, snoozedCount: 0, snoozedShelfHeaderIndex: null, + settledCount: 0, + settledShelfHeaderIndex: null, nextSnoozeWakeAt: null, }; // Settled threads are live shells; archived threads keep their original @@ -596,6 +601,7 @@ export function HomeScreen(props: HomeScreenProps) { now: `${nowMinute}:00.000Z`, snoozeNow: new Date().toISOString(), snoozedShelfExpanded, + settledShelfExpanded, selectedThreadKey: null, }); }, [ @@ -603,6 +609,7 @@ export function HomeScreen(props: HomeScreenProps) { nowMinute, snoozeWakeTick, snoozedShelfExpanded, + settledShelfExpanded, settledVisibleCount, settlementEnvironmentIds, snoozeEnvironmentIds, @@ -655,9 +662,12 @@ export function HomeScreen(props: HomeScreenProps) { snoozedCount: threadListV2Layout.snoozedCount, snoozedShelfExpanded, snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex, + settledCount: threadListV2Layout.settledCount, + settledShelfExpanded, + settledShelfHeaderIndex: threadListV2Layout.settledShelfHeaderIndex, snoozeLabelNow: `${nowMinute}:00.000Z`, }), - [snoozedShelfExpanded, threadListV2Layout, v2PendingTasks], + [settledShelfExpanded, snoozedShelfExpanded, threadListV2Layout, v2PendingTasks], ); const renderV2Item = useCallback( @@ -693,12 +703,20 @@ export function HomeScreen(props: HomeScreenProps) { /> ); } + if (item.type === "v2-settled-shelf") { + return ( + + ); + } const thread = item.item.thread; return ( 0 ? ( + settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0 ? ( setSnoozedShelfExpanded((value) => !value), []); + const [settledShelfExpanded, setSettledShelfExpanded] = useState(true); + const toggleSettledShelf = useCallback(() => setSettledShelfExpanded((value) => !value), []); // now ticks per minute so the inactivity auto-settle boundary is actually // crossed while the pane stays open; without a clock dependency the // partition memoizes a frozen "now". @@ -485,6 +488,8 @@ function ThreadNavigationSidebarPane( hiddenSettledCount: 0, snoozedCount: 0, snoozedShelfHeaderIndex: null, + settledCount: 0, + settledShelfHeaderIndex: null, nextSnoozeWakeAt: null, }; return buildThreadListV2Items({ @@ -500,6 +505,7 @@ function ThreadNavigationSidebarPane( now: `${nowMinute}:00.000Z`, snoozeNow: new Date().toISOString(), snoozedShelfExpanded, + settledShelfExpanded, selectedThreadKey: props.selectedThreadKey ?? null, }); }, [ @@ -507,6 +513,7 @@ function ThreadNavigationSidebarPane( nowMinute, snoozeWakeTick, snoozedShelfExpanded, + settledShelfExpanded, props.selectedThreadKey, options.selectedEnvironmentId, props.searchQuery, @@ -557,9 +564,12 @@ function ThreadNavigationSidebarPane( snoozedCount: threadListV2Layout.snoozedCount, snoozedShelfExpanded, snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex, + settledCount: threadListV2Layout.settledCount, + settledShelfExpanded, + settledShelfHeaderIndex: threadListV2Layout.settledShelfHeaderIndex, snoozeLabelNow: `${nowMinute}:00.000Z`, }); - if (threadListV2Layout.hiddenSettledCount > 0) { + if (settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0) { items.push({ type: "v2-show-more", key: "v2-show-more", @@ -574,6 +584,7 @@ function ThreadNavigationSidebarPane( pendingTasks, props.searchQuery, selectedProjectRefs, + settledShelfExpanded, snoozedShelfExpanded, threadListV2Enabled, threadListV2Layout, @@ -780,7 +791,6 @@ function ThreadNavigationSidebarPane( previous.key === item.key && previous.item.thread === item.item.thread && previous.item.variant === item.item.variant && - previous.item.showSettledDivider === item.item.showSettledDivider && previous.item.snoozed === item.item.snoozed && previous.snoozeWakeLabelText === item.snoozeWakeLabelText ); @@ -797,15 +807,20 @@ function ThreadNavigationSidebarPane( if (previous.type === "v2-snoozed-shelf" && item.type === "v2-snoozed-shelf") { return previous.count === item.count && previous.expanded === item.expanded; } + if (previous.type === "v2-settled-shelf" && item.type === "v2-settled-shelf") { + return previous.count === item.count && previous.expanded === item.expanded; + } if ( previous.type === "v2-thread" || previous.type === "v2-show-more" || previous.type === "v2-pending" || previous.type === "v2-snoozed-shelf" || + previous.type === "v2-settled-shelf" || item.type === "v2-thread" || item.type === "v2-show-more" || item.type === "v2-pending" || - item.type === "v2-snoozed-shelf" + item.type === "v2-snoozed-shelf" || + item.type === "v2-settled-shelf" ) { return false; } @@ -863,7 +878,6 @@ function ThreadNavigationSidebarPane( ); + case "v2-settled-shelf": + return ( + + ); case "v2-show-more": return ( void; + readonly pane?: "screen" | "sidebar"; +}) { + const mutedColor = useThemeColor("--color-foreground-muted"); + return ( + ({ opacity: pressed ? 0.6 : 1 })} + > + + {props.expanded ? "Settled" : `Settled (${props.count})`} + + + + + ); +}); + const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [ { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ]; @@ -267,7 +303,6 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly thread: EnvironmentThreadShell; readonly variant: "card" | "slim"; - readonly showSettledDivider: boolean; /** Snoozed-shelf row: shows its wake time and offers Wake. */ readonly snoozed?: boolean; /** Preformatted against the parent minute tick so this memoized row's @@ -739,9 +774,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { return ( <> - {props.showSettledDivider ? ( - - ) : null} { expect(layout.snoozedCount).toBe(0); }); - it("partitions settled threads into a slim tail with one divider", () => { - const { items } = buildThreadListV2Items({ + it("partitions settled threads into a slim shelf", () => { + const layout = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("active"), title: "Active" }), makeThread({ @@ -447,13 +447,64 @@ describe("buildThreadListV2Items", () => { now: NOW, }); - expect(items.map((item) => [item.thread.id, item.variant])).toEqual([ + expect(layout.items.map((item) => [item.thread.id, item.variant])).toEqual([ ["active", "card"], ["settled", "slim"], ["settled-2", "slim"], ]); - expect(items.map((item) => item.showSettledDivider)).toEqual([false, true, false]); - expect(items.map((item) => item.isLast)).toEqual([false, false, true]); + expect(layout.items.map((item) => item.isLast)).toEqual([false, false, true]); + expect(layout.settledCount).toBe(2); + expect(layout.settledShelfHeaderIndex).toBe(1); + }); + + it("collapses settled threads to a counted shelf header", () => { + const layout = buildThreadListV2Items({ + threads: [ + makeThread({ id: ThreadId.make("active"), title: "Active" }), + makeThread({ + id: ThreadId.make("settled"), + title: "Settled", + settledOverride: "settled", + settledAt: NOW, + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + settledShelfExpanded: false, + }); + + expect(layout.items.map((item) => item.thread.id)).toEqual(["active"]); + expect(layout.settledCount).toBe(1); + expect(layout.settledShelfHeaderIndex).toBe(1); + }); + + it("keeps the selected settled thread visible when its shelf is collapsed", () => { + const layout = buildThreadListV2Items({ + threads: [ + makeThread({ + id: ThreadId.make("selected"), + title: "Selected", + settledOverride: "settled", + settledAt: NOW, + }), + makeThread({ + id: ThreadId.make("other"), + title: "Other", + settledOverride: "settled", + settledAt: NOW, + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + settledShelfExpanded: false, + selectedThreadKey: `${environmentId}:selected`, + }); + + expect(layout.items.map((item) => item.thread.id)).toEqual(["selected"]); + expect(layout.settledCount).toBe(2); + expect(layout.settledShelfHeaderIndex).toBe(0); }); it("keeps cards in creation order while settled sorts by recency", () => { @@ -658,6 +709,8 @@ describe("buildThreadListV2ListItems", () => { const items = buildThreadListV2ListItems({ items: layout.items, pendingTasks: [makePendingTask("queued-1"), makePendingTask("queued-2")], + settledCount: layout.settledCount, + settledShelfHeaderIndex: layout.settledShelfHeaderIndex, }); expect( @@ -666,9 +719,11 @@ describe("buildThreadListV2ListItems", () => { ? item.pendingTask.title : item.type === "v2-thread" ? item.item.thread.id - : "snoozed-shelf", + : item.type === "v2-snoozed-shelf" + ? "snoozed-shelf" + : "settled-shelf", ), - ).toEqual(["active", "queued-1", "queued-2", "settled"]); + ).toEqual(["active", "queued-1", "queued-2", "settled-shelf", "settled"]); // Only the leading queued row labels the section, exactly like Settled. expect( items.filter((item) => item.type === "v2-pending" && item.showPendingDivider), @@ -690,11 +745,17 @@ describe("buildThreadListV2ListItems", () => { expect(items.map((item) => item.type)).toEqual(["v2-thread", "v2-pending"]); }); - it("leaves the thread order untouched when nothing is queued", () => { - const items = buildThreadListV2ListItems({ items: layout.items, pendingTasks: [] }); + it("keeps the settled shelf between active and settled rows when nothing is queued", () => { + const items = buildThreadListV2ListItems({ + items: layout.items, + pendingTasks: [], + settledCount: layout.settledCount, + settledShelfHeaderIndex: layout.settledShelfHeaderIndex, + }); expect(items.map((item) => item.key)).toEqual([ `v2-thread:${environmentId}:active`, + "v2-settled-shelf", `v2-thread:${environmentId}:settled`, ]); }); @@ -726,12 +787,15 @@ describe("buildThreadListV2ListItems", () => { snoozedCount: snoozedLayout.snoozedCount, snoozedShelfExpanded: false, snoozedShelfHeaderIndex: snoozedLayout.snoozedShelfHeaderIndex, + settledCount: snoozedLayout.settledCount, + settledShelfHeaderIndex: snoozedLayout.settledShelfHeaderIndex, }); expect(items.map((item) => item.type)).toEqual([ "v2-thread", "v2-pending", "v2-snoozed-shelf", + "v2-settled-shelf", "v2-thread", ]); }); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 6ed22f5c0b3..c88aff4ec02 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -178,8 +178,6 @@ export function sortThreadsForListV2