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 0) {
- return [
- ...threadItems.slice(0, snoozedShelfHeaderIndex),
- ...pendingItems,
- {
- type: "v2-snoozed-shelf",
- key: "v2-snoozed-shelf",
- count: snoozedCount,
- expanded: input.snoozedShelfExpanded === true,
- },
- ...threadItems.slice(snoozedShelfHeaderIndex),
- ];
+ result.push({
+ type: "v2-snoozed-shelf",
+ key: "v2-snoozed-shelf",
+ count: snoozedCount,
+ expanded: input.snoozedShelfExpanded === true,
+ });
+ result.push(...threadItems.slice(snoozedShelfHeaderIndex, snoozedEnd));
}
- if (pendingItems.length === 0) return threadItems;
-
- const settledStart = threadItems.findIndex(
- (entry) => entry.type === "v2-thread" && entry.item.showSettledDivider,
- );
- return settledStart === -1
- ? [...threadItems, ...pendingItems]
- : [...threadItems.slice(0, settledStart), ...pendingItems, ...threadItems.slice(settledStart)];
+ if (settledShelfHeaderIndex !== null && settledCount > 0) {
+ result.push({
+ type: "v2-settled-shelf",
+ key: "v2-settled-shelf",
+ count: settledCount,
+ expanded: input.settledShelfExpanded !== false,
+ });
+ result.push(...threadItems.slice(settledShelfHeaderIndex));
+ }
+ return result;
}
/**
@@ -321,6 +337,8 @@ export function buildThreadListV2Items(input: {
readonly snoozeNow?: string;
/** Expands the snoozed shelf into rows. Collapsed is the default. */
readonly snoozedShelfExpanded?: boolean;
+ /** Expands the settled shelf into rows. Expanded is the default. */
+ readonly settledShelfExpanded?: boolean;
/** The selected thread remains visible on an otherwise collapsed shelf so
a split-view detail can never lose its navigation row. */
readonly selectedThreadKey?: string | null;
@@ -402,15 +420,24 @@ export function buildThreadListV2Items(input: {
firstValidTimestampMs(left.latestUserMessageAt, left.updatedAt),
);
const settledLimit = input.settledLimit ?? Number.POSITIVE_INFINITY;
- const visibleSettled =
+ const pagedSettled =
orderedSettled.length > settledLimit ? orderedSettled.slice(0, settledLimit) : orderedSettled;
+ const selectedSettled = orderedSettled
+ .slice(pagedSettled.length)
+ .find((thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey);
+ if (selectedSettled !== undefined) pagedSettled.push(selectedSettled);
+ const visibleSettled =
+ input.settledShelfExpanded !== false
+ ? pagedSettled
+ : pagedSettled.filter(
+ (thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey,
+ );
const items: ThreadListV2Item[] = [];
for (const thread of orderedActive) {
items.push({
thread,
variant: "card",
- showSettledDivider: false,
snoozed: false,
isLast: false,
});
@@ -420,16 +447,15 @@ export function buildThreadListV2Items(input: {
items.push({
thread,
variant: "slim",
- showSettledDivider: false,
snoozed: true,
isLast: false,
});
}
- for (const [index, thread] of visibleSettled.entries()) {
+ const settledShelfHeaderIndex = orderedSettled.length > 0 ? items.length : null;
+ for (const thread of visibleSettled) {
items.push({
thread,
variant: "slim",
- showSettledDivider: index === 0,
snoozed: false,
isLast: false,
});
@@ -440,9 +466,11 @@ export function buildThreadListV2Items(input: {
}
return {
items,
- hiddenSettledCount: orderedSettled.length - visibleSettled.length,
+ hiddenSettledCount: orderedSettled.length - pagedSettled.length,
snoozedCount: orderedSnoozed.length,
snoozedShelfHeaderIndex,
+ settledCount: orderedSettled.length,
+ settledShelfHeaderIndex,
nextSnoozeWakeAt,
};
}