1import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests";
2import {
3 canSnooze,
4 effectiveSnoozed,
5 hasQueuedTurnStart,
6 QUEUED_TURN_START_GRACE_MS,
7 resolveSnoozePresets,
8 snoozeWakeLabel,
9} from "@t3tools/client-runtime/state/thread-settled";
10import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled";
11import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
12import { resolveThreadProviderStack } from "@t3tools/client-runtime/state/models";
13import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
14import {
15 createInboxReturnTracker,
16 isThreadWorking,
17 sortInboxThreadsByReturn,
18 sortWorkingThreadsBySend,
19} from "@t3tools/client-runtime/state/thread-inbox";
20import {
21 sortActiveThreadsByOrderKey,
22 resolveSettledThreadTimestamp,
23 sortPinnedThreadsByOrderKey,
24 sortSettledThreads,
25} from "@t3tools/client-runtime/state/thread-sort";
26import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
27
28import type { ThreadListProvider } from "../../state/thread-list-environments";
29import type { ThreadMoveAvailability } from "./threadOrder";
30
31import { relativeTime } from "../../lib/time";
32import type { PendingNewTask } from "../../state/use-pending-new-tasks";
33
34import {
35 applyPendingThreadOrder,
36 reconcilePendingThreadOrder,
37 type PendingThreadOrder,
38} from "./threadOrder";
39
40export { snoozeWakeLabel };
41
42/** Working section beta: when this device saw each thread leave the Working
43 shelf. One instance for Home and the iPad sidebar, so both lists order the
44 inbox the same way and the order survives screens that unmount. */
45export const threadListInboxReturns = createInboxReturnTracker();
46
47/**
48 * Provider drivers for a row's trailing icon stack, back to front. Instances
49 * missing from the environment's config are skipped, and an unresolved
50 * current provider yields nothing so the row never draws a stale stack.
51 */
52export function resolveThreadListV2ProviderDrivers(
53 thread: Pick<EnvironmentThreadShell, "providerInstanceHistory" | "modelSelection" | "runtime">,
54 providers: ReadonlyArray<ThreadListProvider> | undefined,
55): ReadonlyArray<string> {
56 if (providers === undefined) return [];
57 const stack = resolveThreadProviderStack(thread);
58 const drivers = stack.flatMap((instanceId) => {
59 const driver = providers.find((provider) => provider.instanceId === instanceId)?.driver;
60 return driver === undefined ? [] : [driver];
61 });
62 const currentDriver = providers.find(
63 (provider) => provider.instanceId === stack[stack.length - 1],
64 )?.driver;
65 return currentDriver === undefined ? [] : drivers;
66}
67
68/**
69 * Thread List v2 model, ported from the web sidebar v2
70 * (apps/web/src/components/Sidebar.logic.ts + SidebarV2.tsx).
71 *
72 * Six visual states. Color distinguishes approval, input, active work, and
73 * failures. Ready is the unlabeled resting state; waiting (runtime status "idle") is the agent
74 * parked on open background tasks, grey like working rather than a false Done.
75 * The orchestrator v2 presentation bridge parks runtime at idle when the
76 * post-settlement background roster holds the run's completion (subagents,
77 * monitors); commands left running, such as a dev server, read as ready.
78 */
79export type ThreadListV2Status =
80 | "approval"
81 | "input"
82 | "working"
83 | "waiting"
84 | "failed"
85 | "limited"
86 | "ready";
87export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze";
88
89export function resolveThreadListV2SnoozeMenuSelection(input: {
90 readonly event: string;
91 readonly displayedPresets: ReadonlyArray<SnoozePreset>;
92 readonly now: Date;
93}):
94 | { readonly _tag: "selected"; readonly preset: SnoozePreset }
95 | { readonly _tag: "expired" }
96 | { readonly _tag: "not-snooze" } {
97 if (!input.event.startsWith("snooze:")) return { _tag: "not-snooze" };
98
99 const currentPreset = resolveSnoozePresets(input.now).find(
100 (candidate) => input.event === `snooze:${candidate.id}`,
101 );
102 if (currentPreset) return { _tag: "selected", preset: currentPreset };
103
104 const displayedPreset = input.displayedPresets.find(
105 (candidate) => input.event === `snooze:${candidate.id}`,
106 );
107 if (displayedPreset && Date.parse(displayedPreset.snoozedUntil) > input.now.getTime()) {
108 return { _tag: "selected", preset: displayedPreset };
109 }
110 return { _tag: "expired" };
111}
112
113export function resolveThreadListV2SwipeActions(input: {
114 readonly variant: "card" | "slim";
115 readonly settlementSupported: boolean;
116 readonly snoozeSupported: boolean;
117 readonly snoozable: boolean;
118 /** Row is on the snoozed shelf. */
119 readonly snoozed?: boolean;
120}): {
121 readonly primary: Exclude<ThreadListV2SwipeAction, "snooze">;
122 readonly secondary: "snooze" | null;
123} {
124 if (input.snoozed === true) {
125 return { primary: "unsnooze", secondary: null };
126 }
127 const primary = input.settlementSupported
128 ? input.variant === "slim"
129 ? "unsettle"
130 : "settle"
131 : "archive";
132 return {
133 primary,
134 secondary: input.snoozeSupported && input.snoozable ? "snooze" : null,
135 };
136}
137
138/**
139 * The point at which a queued-turn snooze guard expires on its own. Rows arm
140 * a one-shot timer for this boundary so Snooze appears without waiting for an
141 * unrelated render. User-blocked threads return null because only fresh
142 * server data can make them snoozable.
143 */
144export function resolveThreadListV2SnoozeGateExpiryMs(
145 thread: Pick<
146 EnvironmentThreadShell,
147 | "hasPendingApprovals"
148 | "hasPendingUserInput"
149 | "pendingSubagentRequest"
150 | "latestRun"
151 | "latestUserMessageAt"
152 | "runtime"
153 >,
154 options: { readonly now: string },
155): number | null {
156 if (thread.hasPendingApprovals || thread.hasPendingUserInput) return null;
157 if (thread.pendingSubagentRequest != null) return null;
158 if (!hasQueuedTurnStart(thread, options)) return null;
159 const messageAtMs = Date.parse(thread.latestUserMessageAt ?? "");
160 if (Number.isNaN(messageAtMs)) return null;
161 return messageAtMs + QUEUED_TURN_START_GRACE_MS;
162}
163
164// Settled-tail paging: recent history is the common lookup; the deep tail
165// stays behind an explicit Show more. Shared by the compact Home list and
166// the iPad sidebar so both page identically.
167export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10;
168export const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25;
169
170/**
171 * Completed-but-not-yet-seen, mirroring the web sidebar's
172 * hasUnseenCompletion. The visited watermark is server state
173 * (thread.lastVisitedAt), so the marker agrees across web and mobile.
174 * Never-visited threads count as read — a fresh environment must not light
175 * up its whole history — and pre-tracking servers (field absent) never
176 * report unread.
177 */
178export function threadHasUnseenCompletion(
179 thread: Pick<EnvironmentThreadShell, "latestRun" | "lastVisitedAt">,
180): boolean {
181 const completedAt = thread.latestRun?.completedAt;
182 if (!completedAt) return false;
183 const completedAtMs = Date.parse(completedAt);
184 if (Number.isNaN(completedAtMs)) return false;
185 if (!thread.lastVisitedAt) return false;
186 const lastVisitedAtMs = Date.parse(thread.lastVisitedAt);
187 if (Number.isNaN(lastVisitedAtMs)) return true;
188 return completedAtMs > lastVisitedAtMs;
189}
190
191export function resolveThreadListV2Status(
192 thread: Pick<
193 EnvironmentThreadShell,
194 "hasPendingApprovals" | "hasPendingUserInput" | "pendingSubagentRequest" | "runtime"
195 >,
196): ThreadListV2Status {
197 if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") {
198 return "approval";
199 }
200 if (thread.hasPendingUserInput || thread.pendingSubagentRequest === "user_input") {
201 return "input";
202 }
203 if (
204 thread.runtime !== null &&
205 ["preparing", "queued", "starting", "running", "waiting"].includes(thread.runtime.status)
206 ) {
207 return "working";
208 }
209 if (thread.runtime?.status === "idle") {
210 return "waiting";
211 }
212 if (thread.runtime?.status === "failed") {
213 return thread.runtime.lastErrorClass === "usage_limit" ? "limited" : "failed";
214 }
215 return "ready";
216}
217
218/** NaN-safe Date.parse for sort comparators: a malformed timestamp must not
219 poison the whole ordering, so it sinks to the epoch instead. */
220function parseTimestampMs(isoDate: string): number {
221 const parsed = Date.parse(isoDate);
222 return Number.isNaN(parsed) ? 0 : parsed;
223}
224
225/** The active order shared by web and native: new/reopened rows, then the
226 saved arrangement. Activity does not move a thread. */
227export function sortThreadsForListV2<
228 T extends {
229 readonly id: string;
230 readonly createdAt: string;
231 readonly unsettledAt?: string | null | undefined;
232 readonly activeOrderKey?: string | null | undefined;
233 readonly environmentId?: string | undefined;
234 },
235>(threads: readonly T[]): T[] {
236 return sortActiveThreadsByOrderKey(threads);
237}
238
239/** Canonical card section for Move up/down, independent of search or scope. */
240export function getThreadListV2OrderedSection(input: {
241 readonly threads: readonly EnvironmentThreadShell[];
242 readonly section: "pinned" | "active";
243 readonly pendingOrder?: PendingThreadOrder | null;
244 readonly now: string;
245 readonly settlementEnvironmentIds?: ReadonlySet<EnvironmentId>;
246 readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;
247 readonly queuedThreadKeys?: ReadonlySet<string>;
248}): EnvironmentThreadShell[] {
249 // An empty set is treated as absent so `?.` skips building the key.
250 const queuedThreadKeys = input.queuedThreadKeys?.size ? input.queuedThreadKeys : undefined;
251 const threads = input.threads.filter((thread) => {
252 if (thread.archivedAt !== null || thread.lineage.relationshipToParent === "subagent")
253 return false;
254 if (
255 (input.settlementEnvironmentIds?.has(thread.environmentId) ?? true) &&
256 thread.settledOverride === "settled" &&
257 queuedThreadKeys?.has(`${thread.environmentId}:${thread.id}`) !== true
258 ) {
259 return false;
260 }
261 if (
262 (input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true) &&
263 effectiveSnoozed(thread, { now: input.now })
264 ) {
265 return false;
266 }
267 return (thread.pinnedAt != null) === (input.section === "pinned");
268 });
269 const ordered =
270 input.section === "pinned"
271 ? sortPinnedThreadsByOrderKey(threads)
272 : sortActiveThreadsByOrderKey(threads);
273 const pending =
274 input.pendingOrder?.section === input.section
275 ? reconcilePendingThreadOrder(input.pendingOrder, ordered)
276 : null;
277 return applyPendingThreadOrder(ordered, input.section, pending);
278}
279
280export interface ThreadListV2Item {
281 readonly thread: EnvironmentThreadShell;
282 readonly variant: "card" | "slim";
283 /** Snoozed-shelf row: shows the wake countdown and offers Wake. */
284 readonly snoozed: boolean;
285 /** Pinned-block row: renders the pin glyph and offers Unpin. */
286 readonly pinned: boolean;
287 readonly isLast: boolean;
288}
289
290export interface ThreadListV2Layout {
291 readonly items: ThreadListV2Item[];
292 /** Settled threads beyond the render limit (behind "Show more"). */
293 readonly hiddenSettledCount: number;
294 /** Working threads folded away by the Working section beta. */
295 readonly workingCount: number;
296 /** Index in `items` where the Working shelf header belongs. */
297 readonly workingShelfHeaderIndex: number | null;
298 /** Snoozed threads matching the current filters. */
299 readonly snoozedCount: number;
300 /** Index in `items` where the Snoozed shelf header belongs. The header is
301 still rendered when the shelf is collapsed and no snoozed rows exist. */
302 readonly snoozedShelfHeaderIndex: number | null;
303 /** Total settled threads in scope, including rows hidden by collapse/paging. */
304 readonly settledCount: number;
305 /** Index in `items` where the Settled shelf header belongs. */
306 readonly settledShelfHeaderIndex: number | null;
307 /** Soonest wake time among snoozed threads, or null. Callers arm
308 a timeout at this boundary so the list re-partitions the moment a
309 snooze expires instead of on the next minute tick. */
310 readonly nextSnoozeWakeAt: string | null;
311}
312
313export interface ThreadListV2ThreadListItem {
314 readonly type: "v2-thread";
315 readonly key: string;
316 readonly item: ThreadListV2Item;
317 /** Precomputed so recycled-list equality can see a minute-tick change. */
318 readonly snoozeWakeLabelText: string | undefined;
319 /** Row timestamp precomputed against the parent clock so the recycler's
320 equality only sees a change on rows that actually draw a time. Blank
321 while the row renders a status label or the wake countdown instead. */
322 readonly timeLabel: string;
323 /** Minute clock feeding the row's native snooze menu, carried only on rows
324 whose menu holds snooze presets (capability-gated cards the user may
325 snooze). Keeping it out of the list's `extraData` means the minute tick
326 re-renders just these rows instead of every visible one. */
327 readonly snoozePresetMinute: string | undefined;
328 /** Inset hairline drawn under the row. Precomputed from the final order so
329 a neighbour change (e.g. the queued block appearing) updates the row
330 through recycled-list equality instead of leaving a stale divider. */
331 readonly showTrailingDivider: boolean;
332 /** A message for this thread is waiting in the outbox. Carried on the item
333 so an outbox write (which never touches the thread shell) reaches the
334 row through recycled-list equality instead of leaving a stale icon. */
335 readonly hasQueuedMessages: boolean;
336 /** Move up/down availability in the row's card section. Carried on the item
337 for the same reason: a reorder in flight (or its commit) changes menu
338 availability without changing any shell. */
339 readonly canMoveUp: boolean;
340 readonly canMoveDown: boolean;
341}
342
343export interface ThreadListV2PendingListItem {
344 readonly type: "v2-pending";
345 readonly key: string;
346 readonly pendingTask: PendingNewTask;
347 /** First queued row after the active block draws the PENDING divider. */
348 readonly showPendingDivider: boolean;
349 /** Same rule as the thread rows: a hairline unless the next row carries its
350 own section rule or none follows. */
351 readonly showTrailingDivider: boolean;
352}
353
354export interface ThreadListV2WorkingShelfListItem {
355 readonly type: "v2-working-shelf";
356 readonly key: "v2-working-shelf";
357 readonly count: number;
358 readonly expanded: boolean;
359 /** See the snoozed shelf header's field. */
360 readonly disabled: boolean;
361}
362
363export interface ThreadListV2SnoozedShelfListItem {
364 readonly type: "v2-snoozed-shelf";
365 readonly key: "v2-snoozed-shelf";
366 readonly count: number;
367 readonly expanded: boolean;
368 /** Shelf preferences still loading: the toggle is disabled until they
369 arrive. Carried on the item because a recycled cell ignores the render
370 closure — without it the header would stay visibly disabled (or enabled
371 too early) after the preference load lands. */
372 readonly disabled: boolean;
373}
374
375export interface ThreadListV2SettledShelfListItem {
376 readonly type: "v2-settled-shelf";
377 readonly key: "v2-settled-shelf";
378 readonly count: number;
379 readonly expanded: boolean;
380 /** See the snoozed shelf header's field. */
381 readonly disabled: boolean;
382}
383
384export type ThreadListV2ListItem =
385 | ThreadListV2ThreadListItem
386 | ThreadListV2PendingListItem
387 | ThreadListV2WorkingShelfListItem
388 | ThreadListV2SnoozedShelfListItem
389 | ThreadListV2SettledShelfListItem;
390
391/** Narrows a wider list-item union (e.g. the sidebar's legacy + v2 mix) to
392 the v2 item kinds the shared equality understands. */
393export function isThreadListV2ListItem(value: {
394 readonly type: string;
395}): value is ThreadListV2ListItem {
396 return (
397 value.type === "v2-thread" ||
398 value.type === "v2-pending" ||
399 value.type === "v2-working-shelf" ||
400 value.type === "v2-snoozed-shelf" ||
401 value.type === "v2-settled-shelf"
402 );
403}
404
405/** Recycled-list equality for the flat v2 list (Home + iPad sidebar).
406 Item objects are rebuilt on every minute tick and every partition run;
407 without this the lists would consider every mounted row changed and
408 re-render all of them (each carrying a swipeable + a PR subscription).
409 The clock-derived fields are per-row precomputed text, so a minute tick
410 only flips equality on rows whose visible text (or snooze menu) actually
411 moved. Thread references are stable across rebuilds. */
412export function threadListV2ListItemsAreEqual(
413 previous: ThreadListV2ListItem,
414 item: ThreadListV2ListItem,
415): boolean {
416 switch (item.type) {
417 case "v2-thread":
418 return (
419 previous.type === "v2-thread" &&
420 previous.key === item.key &&
421 previous.item.thread === item.item.thread &&
422 previous.item.variant === item.item.variant &&
423 previous.item.snoozed === item.item.snoozed &&
424 previous.item.pinned === item.item.pinned &&
425 previous.snoozeWakeLabelText === item.snoozeWakeLabelText &&
426 previous.timeLabel === item.timeLabel &&
427 previous.snoozePresetMinute === item.snoozePresetMinute &&
428 previous.showTrailingDivider === item.showTrailingDivider &&
429 previous.hasQueuedMessages === item.hasQueuedMessages &&
430 previous.canMoveUp === item.canMoveUp &&
431 previous.canMoveDown === item.canMoveDown
432 );
433 case "v2-pending":
434 return (
435 previous.type === "v2-pending" &&
436 previous.key === item.key &&
437 previous.pendingTask === item.pendingTask &&
438 previous.showPendingDivider === item.showPendingDivider &&
439 previous.showTrailingDivider === item.showTrailingDivider
440 );
441 case "v2-working-shelf":
442 return (
443 previous.type === "v2-working-shelf" &&
444 previous.count === item.count &&
445 previous.expanded === item.expanded &&
446 previous.disabled === item.disabled
447 );
448 case "v2-snoozed-shelf":
449 return (
450 previous.type === "v2-snoozed-shelf" &&
451 previous.count === item.count &&
452 previous.expanded === item.expanded &&
453 previous.disabled === item.disabled
454 );
455 case "v2-settled-shelf":
456 return (
457 previous.type === "v2-settled-shelf" &&
458 previous.count === item.count &&
459 previous.expanded === item.expanded &&
460 previous.disabled === item.disabled
461 );
462 }
463}
464
465/** The timestamp a row renders when it shows no status label: the settle
466 stamp on settled slim rows, otherwise the latest activity. Blank for
467 status-labelled cards and snoozed rows with a wake countdown — those
468 never draw a time, so their minute tick must not invalidate the cell. */
469function resolveThreadListV2ItemTimeLabel(
470 item: ThreadListV2Item,
471 showSnoozeWakeLabel: boolean,
472): string {
473 const { thread, variant, snoozed } = item;
474 if (showSnoozeWakeLabel) return "";
475 if (
476 variant === "card" &&
477 (resolveThreadListV2Status(thread) !== "ready" || threadHasUnseenCompletion(thread))
478 )
479 return "";
480 const settledTimestamp =
481 variant === "slim" && !snoozed ? resolveSettledThreadTimestamp(thread) : null;
482 return relativeTime(
483 settledTimestamp ?? thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt,
484 );
485}
486
487/**
488 * Builds the shared mobile order: active → pending → working shelf (beta) →
489 * snoozed shelf → settled. Pending tasks are waiting rather than asking, and
490 * busy or parked work remains reachable without competing with either the
491 * inbox or settled history.
492 */
493export function buildThreadListV2ListItems(input: {
494 readonly items: ReadonlyArray<ThreadListV2Item>;
495 readonly pendingTasks: ReadonlyArray<PendingNewTask>;
496 readonly workingCount?: number;
497 readonly workingShelfExpanded?: boolean;
498 readonly workingShelfHeaderIndex?: number | null;
499 readonly snoozedCount?: number;
500 readonly snoozedShelfExpanded?: boolean;
501 readonly snoozedShelfHeaderIndex?: number | null;
502 readonly settledCount?: number;
503 readonly settledShelfExpanded?: boolean;
504 readonly settledShelfHeaderIndex?: number | null;
505 readonly snoozeLabelNow?: string;
506 /** Environments whose server supports thread.snooze. Rows on other
507 environments never carry the minute clock that feeds the snooze menu.
508 Absent = no gating (tests). */
509 readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;
510 /** Thread keys (`environmentId:threadId`) with a message waiting in the
511 outbox; stamped onto the matching rows as `hasQueuedMessages`. */
512 readonly queuedThreadKeys?: ReadonlySet<string>;
513 /** Menu availability for Move up/down, keyed by `environmentId:threadId`
514 (only consulted for card rows — slim menus omit the moves). Produced by
515 `computeThreadMoveAvailability` in one pass per section; a recycled cell
516 ignores the render closure, so the stamps ride on the item. Absent =
517 never available (tests). */
518 readonly moveAvailability?: ReadonlyMap<string, ThreadMoveAvailability>;
519 /** True while the shelf expansion preferences are still loading; stamped
520 onto both shelf headers so the disabled state reaches recycled cells. */
521 readonly shelfPreferencesLoading?: boolean;
522}): ThreadListV2ListItem[] {
523 // An empty set is treated as absent so `?.` skips building the key.
524 const queuedThreadKeys = input.queuedThreadKeys?.size ? input.queuedThreadKeys : undefined;
525 const threadItems = input.items.map((item): ThreadListV2ListItem => {
526 const snoozeWakeLabelText =
527 item.snoozed && item.thread.snoozedUntil != null && input.snoozeLabelNow !== undefined
528 ? snoozeWakeLabel(item.thread.snoozedUntil, { now: input.snoozeLabelNow })
529 : undefined;
530 // The minute clock belongs on the item, not the list's extraData, so the
531 // recycler's equality can confine the per-minute re-render to rows whose
532 // snooze menu actually shows preset times. The swipe-revealed snooze menu
533 // exists on slim rows too (the variant only swaps the primary action),
534 // so the gate follows actual snooze availability, not the variant.
535 const snoozePresetMinute =
536 !item.snoozed &&
537 input.snoozeLabelNow !== undefined &&
538 (input.snoozeEnvironmentIds?.has(item.thread.environmentId) ?? true) &&
539 canSnooze(item.thread, { now: input.snoozeLabelNow })
540 ? input.snoozeLabelNow
541 : undefined;
542 const move =
543 item.variant === "card"
544 ? input.moveAvailability?.get(`${item.thread.environmentId}:${item.thread.id}`)
545 : undefined;
546 return {
547 type: "v2-thread",
548 key: `v2-thread:${item.thread.environmentId}:${item.thread.id}`,
549 item,
550 snoozeWakeLabelText,
551 timeLabel: resolveThreadListV2ItemTimeLabel(item, snoozeWakeLabelText !== undefined),
552 snoozePresetMinute,
553 showTrailingDivider: false,
554 hasQueuedMessages:
555 queuedThreadKeys?.has(`${item.thread.environmentId}:${item.thread.id}`) === true,
556 canMoveUp: move?.canMoveUp === true,
557 canMoveDown: move?.canMoveDown === true,
558 };
559 });
560 const pendingItems = input.pendingTasks.map((pendingTask, index): ThreadListV2ListItem => ({
561 type: "v2-pending",
562 key: `v2-${pendingTask.key}`,
563 pendingTask,
564 showPendingDivider: index === 0,
565 showTrailingDivider: false,
566 }));
567 const workingCount = input.workingCount ?? 0;
568 const workingShelfHeaderIndex = input.workingShelfHeaderIndex ?? null;
569 const snoozedCount = input.snoozedCount ?? 0;
570 const snoozedShelfHeaderIndex = input.snoozedShelfHeaderIndex ?? null;
571 const settledCount = input.settledCount ?? 0;
572 const settledShelfHeaderIndex = input.settledShelfHeaderIndex ?? null;
573 const snoozedEnd = settledShelfHeaderIndex ?? threadItems.length;
574 const workingEnd = snoozedShelfHeaderIndex ?? snoozedEnd;
575 const activeEnd = workingShelfHeaderIndex ?? workingEnd;
576 const result: ThreadListV2ListItem[] = [...threadItems.slice(0, activeEnd), ...pendingItems];
577 const shelfDisabled = input.shelfPreferencesLoading === true;
578 if (workingShelfHeaderIndex !== null && workingCount > 0) {
579 result.push({
580 type: "v2-working-shelf",
581 key: "v2-working-shelf",
582 count: workingCount,
583 expanded: input.workingShelfExpanded === true,
584 disabled: shelfDisabled,
585 });
586 result.push(...threadItems.slice(workingShelfHeaderIndex, workingEnd));
587 }
588 if (snoozedShelfHeaderIndex !== null && snoozedCount > 0) {
589 result.push({
590 type: "v2-snoozed-shelf",
591 key: "v2-snoozed-shelf",
592 count: snoozedCount,
593 expanded: input.snoozedShelfExpanded === true,
594 disabled: shelfDisabled,
595 });
596 result.push(...threadItems.slice(snoozedShelfHeaderIndex, snoozedEnd));
597 }
598 if (settledShelfHeaderIndex !== null && settledCount > 0) {
599 result.push({
600 type: "v2-settled-shelf",
601 key: "v2-settled-shelf",
602 count: settledCount,
603 expanded: input.settledShelfExpanded !== false,
604 disabled: shelfDisabled,
605 });
606 result.push(...threadItems.slice(settledShelfHeaderIndex));
607 }
608 // Hairlines depend on the final neighbour, so they are stamped after the
609 // splice: a recycled cell only re-renders when its divider actually flips.
610 return result.map((entry, index) => {
611 if (entry.type !== "v2-thread" && entry.type !== "v2-pending") return entry;
612 const next = result[index + 1];
613 const showTrailingDivider =
614 next?.type === "v2-thread" || (next?.type === "v2-pending" && !next.showPendingDivider);
615 return showTrailingDivider === entry.showTrailingDivider
616 ? entry
617 : { ...entry, showTrailingDivider };
618 });
619}
620
621// Streams update a few unsettled rows, so most rebuilds classify the same
622// settled shells in the same order. Shells are immutable and the settled sort
623// reads only their own fields, so an element-identical input has an identical
624// output. One entry keeps memory bounded; callers with different scopes (Home
625// and the split-view sidebar) just recompute.
626let lastSettledInput: ReadonlyArray<EnvironmentThreadShell> = [];
627let lastSettledOutput: ReadonlyArray<EnvironmentThreadShell> = [];
628
629function sortSettledThreadsReusingLast(
630 settled: ReadonlyArray<EnvironmentThreadShell>,
631): ReadonlyArray<EnvironmentThreadShell> {
632 if (settled.length === lastSettledInput.length) {
633 let same = true;
634 for (let index = 0; index < settled.length; index += 1) {
635 if (settled[index] !== lastSettledInput[index]) {
636 same = false;
637 break;
638 }
639 }
640 if (same) return lastSettledOutput;
641 }
642 lastSettledInput = settled;
643 lastSettledOutput = sortSettledThreads(settled);
644 return lastSettledOutput;
645}
646
647/**
648 * Partitions visible threads into the active card block (saved order) and
649 * the settled recency tail, matching the web v2 list.
650 */
651export function buildThreadListV2Items(input: {
652 readonly pendingOrder?: PendingThreadOrder | null;
653 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
654 readonly environmentId: EnvironmentId | null;
655 readonly projectRefs?: ReadonlyArray<{
656 readonly environmentId: EnvironmentId;
657 readonly projectId: ProjectId;
658 }> | null;
659 readonly searchQuery: string;
660 readonly matchedThreadKeys?: ReadonlySet<string>;
661 /** Environments whose server supports thread.settle/unsettle. Threads on
662 other environments never classify as settled — the user could neither
663 un-settle nor pin them. Absent = no gating (tests). */
664 readonly settlementEnvironmentIds?: ReadonlySet<EnvironmentId>;
665 /** Environments whose server supports thread.snooze/unsnooze. Same
666 contract as settlementEnvironmentIds. */
667 readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;
668 /** Max settled rows to render; the rest are counted, not built. */
669 readonly settledLimit?: number;
670 /** Second-precise clock used for time-based classification. */
671 readonly now: string;
672 /** Working section beta: unpinned working threads fold into the Working
673 shelf, and the inbox orders by when each thread came back to the user
674 instead of the saved arrangement. */
675 readonly workingShelfEnabled?: boolean;
676 /** Expands the Working shelf into rows. Collapsed is the default. */
677 readonly workingShelfExpanded?: boolean;
678 /** Returns this device observed but the server does not stamp, such as an
679 approval request mid-turn. Only read while the beta is on. */
680 readonly inboxReturnAt?: (thread: EnvironmentThreadShell) => number | undefined;
681 /** Expands the snoozed shelf into rows. Collapsed is the default. */
682 readonly snoozedShelfExpanded?: boolean;
683 /** Expands the settled shelf into rows. Expanded is the default. */
684 readonly settledShelfExpanded?: boolean;
685 /** The selected thread remains visible on an otherwise collapsed shelf so
686 a split-view detail can never lose its navigation row. */
687 readonly selectedThreadKey?: string | null;
688 /** Thread keys (`environmentId:threadId`) with a message waiting in the
689 outbox. Such a thread has work the user is waiting on, so it stays in
690 the active block even when the server has settled it. */
691 readonly queuedThreadKeys?: ReadonlySet<string>;
692}): ThreadListV2Layout {
693 const now = input.now;
694 const pending =
695 input.pendingOrder == null
696 ? null
697 : reconcilePendingThreadOrder(
698 input.pendingOrder,
699 getThreadListV2OrderedSection({
700 ...input,
701 section: input.pendingOrder.section,
702 pendingOrder: null,
703 }),
704 );
705 const query = input.searchQuery.trim().toLocaleLowerCase();
706 const projectKeys = input.projectRefs
707 ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))
708 : null;
709
710 const workingShelfEnabled = input.workingShelfEnabled === true;
711 const pinned: EnvironmentThreadShell[] = [];
712 const active: EnvironmentThreadShell[] = [];
713 const working: EnvironmentThreadShell[] = [];
714 const settled: EnvironmentThreadShell[] = [];
715 const snoozed: EnvironmentThreadShell[] = [];
716 let nextSnoozeWakeAt: string | null = null;
717 // An empty set is treated as absent so `?.` skips building the key.
718 const queuedThreadKeys = input.queuedThreadKeys?.size ? input.queuedThreadKeys : undefined;
719 for (const thread of input.threads) {
720 if (thread.archivedAt !== null || thread.lineage.relationshipToParent === "subagent") continue;
721 // The server stamps settledOverride for the tail.
722 if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue;
723 if (projectKeys !== null && !projectKeys.has(`${thread.environmentId}:${thread.projectId}`)) {
724 continue;
725 }
726 if (
727 query.length > 0 &&
728 !thread.title.toLocaleLowerCase().includes(query) &&
729 !threadPullRequestSearchTerms(thread).some((term) =>
730 term.toLocaleLowerCase().includes(query),
731 ) &&
732 input.matchedThreadKeys?.has(
733 threadSearchMatchKey({
734 environmentId: thread.environmentId,
735 threadId: thread.id,
736 }),
737 ) !== true
738 ) {
739 continue;
740 }
741 const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true;
742 const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true;
743 // Snooze outranks settlement and pinning until the thread wakes.
744 if (supportsSnooze && effectiveSnoozed(thread, { now })) {
745 snoozed.push(thread);
746 if (
747 thread.snoozedUntil != null &&
748 (nextSnoozeWakeAt === null ||
749 parseTimestampMs(thread.snoozedUntil) < parseTimestampMs(nextSnoozeWakeAt))
750 ) {
751 nextSnoozeWakeAt = thread.snoozedUntil;
752 }
753 continue;
754 }
755 const hasQueuedMessages =
756 queuedThreadKeys?.has(`${thread.environmentId}:${thread.id}`) === true;
757 if (supportsSettlement && thread.settledOverride === "settled" && !hasQueuedMessages) {
758 settled.push(thread);
759 } else if (thread.pinnedAt != null) {
760 pinned.push(thread);
761 } else if (workingShelfEnabled && isThreadWorking(thread)) {
762 working.push(thread);
763 } else {
764 active.push(thread);
765 }
766 }
767
768 // The beta inbox is time-ordered, so the saved arrangement (and any move in
769 // flight) is kept but not applied until the beta is off again.
770 const orderedActive = workingShelfEnabled
771 ? sortInboxThreadsByReturn(active, input.inboxReturnAt)
772 : applyPendingThreadOrder(sortThreadsForListV2(active), "active", pending);
773 // Newest send first; finishing and waking again do not move a row.
774 const orderedWorking = sortWorkingThreadsBySend(working);
775 const orderedSnoozed = [...snoozed].sort(
776 (left, right) =>
777 parseTimestampMs(left.snoozedUntil ?? "") - parseTimestampMs(right.snoozedUntil ?? ""),
778 );
779 const selectedThreadKey = input.selectedThreadKey ?? null;
780 const visibleWorking =
781 input.workingShelfExpanded === true
782 ? orderedWorking
783 : orderedWorking.filter(
784 (thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey,
785 );
786 const visibleSnoozed =
787 input.snoozedShelfExpanded === true
788 ? orderedSnoozed
789 : orderedSnoozed.filter(
790 (thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey,
791 );
792 const orderedSettled = sortSettledThreadsReusingLast(settled);
793 const settledLimit = input.settledLimit ?? Number.POSITIVE_INFINITY;
794 const limitedSettled =
795 orderedSettled.length > settledLimit ? orderedSettled.slice(0, settledLimit) : orderedSettled;
796 const selectedSettled =
797 selectedThreadKey === null
798 ? undefined
799 : orderedSettled
800 .slice(limitedSettled.length)
801 .find((thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey);
802 const pagedSettled =
803 selectedSettled === undefined ? limitedSettled : [...limitedSettled, selectedSettled];
804 const visibleSettled =
805 input.settledShelfExpanded !== false
806 ? pagedSettled
807 : pagedSettled.filter(
808 (thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey,
809 );
810
811 const items: ThreadListV2Item[] = [];
812 for (const thread of applyPendingThreadOrder(
813 sortPinnedThreadsByOrderKey(pinned),
814 "pinned",
815 pending,
816 )) {
817 items.push({
818 thread,
819 variant: "card",
820 snoozed: false,
821 pinned: true,
822 isLast: false,
823 });
824 }
825 for (const thread of orderedActive) {
826 items.push({
827 thread,
828 variant: "card",
829 snoozed: false,
830 pinned: false,
831 isLast: false,
832 });
833 }
834 const workingShelfHeaderIndex = orderedWorking.length > 0 ? items.length : null;
835 for (const thread of visibleWorking) {
836 items.push({
837 thread,
838 variant: "card",
839 snoozed: false,
840 pinned: false,
841 isLast: false,
842 });
843 }
844 const snoozedShelfHeaderIndex = orderedSnoozed.length > 0 ? items.length : null;
845 for (const thread of visibleSnoozed) {
846 items.push({
847 thread,
848 variant: "slim",
849 snoozed: true,
850 pinned: false,
851 isLast: false,
852 });
853 }
854 const settledShelfHeaderIndex = orderedSettled.length > 0 ? items.length : null;
855 for (const thread of visibleSettled) {
856 items.push({
857 thread,
858 variant: "slim",
859 snoozed: false,
860 pinned: false,
861 isLast: false,
862 });
863 }
864 const last = items.at(-1);
865 if (last) {
866 items[items.length - 1] = { ...last, isLast: true };
867 }
868 return {
869 items,
870 hiddenSettledCount: orderedSettled.length - pagedSettled.length,
871 workingCount: orderedWorking.length,
872 workingShelfHeaderIndex,
873 snoozedCount: orderedSnoozed.length,
874 snoozedShelfHeaderIndex,
875 settledCount: orderedSettled.length,
876 settledShelfHeaderIndex,
877 nextSnoozeWakeAt,
878 };
879}