import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests";import { effectiveSnoozed, hasQueuedTurnStart, QUEUED_TURN_START_GRACE_MS, resolveSnoozePresets, snoozeWakeLabel,} from "@t3tools/client-runtime/state/thread-settled";import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled";import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";import { sortActiveThreadsByOrderKey, resolveSettledThreadTimestamp, sortPinnedThreadsByOrderKey,} from "@t3tools/client-runtime/state/thread-sort";import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { applyPendingThreadOrder, reconcilePendingThreadOrder, type PendingThreadOrder,} from "./threadOrder"; export { snoozeWakeLabel }; /** * Thread List v2 model, ported from the web sidebar v2 * (apps/web/src/components/Sidebar.logic.ts + SidebarV2.tsx). * * Four visual states, three colors: color is reserved for "act now" * (approval), "in motion" (working), and "broken" (failed). Ready is the * unlabeled resting state. */export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready";export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze"; export function resolveThreadListV2SnoozeMenuSelection(input: { readonly event: string; readonly displayedPresets: ReadonlyArray<SnoozePreset>; readonly now: Date;}): | { readonly _tag: "selected"; readonly preset: SnoozePreset } | { readonly _tag: "expired" } | { readonly _tag: "not-snooze" } { if (!input.event.startsWith("snooze:")) return { _tag: "not-snooze" }; const currentPreset = resolveSnoozePresets(input.now).find( (candidate) => input.event === `snooze:${candidate.id}`, ); if (currentPreset) return { _tag: "selected", preset: currentPreset }; const displayedPreset = input.displayedPresets.find( (candidate) => input.event === `snooze:${candidate.id}`, ); if (displayedPreset && Date.parse(displayedPreset.snoozedUntil) > input.now.getTime()) { return { _tag: "selected", preset: displayedPreset }; } return { _tag: "expired" };} export function resolveThreadListV2SwipeActions(input: { readonly variant: "card" | "slim"; readonly settlementSupported: boolean; readonly snoozeSupported: boolean; readonly snoozable: boolean; /** Row is on the snoozed shelf. */ readonly snoozed?: boolean;}): { readonly primary: Exclude<ThreadListV2SwipeAction, "snooze">; readonly secondary: "snooze" | null;} { if (input.snoozed === true) { return { primary: "unsnooze", secondary: null }; } const primary = input.settlementSupported ? input.variant === "slim" ? "unsettle" : "settle" : "archive"; return { primary, secondary: input.snoozeSupported && input.snoozable ? "snooze" : null, };} /** * The point at which a queued-turn snooze guard expires on its own. Rows arm * a one-shot timer for this boundary so Snooze appears without waiting for an * unrelated render. User-blocked threads return null because only fresh * server data can make them snoozable. */export function resolveThreadListV2SnoozeGateExpiryMs( thread: Pick< EnvironmentThreadShell, "hasPendingApprovals" | "hasPendingUserInput" | "latestUserMessageAt" | "latestTurn" | "session" >, options: { readonly now: string },): number | null { if (thread.hasPendingApprovals || thread.hasPendingUserInput) return null; if (!hasQueuedTurnStart(thread, options)) return null; const messageAtMs = Date.parse(thread.latestUserMessageAt ?? ""); if (Number.isNaN(messageAtMs)) return null; return messageAtMs + QUEUED_TURN_START_GRACE_MS;} // Settled-tail paging: recent history is the common lookup; the deep tail// stays behind an explicit Show more. Shared by the compact Home list and// the iPad sidebar so both page identically.export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10;export const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25; /** * The flat Thread List v2 is the default on every app variant; the Settings → * Legacy toggle opts a device back into the grouped legacy list. Preferences * persist as sparse patches, so `undefined` genuinely means "never chosen". * * `preferencesLoaded` guards the startup window: preferences load * asynchronously, and rendering one list before the stored choice arrives would * remount the whole thing a tick later. While loading, hold the default — that * is where every device without an explicit legacy opt-in lands anyway. */export function resolveThreadListV2Enabled(input: { readonly legacyPreference: boolean | undefined; readonly preferencesLoaded: boolean;}): boolean { if (!input.preferencesLoaded) { return true; } return input.legacyPreference !== true;} export function resolveThreadListV2Status( thread: Pick<EnvironmentThreadShell, "hasPendingApprovals" | "hasPendingUserInput" | "session">,): ThreadListV2Status { if (thread.hasPendingApprovals) { return "approval"; } if (thread.hasPendingUserInput) { return "input"; } if (thread.session?.status === "running" || thread.session?.status === "starting") { return "working"; } if (thread.session?.status === "error") { return "failed"; } return "ready";} /** NaN-safe Date.parse for sort comparators: a malformed timestamp must not poison the whole ordering, so it sinks to the epoch instead. */function parseTimestampMs(isoDate: string): number { const parsed = Date.parse(isoDate); return Number.isNaN(parsed) ? 0 : parsed;} /** The active order shared by web and native: new/reopened rows, then the saved arrangement. Activity does not move a thread. */export function sortThreadsForListV2< T extends { readonly id: string; readonly createdAt: string; readonly unsettledAt?: string | null | undefined; readonly activeOrderKey?: string | null | undefined; readonly environmentId?: string | undefined; },>(threads: readonly T[]): T[] { return sortActiveThreadsByOrderKey(threads);} /** Canonical card section for Move up/down, independent of search or scope. */export function getThreadListV2OrderedSection(input: { readonly threads: readonly EnvironmentThreadShell[]; readonly section: "pinned" | "active"; readonly pendingOrder?: PendingThreadOrder | null; readonly now: string; readonly settlementEnvironmentIds?: ReadonlySet<EnvironmentId>; readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>; readonly queuedThreadKeys?: ReadonlySet<string>;}): EnvironmentThreadShell[] { const threads = input.threads.filter((thread) => { if (thread.archivedAt !== null) return false; if ( (input.settlementEnvironmentIds?.has(thread.environmentId) ?? true) && thread.settledOverride === "settled" && input.queuedThreadKeys?.has(`${thread.environmentId}:${thread.id}`) !== true ) { return false; } if ( (input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true) && effectiveSnoozed(thread, { now: input.now }) ) { return false; } return (thread.pinnedAt != null) === (input.section === "pinned"); }); const ordered = input.section === "pinned" ? sortPinnedThreadsByOrderKey(threads) : sortActiveThreadsByOrderKey(threads); const pending = input.pendingOrder?.section === input.section ? reconcilePendingThreadOrder(input.pendingOrder, ordered) : null; return applyPendingThreadOrder(ordered, input.section, pending);} export interface ThreadListV2Item { readonly thread: EnvironmentThreadShell; readonly variant: "card" | "slim"; /** Snoozed-shelf row: shows the wake countdown and offers Wake. */ readonly snoozed: boolean; /** Pinned-block row: renders the pin glyph and offers Unpin. */ readonly pinned: boolean; readonly isLast: boolean;} export interface ThreadListV2Layout { readonly items: ThreadListV2Item[]; /** Settled threads beyond the render limit (behind "Show more"). */ readonly hiddenSettledCount: number; /** Snoozed threads matching the current filters. */ readonly snoozedCount: number; /** Index in `items` where the Snoozed shelf header belongs. The header is still rendered when the shelf is collapsed and no snoozed rows exist. */ readonly snoozedShelfHeaderIndex: number | null; /** Total settled threads in scope, including rows hidden by collapse/paging. */ readonly settledCount: number; /** Index in `items` where the Settled shelf header belongs. */ readonly settledShelfHeaderIndex: number | null; /** Soonest wake time among snoozed threads, or null. Callers arm a timeout at this boundary so the list re-partitions the moment a snooze expires instead of on the next minute tick. */ readonly nextSnoozeWakeAt: string | null;} export interface ThreadListV2ThreadListItem { readonly type: "v2-thread"; readonly key: string; readonly item: ThreadListV2Item; /** Precomputed so recycled-list equality can see a minute-tick change. */ readonly snoozeWakeLabelText: string | undefined;} export interface ThreadListV2PendingListItem { readonly type: "v2-pending"; readonly key: string; readonly pendingTask: PendingNewTask; /** First queued row after the active block draws the PENDING divider. */ readonly showPendingDivider: boolean;} export interface ThreadListV2SnoozedShelfListItem { readonly type: "v2-snoozed-shelf"; readonly key: "v2-snoozed-shelf"; readonly count: number; readonly expanded: boolean;} export interface ThreadListV2SettledShelfListItem { readonly type: "v2-settled-shelf"; readonly key: "v2-settled-shelf"; readonly count: number; readonly expanded: boolean;} export type ThreadListV2ListItem = | ThreadListV2ThreadListItem | ThreadListV2PendingListItem | ThreadListV2SnoozedShelfListItem | ThreadListV2SettledShelfListItem; /** * Builds the shared mobile order: active → pending → snoozed shelf → settled. * Pending tasks are waiting rather than asking, and parked work remains * reachable without competing with either the inbox or settled history. */export function buildThreadListV2ListItems(input: { readonly items: ReadonlyArray<ThreadListV2Item>; readonly pendingTasks: ReadonlyArray<PendingNewTask>; readonly snoozedCount?: number; readonly snoozedShelfExpanded?: boolean; readonly snoozedShelfHeaderIndex?: number | null; readonly settledCount?: number; readonly settledShelfExpanded?: boolean; readonly settledShelfHeaderIndex?: number | null; readonly snoozeLabelNow?: string;}): ThreadListV2ListItem[] { const threadItems = input.items.map((item): ThreadListV2ListItem => ({ type: "v2-thread", key: `v2-thread:${item.thread.environmentId}:${item.thread.id}`, item, snoozeWakeLabelText: item.snoozed && item.thread.snoozedUntil != null && input.snoozeLabelNow !== undefined ? snoozeWakeLabel(item.thread.snoozedUntil, { now: input.snoozeLabelNow }) : undefined, })); const pendingItems = input.pendingTasks.map((pendingTask, index): ThreadListV2ListItem => ({ type: "v2-pending", key: `v2-${pendingTask.key}`, pendingTask, showPendingDivider: index === 0, })); const snoozedCount = input.snoozedCount ?? 0; const snoozedShelfHeaderIndex = input.snoozedShelfHeaderIndex ?? null; const settledCount = input.settledCount ?? 0; const settledShelfHeaderIndex = input.settledShelfHeaderIndex ?? null; const activeEnd = snoozedShelfHeaderIndex ?? settledShelfHeaderIndex ?? threadItems.length; const snoozedEnd = settledShelfHeaderIndex ?? threadItems.length; const result: ThreadListV2ListItem[] = [...threadItems.slice(0, activeEnd), ...pendingItems]; if (snoozedShelfHeaderIndex !== null && snoozedCount > 0) { result.push({ type: "v2-snoozed-shelf", key: "v2-snoozed-shelf", count: snoozedCount, expanded: input.snoozedShelfExpanded === true, }); result.push(...threadItems.slice(snoozedShelfHeaderIndex, snoozedEnd)); } 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;} /** * Partitions visible threads into the active card block (saved order) and * the settled recency tail, matching the web v2 list. */export function buildThreadListV2Items(input: { readonly pendingOrder?: PendingThreadOrder | null; readonly threads: ReadonlyArray<EnvironmentThreadShell>; readonly environmentId: EnvironmentId | null; readonly projectRefs?: ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly projectId: ProjectId; }> | null; readonly searchQuery: string; readonly matchedThreadKeys?: ReadonlySet<string>; /** Environments whose server supports thread.settle/unsettle. Threads on other environments never classify as settled — the user could neither un-settle nor pin them. Absent = no gating (tests). */ readonly settlementEnvironmentIds?: ReadonlySet<EnvironmentId>; /** Environments whose server supports thread.snooze/unsnooze. Same contract as settlementEnvironmentIds. */ readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>; /** Max settled rows to render; the rest are counted, not built. */ readonly settledLimit?: number; /** Second-precise clock used for time-based classification. */ readonly now: 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; /** Thread keys (`environmentId:threadId`) with a message waiting in the outbox. Such a thread has work the user is waiting on, so it stays in the active block even when the server has settled it. */ readonly queuedThreadKeys?: ReadonlySet<string>;}): ThreadListV2Layout { const now = input.now; const pending = input.pendingOrder == null ? null : reconcilePendingThreadOrder( input.pendingOrder, getThreadListV2OrderedSection({ ...input, section: input.pendingOrder.section, pendingOrder: null, }), ); const query = input.searchQuery.trim().toLocaleLowerCase(); const projectKeys = input.projectRefs ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) : null; const pinned: EnvironmentThreadShell[] = []; const active: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; const snoozed: EnvironmentThreadShell[] = []; let nextSnoozeWakeAt: string | null = null; for (const thread of input.threads) { // Callers pass live shells. The server stamps settledOverride for the tail. if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue; if (projectKeys !== null && !projectKeys.has(`${thread.environmentId}:${thread.projectId}`)) { continue; } if ( query.length > 0 && !thread.title.toLocaleLowerCase().includes(query) && !threadPullRequestSearchTerms(thread).some((term) => term.toLocaleLowerCase().includes(query), ) && input.matchedThreadKeys?.has( threadSearchMatchKey({ environmentId: thread.environmentId, threadId: thread.id, }), ) !== true ) { continue; } const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true; const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true; // Snooze outranks settlement and pinning until the thread wakes. if (supportsSnooze && effectiveSnoozed(thread, { now })) { snoozed.push(thread); if ( thread.snoozedUntil != null && (nextSnoozeWakeAt === null || parseTimestampMs(thread.snoozedUntil) < parseTimestampMs(nextSnoozeWakeAt)) ) { nextSnoozeWakeAt = thread.snoozedUntil; } continue; } const hasQueuedMessages = input.queuedThreadKeys?.has(`${thread.environmentId}:${thread.id}`) === true; if (supportsSettlement && thread.settledOverride === "settled" && !hasQueuedMessages) { settled.push(thread); } else if (thread.pinnedAt != null) { pinned.push(thread); } else { active.push(thread); } } const orderedActive = applyPendingThreadOrder(sortThreadsForListV2(active), "active", pending); const orderedSnoozed = [...snoozed].sort( (left, right) => parseTimestampMs(left.snoozedUntil ?? "") - parseTimestampMs(right.snoozedUntil ?? ""), ); const selectedThreadKey = input.selectedThreadKey ?? null; const visibleSnoozed = input.snoozedShelfExpanded === true ? orderedSnoozed : orderedSnoozed.filter( (thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey, ); const orderedSettled = [...settled].sort( (left, right) => parseTimestampMs(resolveSettledThreadTimestamp(right) ?? "") - parseTimestampMs(resolveSettledThreadTimestamp(left) ?? ""), ); const settledLimit = input.settledLimit ?? Number.POSITIVE_INFINITY; 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 applyPendingThreadOrder( sortPinnedThreadsByOrderKey(pinned), "pinned", pending, )) { items.push({ thread, variant: "card", snoozed: false, pinned: true, isLast: false, }); } for (const thread of orderedActive) { items.push({ thread, variant: "card", snoozed: false, pinned: false, isLast: false, }); } const snoozedShelfHeaderIndex = orderedSnoozed.length > 0 ? items.length : null; for (const thread of visibleSnoozed) { items.push({ thread, variant: "slim", snoozed: true,Lines 1–500 of 528. Load more
import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; import { effectiveSnoozed, hasQueuedTurnStart, QUEUED_TURN_START_GRACE_MS, resolveSnoozePresets, snoozeWakeLabel, } from "@t3tools/client-runtime/state/thread-settled"; import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { sortActiveThreadsByOrderKey, resolveSettledThreadTimestamp, sortPinnedThreadsByOrderKey, } from "@t3tools/client-runtime/state/thread-sort"; import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { applyPendingThreadOrder, reconcilePendingThreadOrder, type PendingThreadOrder, } from "./threadOrder"; export { snoozeWakeLabel }; /** * Thread List v2 model, ported from the web sidebar v2 * (apps/web/src/components/Sidebar.logic.ts + SidebarV2.tsx). * * Four visual states, three colors: color is reserved for "act now" * (approval), "in motion" (working), and "broken" (failed). Ready is the * unlabeled resting state. */ export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready"; export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze"; export function resolveThreadListV2SnoozeMenuSelection(input: { readonly event: string; readonly displayedPresets: ReadonlyArray<SnoozePreset>; readonly now: Date; }): | { readonly _tag: "selected"; readonly preset: SnoozePreset } | { readonly _tag: "expired" } | { readonly _tag: "not-snooze" } { if (!input.event.startsWith("snooze:")) return { _tag: "not-snooze" }; const currentPreset = resolveSnoozePresets(input.now).find( (candidate) => input.event === `snooze:${candidate.id}`, ); if (currentPreset) return { _tag: "selected", preset: currentPreset }; const displayedPreset = input.displayedPresets.find( (candidate) => input.event === `snooze:${candidate.id}`, ); if (displayedPreset && Date.parse(displayedPreset.snoozedUntil) > input.now.getTime()) { return { _tag: "selected", preset: displayedPreset }; } return { _tag: "expired" }; } export function resolveThreadListV2SwipeActions(input: { readonly variant: "card" | "slim"; readonly settlementSupported: boolean; readonly snoozeSupported: boolean; readonly snoozable: boolean; /** Row is on the snoozed shelf. */ readonly snoozed?: boolean; }): { readonly primary: Exclude<ThreadListV2SwipeAction, "snooze">; readonly secondary: "snooze" | null; } { if (input.snoozed === true) { return { primary: "unsnooze", secondary: null }; } const primary = input.settlementSupported ? input.variant === "slim" ? "unsettle" : "settle" : "archive"; return { primary, secondary: input.snoozeSupported && input.snoozable ? "snooze" : null, }; } /** * The point at which a queued-turn snooze guard expires on its own. Rows arm * a one-shot timer for this boundary so Snooze appears without waiting for an * unrelated render. User-blocked threads return null because only fresh * server data can make them snoozable. */ export function resolveThreadListV2SnoozeGateExpiryMs( thread: Pick< EnvironmentThreadShell, "hasPendingApprovals" | "hasPendingUserInput" | "latestUserMessageAt" | "latestTurn" | "session" >, options: { readonly now: string }, ): number | null { if (thread.hasPendingApprovals || thread.hasPendingUserInput) return null; if (!hasQueuedTurnStart(thread, options)) return null; const messageAtMs = Date.parse(thread.latestUserMessageAt ?? ""); if (Number.isNaN(messageAtMs)) return null; return messageAtMs + QUEUED_TURN_START_GRACE_MS; } // Settled-tail paging: recent history is the common lookup; the deep tail // stays behind an explicit Show more. Shared by the compact Home list and // the iPad sidebar so both page identically. export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10; export const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25; /** * The flat Thread List v2 is the default on every app variant; the Settings → * Legacy toggle opts a device back into the grouped legacy list. Preferences * persist as sparse patches, so `undefined` genuinely means "never chosen". * * `preferencesLoaded` guards the startup window: preferences load * asynchronously, and rendering one list before the stored choice arrives would * remount the whole thing a tick later. While loading, hold the default — that * is where every device without an explicit legacy opt-in lands anyway. */ export function resolveThreadListV2Enabled(input: { readonly legacyPreference: boolean | undefined; readonly preferencesLoaded: boolean; }): boolean { if (!input.preferencesLoaded) { return true; } return input.legacyPreference !== true; } export function resolveThreadListV2Status( thread: Pick<EnvironmentThreadShell, "hasPendingApprovals" | "hasPendingUserInput" | "session">, ): ThreadListV2Status { if (thread.hasPendingApprovals) { return "approval"; } if (thread.hasPendingUserInput) { return "input"; } if (thread.session?.status === "running" || thread.session?.status === "starting") { return "working"; } if (thread.session?.status === "error") { return "failed"; } return "ready"; } /** NaN-safe Date.parse for sort comparators: a malformed timestamp must not poison the whole ordering, so it sinks to the epoch instead. */ function parseTimestampMs(isoDate: string): number { const parsed = Date.parse(isoDate); return Number.isNaN(parsed) ? 0 : parsed; } /** The active order shared by web and native: new/reopened rows, then the saved arrangement. Activity does not move a thread. */ export function sortThreadsForListV2< T extends { readonly id: string; readonly createdAt: string; readonly unsettledAt?: string | null | undefined; readonly activeOrderKey?: string | null | undefined; readonly environmentId?: string | undefined; }, >(threads: readonly T[]): T[] { return sortActiveThreadsByOrderKey(threads); } /** Canonical card section for Move up/down, independent of search or scope. */ export function getThreadListV2OrderedSection(input: { readonly threads: readonly EnvironmentThreadShell[]; readonly section: "pinned" | "active"; readonly pendingOrder?: PendingThreadOrder | null; readonly now: string; readonly settlementEnvironmentIds?: ReadonlySet<EnvironmentId>; readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>; readonly queuedThreadKeys?: ReadonlySet<string>; }): EnvironmentThreadShell[] { const threads = input.threads.filter((thread) => { if (thread.archivedAt !== null) return false; if ( (input.settlementEnvironmentIds?.has(thread.environmentId) ?? true) && thread.settledOverride === "settled" && input.queuedThreadKeys?.has(`${thread.environmentId}:${thread.id}`) !== true ) { return false; } if ( (input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true) && effectiveSnoozed(thread, { now: input.now }) ) { return false; } return (thread.pinnedAt != null) === (input.section === "pinned"); }); const ordered = input.section === "pinned" ? sortPinnedThreadsByOrderKey(threads) : sortActiveThreadsByOrderKey(threads); const pending = input.pendingOrder?.section === input.section ? reconcilePendingThreadOrder(input.pendingOrder, ordered) : null; return applyPendingThreadOrder(ordered, input.section, pending); } export interface ThreadListV2Item { readonly thread: EnvironmentThreadShell; readonly variant: "card" | "slim"; /** Snoozed-shelf row: shows the wake countdown and offers Wake. */ readonly snoozed: boolean; /** Pinned-block row: renders the pin glyph and offers Unpin. */ readonly pinned: boolean; readonly isLast: boolean; } export interface ThreadListV2Layout { readonly items: ThreadListV2Item[]; /** Settled threads beyond the render limit (behind "Show more"). */ readonly hiddenSettledCount: number; /** Snoozed threads matching the current filters. */ readonly snoozedCount: number; /** Index in `items` where the Snoozed shelf header belongs. The header is still rendered when the shelf is collapsed and no snoozed rows exist. */ readonly snoozedShelfHeaderIndex: number | null; /** Total settled threads in scope, including rows hidden by collapse/paging. */ readonly settledCount: number; /** Index in `items` where the Settled shelf header belongs. */ readonly settledShelfHeaderIndex: number | null; /** Soonest wake time among snoozed threads, or null. Callers arm a timeout at this boundary so the list re-partitions the moment a snooze expires instead of on the next minute tick. */ readonly nextSnoozeWakeAt: string | null; } export interface ThreadListV2ThreadListItem { readonly type: "v2-thread"; readonly key: string; readonly item: ThreadListV2Item; /** Precomputed so recycled-list equality can see a minute-tick change. */ readonly snoozeWakeLabelText: string | undefined; } export interface ThreadListV2PendingListItem { readonly type: "v2-pending"; readonly key: string; readonly pendingTask: PendingNewTask; /** First queued row after the active block draws the PENDING divider. */ readonly showPendingDivider: boolean; } export interface ThreadListV2SnoozedShelfListItem { readonly type: "v2-snoozed-shelf"; readonly key: "v2-snoozed-shelf"; readonly count: number; readonly expanded: boolean; } export interface ThreadListV2SettledShelfListItem { readonly type: "v2-settled-shelf"; readonly key: "v2-settled-shelf"; readonly count: number; readonly expanded: boolean; } export type ThreadListV2ListItem = | ThreadListV2ThreadListItem | ThreadListV2PendingListItem | ThreadListV2SnoozedShelfListItem | ThreadListV2SettledShelfListItem; /** * Builds the shared mobile order: active → pending → snoozed shelf → settled. * Pending tasks are waiting rather than asking, and parked work remains * reachable without competing with either the inbox or settled history. */ export function buildThreadListV2ListItems(input: { readonly items: ReadonlyArray<ThreadListV2Item>; readonly pendingTasks: ReadonlyArray<PendingNewTask>; readonly snoozedCount?: number; readonly snoozedShelfExpanded?: boolean; readonly snoozedShelfHeaderIndex?: number | null; readonly settledCount?: number; readonly settledShelfExpanded?: boolean; readonly settledShelfHeaderIndex?: number | null; readonly snoozeLabelNow?: string; }): ThreadListV2ListItem[] { const threadItems = input.items.map((item): ThreadListV2ListItem => ({ type: "v2-thread", key: `v2-thread:${item.thread.environmentId}:${item.thread.id}`, item, snoozeWakeLabelText: item.snoozed && item.thread.snoozedUntil != null && input.snoozeLabelNow !== undefined ? snoozeWakeLabel(item.thread.snoozedUntil, { now: input.snoozeLabelNow }) : undefined, })); const pendingItems = input.pendingTasks.map((pendingTask, index): ThreadListV2ListItem => ({ type: "v2-pending", key: `v2-${pendingTask.key}`, pendingTask, showPendingDivider: index === 0, })); const snoozedCount = input.snoozedCount ?? 0; const snoozedShelfHeaderIndex = input.snoozedShelfHeaderIndex ?? null; const settledCount = input.settledCount ?? 0; const settledShelfHeaderIndex = input.settledShelfHeaderIndex ?? null; const activeEnd = snoozedShelfHeaderIndex ?? settledShelfHeaderIndex ?? threadItems.length; const snoozedEnd = settledShelfHeaderIndex ?? threadItems.length; const result: ThreadListV2ListItem[] = [...threadItems.slice(0, activeEnd), ...pendingItems]; if (snoozedShelfHeaderIndex !== null && snoozedCount > 0) { result.push({ type: "v2-snoozed-shelf", key: "v2-snoozed-shelf", count: snoozedCount, expanded: input.snoozedShelfExpanded === true, }); result.push(...threadItems.slice(snoozedShelfHeaderIndex, snoozedEnd)); } 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; } /** * Partitions visible threads into the active card block (saved order) and * the settled recency tail, matching the web v2 list. */ export function buildThreadListV2Items(input: { readonly pendingOrder?: PendingThreadOrder | null; readonly threads: ReadonlyArray<EnvironmentThreadShell>; readonly environmentId: EnvironmentId | null; readonly projectRefs?: ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly projectId: ProjectId; }> | null; readonly searchQuery: string; readonly matchedThreadKeys?: ReadonlySet<string>; /** Environments whose server supports thread.settle/unsettle. Threads on other environments never classify as settled — the user could neither un-settle nor pin them. Absent = no gating (tests). */ readonly settlementEnvironmentIds?: ReadonlySet<EnvironmentId>; /** Environments whose server supports thread.snooze/unsnooze. Same contract as settlementEnvironmentIds. */ readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>; /** Max settled rows to render; the rest are counted, not built. */ readonly settledLimit?: number; /** Second-precise clock used for time-based classification. */ readonly now: 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; /** Thread keys (`environmentId:threadId`) with a message waiting in the outbox. Such a thread has work the user is waiting on, so it stays in the active block even when the server has settled it. */ readonly queuedThreadKeys?: ReadonlySet<string>; }): ThreadListV2Layout { const now = input.now; const pending = input.pendingOrder == null ? null : reconcilePendingThreadOrder( input.pendingOrder, getThreadListV2OrderedSection({ ...input, section: input.pendingOrder.section, pendingOrder: null, }), ); const query = input.searchQuery.trim().toLocaleLowerCase(); const projectKeys = input.projectRefs ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) : null; const pinned: EnvironmentThreadShell[] = []; const active: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; const snoozed: EnvironmentThreadShell[] = []; let nextSnoozeWakeAt: string | null = null; for (const thread of input.threads) { // Callers pass live shells. The server stamps settledOverride for the tail. if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue; if (projectKeys !== null && !projectKeys.has(`${thread.environmentId}:${thread.projectId}`)) { continue; } if ( query.length > 0 && !thread.title.toLocaleLowerCase().includes(query) && !threadPullRequestSearchTerms(thread).some((term) => term.toLocaleLowerCase().includes(query), ) && input.matchedThreadKeys?.has( threadSearchMatchKey({ environmentId: thread.environmentId, threadId: thread.id, }), ) !== true ) { continue; } const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true; const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true; // Snooze outranks settlement and pinning until the thread wakes. if (supportsSnooze && effectiveSnoozed(thread, { now })) { snoozed.push(thread); if ( thread.snoozedUntil != null && (nextSnoozeWakeAt === null || parseTimestampMs(thread.snoozedUntil) < parseTimestampMs(nextSnoozeWakeAt)) ) { nextSnoozeWakeAt = thread.snoozedUntil; } continue; } const hasQueuedMessages = input.queuedThreadKeys?.has(`${thread.environmentId}:${thread.id}`) === true; if (supportsSettlement && thread.settledOverride === "settled" && !hasQueuedMessages) { settled.push(thread); } else if (thread.pinnedAt != null) { pinned.push(thread); } else { active.push(thread); } } const orderedActive = applyPendingThreadOrder(sortThreadsForListV2(active), "active", pending); const orderedSnoozed = [...snoozed].sort( (left, right) => parseTimestampMs(left.snoozedUntil ?? "") - parseTimestampMs(right.snoozedUntil ?? ""), ); const selectedThreadKey = input.selectedThreadKey ?? null; const visibleSnoozed = input.snoozedShelfExpanded === true ? orderedSnoozed : orderedSnoozed.filter( (thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey, ); const orderedSettled = [...settled].sort( (left, right) => parseTimestampMs(resolveSettledThreadTimestamp(right) ?? "") - parseTimestampMs(resolveSettledThreadTimestamp(left) ?? ""), ); const settledLimit = input.settledLimit ?? Number.POSITIVE_INFINITY; 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 applyPendingThreadOrder( sortPinnedThreadsByOrderKey(pinned), "pinned", pending, )) { items.push({ thread, variant: "card", snoozed: false, pinned: true, isLast: false, }); } for (const thread of orderedActive) { items.push({ thread, variant: "card", snoozed: false, pinned: false, isLast: false, }); } const snoozedShelfHeaderIndex = orderedSnoozed.length > 0 ? items.length : null; for (const thread of visibleSnoozed) { items.push({ thread, variant: "slim", snoozed: true,