Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ import { useQueuedThreadKeys } from "../../state/use-thread-outbox";
import {
ThreadListV2PendingRow,
ThreadListV2Row,
ThreadListV2PinnedShelfHeader,
ThreadListV2SettledShelfHeader,
ThreadListV2ShowMoreRow,
ThreadListV2SnoozedShelfHeader,
Expand Down Expand Up @@ -379,10 +380,12 @@ export function HomeScreen(props: HomeScreenProps) {
setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT);
const {
loaded: shelfPreferencesLoaded,
pinnedShelfExpanded,
settledShelfExpanded,
snoozedShelfExpanded,
workingShelfEnabled,
workingShelfExpanded,
togglePinnedShelf,
toggleSettledShelf,
toggleSnoozedShelf,
toggleWorkingShelf,
Expand Down Expand Up @@ -424,6 +427,7 @@ export function HomeScreen(props: HomeScreenProps) {
now,
pendingOrder,
shelfPreferencesLoaded,
pinnedShelfExpanded,
workingShelfEnabled,
workingShelfExpanded,
snoozedShelfExpanded,
Expand Down Expand Up @@ -488,6 +492,16 @@ export function HomeScreen(props: HomeScreenProps) {
/>
);
}
if (item.type === "v2-pinned-shelf") {
return (
<ThreadListV2PinnedShelfHeader
count={item.count}
disabled={item.disabled}
expanded={item.expanded}
onToggle={togglePinnedShelf}
/>
);
}
if (item.type === "v2-working-shelf") {
return (
<ThreadListV2WorkingShelfHeader
Expand Down
14 changes: 14 additions & 0 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { WorkspaceSidebarColumn } from "../layout/workspace/WorkspaceSidebarColu
import {
ThreadListV2PendingRow,
ThreadListV2Row,
ThreadListV2PinnedShelfHeader,
ThreadListV2SettledShelfHeader,
ThreadListV2ShowMoreRow,
ThreadListV2SnoozedShelfHeader,
Expand Down Expand Up @@ -241,10 +242,12 @@ function ThreadNavigationSidebarPane(props: ThreadNavigationSidebarProps) {
setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT);
const {
loaded: shelfPreferencesLoaded,
pinnedShelfExpanded,
settledShelfExpanded,
snoozedShelfExpanded,
workingShelfEnabled,
workingShelfExpanded,
togglePinnedShelf,
toggleSettledShelf,
toggleSnoozedShelf,
toggleWorkingShelf,
Expand Down Expand Up @@ -286,6 +289,7 @@ function ThreadNavigationSidebarPane(props: ThreadNavigationSidebarProps) {
now,
pendingOrder,
shelfPreferencesLoaded,
pinnedShelfExpanded,
workingShelfEnabled,
workingShelfExpanded,
snoozedShelfExpanded,
Expand Down Expand Up @@ -485,6 +489,16 @@ function ThreadNavigationSidebarPane(props: ThreadNavigationSidebarProps) {
/>
);
}
case "v2-pinned-shelf":
return (
<ThreadListV2PinnedShelfHeader
count={item.count}
disabled={item.disabled}
expanded={item.expanded}
onToggle={togglePinnedShelf}
pane="sidebar"
/>
);
case "v2-working-shelf":
return (
<ThreadListV2WorkingShelfHeader
Expand Down
13 changes: 12 additions & 1 deletion apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,12 @@ type ThreadListV2ShelfHeaderProps = {
readonly pane?: "screen" | "sidebar";
};

const SHELF_LABEL = { working: "Working", snoozed: "Snoozed", settled: "Settled" } as const;
const SHELF_LABEL = {
pinned: "Pinned",
working: "Working",
snoozed: "Snoozed",
settled: "Settled",
} as const;

function ThreadListV2ShelfHeader(
props: ThreadListV2ShelfHeaderProps & { readonly kind: keyof typeof SHELF_LABEL },
Expand All @@ -303,6 +308,12 @@ function ThreadListV2ShelfHeader(
);
}

export const ThreadListV2PinnedShelfHeader = memo(function ThreadListV2PinnedShelfHeader(
props: ThreadListV2ShelfHeaderProps,
) {
return <ThreadListV2ShelfHeader {...props} kind="pinned" />;
});

export const ThreadListV2WorkingShelfHeader = memo(function ThreadListV2WorkingShelfHeader(
props: ThreadListV2ShelfHeaderProps,
) {
Expand Down
19 changes: 19 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,21 @@ describe("buildThreadListV2Items", () => {
expect(layout.items.map((item) => item.thread.id)).toEqual(["open"]);
expect(layout.items[0]?.snoozed).toBe(true);
expect(layout.snoozedCount).toBe(2);

const pins = buildThreadListV2Items({
threads: [
makeThread({ id: ThreadId.make("pin-open"), title: "Pin open", pinnedAt: NOW }),
makeThread({ id: ThreadId.make("pin-other"), title: "Pin other", pinnedAt: NOW }),
makeThread({ id: ThreadId.make("active"), title: "Active" }),
],
environmentId: null,
searchQuery: "",
now: NOW,
pinnedShelfExpanded: false,
selectedThreadKey: `${environmentId}:pin-open`,
});
expect(pins.items.map((item) => item.thread.id)).toEqual(["pin-open", "active"]);
expect(pins.pinnedCount).toBe(2);
});

it("keeps snoozed threads visible on environments without the snooze capability", () => {
Expand Down Expand Up @@ -1730,6 +1745,7 @@ describe("Working section beta", () => {
it("places the shelf after queued tasks and before snoozed and settled threads", () => {
const layout = buildThreadListV2Items({
threads: [
makeThread({ id: ThreadId.make("pinned"), title: "pinned", pinnedAt: NOW }),
makeThread({ id: ThreadId.make("active"), title: "active" }),
makeThread({ id: ThreadId.make("working"), title: "working", runtime: running }),
makeThread({
Expand All @@ -1756,6 +1772,7 @@ describe("Working section beta", () => {
const items = buildThreadListV2ListItems({
items: layout.items,
pendingTasks: [makePendingTask("queued")],
pinnedCount: layout.pinnedCount,
workingCount: layout.workingCount,
workingShelfExpanded: true,
workingShelfHeaderIndex: layout.workingShelfHeaderIndex,
Expand All @@ -1775,6 +1792,8 @@ describe("Working section beta", () => {
),
).toEqual([
"queued",
"v2-pinned-shelf",
"pinned",
"active",
"v2-working-shelf",
"working",
Expand Down
54 changes: 48 additions & 6 deletions apps/mobile/src/features/threads/threadListV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,8 @@ export interface ThreadListV2Layout {
readonly hiddenSettledCount: number;
/** The settled rows the shelf may list, before its visible limit. */
readonly listedSettledThreads: ReadonlyArray<EnvironmentThreadShell>;
/** Pinned threads in scope; their rows lead `items`, so the shelf header sits before them. */
readonly pinnedCount: number;
readonly workingCount: number;
readonly workingShelfHeaderIndex: number | null;
readonly snoozedCount: number;
Expand Down Expand Up @@ -399,6 +401,14 @@ export interface ThreadListV2PendingListItem {
readonly showTrailingDivider: boolean;
}

export interface ThreadListV2PinnedShelfListItem {
readonly type: "v2-pinned-shelf";
readonly key: "v2-pinned-shelf";
readonly count: number;
readonly expanded: boolean;
readonly disabled: boolean;
}

export interface ThreadListV2WorkingShelfListItem {
readonly type: "v2-working-shelf";
readonly key: "v2-working-shelf";
Expand Down Expand Up @@ -427,6 +437,7 @@ export interface ThreadListV2SettledShelfListItem {
export type ThreadListV2ListItem =
| ThreadListV2ThreadListItem
| ThreadListV2PendingListItem
| ThreadListV2PinnedShelfListItem
| ThreadListV2WorkingShelfListItem
| ThreadListV2SnoozedShelfListItem
| ThreadListV2SettledShelfListItem;
Expand All @@ -437,6 +448,7 @@ export function isThreadListV2ListItem(value: {
return (
value.type === "v2-thread" ||
value.type === "v2-pending" ||
value.type === "v2-pinned-shelf" ||
value.type === "v2-working-shelf" ||
value.type === "v2-snoozed-shelf" ||
value.type === "v2-settled-shelf"
Expand Down Expand Up @@ -474,6 +486,13 @@ export function threadListV2ListItemsAreEqual(
previous.showPendingDivider === item.showPendingDivider &&
previous.showTrailingDivider === item.showTrailingDivider
);
case "v2-pinned-shelf":
return (
previous.type === "v2-pinned-shelf" &&
previous.count === item.count &&
previous.expanded === item.expanded &&
previous.disabled === item.disabled
);
case "v2-working-shelf":
return (
previous.type === "v2-working-shelf" &&
Expand Down Expand Up @@ -521,6 +540,8 @@ function resolveThreadListV2ItemTimeLabel(
export function buildThreadListV2ListItems(input: {
readonly items: ReadonlyArray<ThreadListV2Item>;
readonly pendingTasks: ReadonlyArray<PendingNewTask>;
readonly pinnedCount?: number;
readonly pinnedShelfExpanded?: boolean;
readonly workingCount?: number;
readonly workingShelfExpanded?: boolean;
readonly workingShelfHeaderIndex?: number | null;
Expand Down Expand Up @@ -584,8 +605,18 @@ export function buildThreadListV2ListItems(input: {
const snoozedEnd = settledShelfHeaderIndex ?? threadItems.length;
const workingEnd = snoozedShelfHeaderIndex ?? snoozedEnd;
const activeEnd = workingShelfHeaderIndex ?? workingEnd;
const result: ThreadListV2ListItem[] = [...pendingItems, ...threadItems.slice(0, activeEnd)];
const shelfDisabled = input.shelfPreferencesLoading === true;
const result: ThreadListV2ListItem[] = [...pendingItems];
if ((input.pinnedCount ?? 0) > 0) {
result.push({
type: "v2-pinned-shelf",
key: "v2-pinned-shelf",
count: input.pinnedCount ?? 0,
expanded: input.pinnedShelfExpanded !== false,
disabled: shelfDisabled,
});
}
result.push(...threadItems.slice(0, activeEnd));
if (workingShelfHeaderIndex !== null && workingCount > 0) {
result.push({
type: "v2-working-shelf",
Expand Down Expand Up @@ -663,6 +694,8 @@ export function buildThreadListV2Items(input: {
readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;
readonly settledLimit?: number;
readonly now: string;
/** Pins stay listed unless the shelf is explicitly collapsed. */
readonly pinnedShelfExpanded?: boolean;
readonly workingShelfEnabled?: boolean;
readonly workingShelfExpanded?: boolean;
readonly isWorking?: (thread: EnvironmentThreadShell) => boolean;
Expand Down Expand Up @@ -788,6 +821,7 @@ export function buildThreadListV2Items(input: {
})),
hiddenSettledCount: 0,
listedSettledThreads: [],
pinnedCount: 0,
workingCount: 0,
workingShelfHeaderIndex: null,
snoozedCount: 0,
Expand All @@ -808,6 +842,17 @@ export function buildThreadListV2Items(input: {
parseTimestampMs(left.snoozedUntil ?? "") - parseTimestampMs(right.snoozedUntil ?? ""),
);
const selectedThreadKey = input.selectedThreadKey ?? null;
const orderedPinned = applyPendingThreadOrder(
sortPinnedThreadsByOrderKey(pinned),
"pinned",
pending,
);
const visiblePinned =
input.pinnedShelfExpanded !== false
? orderedPinned
: orderedPinned.filter(
(thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey,
);
const visibleWorking =
input.workingShelfExpanded === true
? orderedWorking
Expand Down Expand Up @@ -846,11 +891,7 @@ export function buildThreadListV2Items(input: {
);

const items: ThreadListV2Item[] = [];
for (const thread of applyPendingThreadOrder(
sortPinnedThreadsByOrderKey(pinned),
"pinned",
pending,
)) {
for (const thread of visiblePinned) {
items.push({
thread,
variant: "card",
Expand Down Expand Up @@ -911,6 +952,7 @@ export function buildThreadListV2Items(input: {
items,
hiddenSettledCount: Math.max(0, settledTotal - pagedSettled.length),
listedSettledThreads: listedSettled,
pinnedCount: orderedPinned.length,
workingCount: orderedWorking.length,
workingShelfHeaderIndex,
snoozedCount: orderedSnoozed.length,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ export function useThreadListV2ShelfPreferences() {
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const loaded = AsyncResult.isSuccess(preferencesResult);
// Pins stay listed until the stored choice says otherwise, so they do not pop in after loading.
const pinnedShelfExpanded =
!loaded || preferencesResult.value.threadListPinnedShelfExpanded !== false;
const snoozedShelfExpanded =
loaded && preferencesResult.value.threadListSnoozedShelfExpanded === true;
const settledShelfExpanded =
Expand All @@ -16,6 +19,14 @@ export function useThreadListV2ShelfPreferences() {
loaded && preferencesResult.value.threadListWorkingShelfExpanded === true;

// Transforms read optimistic state so same-frame presses toggle twice.
const togglePinnedShelf = () => {
if (!loaded) return;
savePreferences({
transform: (current) => ({
threadListPinnedShelfExpanded: current.threadListPinnedShelfExpanded === false,
}),
});
};
const toggleSnoozedShelf = () => {
if (!loaded) return;
savePreferences({
Expand Down Expand Up @@ -43,10 +54,12 @@ export function useThreadListV2ShelfPreferences() {

return {
loaded,
pinnedShelfExpanded,
settledShelfExpanded,
snoozedShelfExpanded,
workingShelfEnabled,
workingShelfExpanded,
togglePinnedShelf,
toggleSettledShelf,
toggleSnoozedShelf,
toggleWorkingShelf,
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/threads/useThreadListV2.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ it("reuses a large thread list across tab renders and updates when its inputs ch
now: "2026-01-02T00:00:00.000Z",
pendingOrder: null,
shelfPreferencesLoaded: true,
pinnedShelfExpanded: true,
workingShelfEnabled: true,
workingShelfExpanded: false,
snoozedShelfExpanded: false,
Expand Down
6 changes: 6 additions & 0 deletions apps/mobile/src/features/threads/useThreadListV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export function useThreadListV2(input: {
readonly now: string;
readonly pendingOrder: LayoutInput["pendingOrder"];
readonly shelfPreferencesLoaded: boolean;
readonly pinnedShelfExpanded: boolean;
readonly workingShelfEnabled: boolean;
readonly workingShelfExpanded: boolean;
readonly snoozedShelfExpanded: boolean;
Expand Down Expand Up @@ -145,6 +146,7 @@ export function useThreadListV2(input: {
queuedThreadKeys: input.queuedThreadKeys,
settledLimit: input.settledLimit,
now: layoutTime,
pinnedShelfExpanded: input.pinnedShelfExpanded,
workingShelfEnabled,
workingShelfExpanded: input.workingShelfExpanded,
inboxReturnAt: inbox.returnedAt,
Expand Down Expand Up @@ -177,6 +179,7 @@ export function useThreadListV2(input: {
input.queuedThreadKeys,
input.settledLimit,
layoutTime,
input.pinnedShelfExpanded,
workingShelfEnabled,
input.workingShelfExpanded,
inbox.returnedAt,
Expand Down Expand Up @@ -214,6 +217,8 @@ export function useThreadListV2(input: {
buildThreadListV2ListItems({
items: layout.items,
pendingTasks,
pinnedCount: layout.pinnedCount,
pinnedShelfExpanded: input.pinnedShelfExpanded,
workingCount: layout.workingCount,
workingShelfExpanded: input.workingShelfExpanded,
workingShelfHeaderIndex: layout.workingShelfHeaderIndex,
Expand All @@ -232,6 +237,7 @@ export function useThreadListV2(input: {
[
layout,
pendingTasks,
input.pinnedShelfExpanded,
input.workingShelfExpanded,
input.snoozedShelfExpanded,
input.settledShelfExpanded,
Expand Down
Loading
Loading