Skip to content
Source
Sign inSign up

threadListV2.ts

923 lines32.7 KB

1import {
2 canSnooze,
3 effectiveSnoozed,
4 hasQueuedTurnStart,
5 pickSnoozePreset,
6 QUEUED_TURN_START_GRACE_MS,
7 resolveSnoozePresets,
8 snoozeWakeLabel,
9 threadWokeAt,
10} from "@t3tools/client-runtime/state/thread-settled";
11import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled";
12import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
13import { resolveThreadProviderStack } from "@t3tools/client-runtime/state/models";
14import {
15 threadSearchMatchKey,
16 threadSearchSubject,
17} from "@t3tools/client-runtime/state/thread-search";
18import { foldSearchQuery, rankSearchItems, scoreThreadSearch } from "@t3tools/shared/paletteSearch";
19import {
20 createInboxReturnTracker,
21 isThreadWorking,
22 sortInboxThreadsByReturn,
23 sortWorkingThreadsBySend,
24} from "@t3tools/client-runtime/state/thread-inbox";
25import {
26 sortActiveThreadsByOrderKey,
27 resolveSettledThreadTimestamp,
28 resolveThreadActivityTimestamp,
29 sortPinnedThreadsByOrderKey,
30 sortSettledThreads,
31} from "@t3tools/client-runtime/state/thread-sort";
32import { isListedThreadLineage, type EnvironmentId, type ProjectId } from "@t3tools/contracts";
33import type { SnoozePresetSetting } from "@t3tools/contracts/settings";
34import type { MenuAction } from "@react-native-menu/menu";
35​
36import type { ThreadListProvider } from "../../state/thread-list-environments";
37import type { ThreadMoveAvailability } from "./threadOrder";
38​
39import { relativeTime } from "../../lib/time";
40import type { PendingNewTask } from "../../state/use-pending-new-tasks";
41​
42import {
43 applyPendingThreadOrder,
44 reconcilePendingThreadOrder,
45 type PendingThreadOrder,
46} from "./threadOrder";
47​
48export const threadListInboxReturns = createInboxReturnTracker();
49​
50export function resolveThreadListV2ProviderDrivers(
51 thread: Pick<EnvironmentThreadShell, "providerInstanceHistory" | "modelSelection" | "runtime">,
52 providers: ReadonlyArray<ThreadListProvider> | undefined,
53): ReadonlyArray<string> {
54 if (providers === undefined) return [];
55 const stack = resolveThreadProviderStack(thread);
56 const drivers = stack.flatMap((instanceId) => {
57 const driver = providers.find((provider) => provider.instanceId === instanceId)?.driver;
58 return driver === undefined ? [] : [driver];
59 });
60 const currentDriver = providers.find(
61 (provider) => provider.instanceId === stack[stack.length - 1],
62 )?.driver;
63 return currentDriver === undefined ? [] : drivers;
65​
66export type ThreadListV2Status =
67 | "approval"
68 | "input"
69 | "request"
70 | "working"
71 | "waiting"
72 | "failed"
73 | "limited"
74 | "ready";
75export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze";
76​
77/**
78 * Presets for a row's snooze menu, read at the list's minute instead of the
79 * clock: the compiler keys a row's memo on its inputs, so a clock read inside
80 * it would keep the times from the row's first render.
81 */
82export function resolveThreadListV2SnoozePresets(input: {
83 readonly secondary: "snooze" | null;
84 readonly now: string;
85 readonly presets: ReadonlyArray<SnoozePresetSetting> | undefined;
86}): ReadonlyArray<SnoozePreset> {
87 return input.secondary === "snooze"
88 ? resolveSnoozePresets(new Date(input.now), presetOptions(input.presets))
89 : [];
91​
92function presetOptions(presets: ReadonlyArray<SnoozePresetSetting> | undefined) {
93 return presets ? { presets } : {};
95​
96export function resolveThreadListV2SnoozeMenuSelection(input: {
97 readonly event: string;
98 readonly displayedPresets: ReadonlyArray<SnoozePreset>;
99 readonly now: Date;
100 readonly presets: ReadonlyArray<SnoozePresetSetting> | undefined;
101}):
102 | { readonly _tag: "selected"; readonly preset: SnoozePreset }
103 | { readonly _tag: "expired" }
104 | { readonly _tag: "not-snooze" } {
105 if (!input.event.startsWith("snooze:")) return { _tag: "not-snooze" };
106 const preset = pickSnoozePreset({
107 shown: input.displayedPresets,
108 id: input.event.slice("snooze:".length),
109 now: input.now,
110 ...presetOptions(input.presets),
111 });
112 return preset ? { _tag: "selected", preset } : { _tag: "expired" };
114​
115export function resolveThreadListV2SwipeActions(input: {
116 readonly variant: "card" | "slim";
117 readonly settlementSupported: boolean;
118 readonly snoozeSupported: boolean;
119 readonly snoozable: boolean;
120 readonly snoozed?: boolean;
121}): {
122 readonly primary: Exclude<ThreadListV2SwipeAction, "snooze">;
123 readonly secondary: "snooze" | null;
124} {
125 if (input.snoozed === true) {
126 return { primary: "unsnooze", secondary: null };
127 }
128 const primary = input.settlementSupported
129 ? input.variant === "slim"
130 ? "unsettle"
131 : "settle"
132 : "archive";
133 return {
134 primary,
135 secondary: input.snoozeSupported && input.snoozable ? "snooze" : null,
136 };
138​
139// Nested keeps iOS menus short: UIKit scrolls a taller menu, and that scroll
140// cancels press-drag-release selection.
141export function layoutThreadListV2RowMenuActions(input: {
142 readonly nested: boolean;
143 readonly direct: readonly MenuAction[];
144 readonly reorder: readonly MenuAction[];
145 readonly pin: readonly MenuAction[];
146 readonly secondary: readonly MenuAction[];
147}): MenuAction[] {
148 if (!input.nested) {
149 return [...input.direct, ...input.reorder, ...input.pin, ...input.secondary];
150 }
151 return [
152 ...input.direct,
153 ...input.pin,
154 {
155 id: "more",
156 title: "More",
157 image: "ellipsis.circle",
158 subactions: [
159 ...(input.reorder.length > 1
160 ? [
161 {
162 id: "arrange-menu",
163 title: "Arrange",
164 image: "arrow.up.arrow.down",
165 subactions: [...input.reorder],
166 },
167 ]
168 : input.reorder),
169 ...input.secondary,
170 ],
171 },
172 ];
174​
175export function resolveThreadListV2SnoozeGateExpiryMs(
176 thread: Pick<
177 EnvironmentThreadShell,
178 | "hasPendingApprovals"
179 | "hasPendingUserInput"
180 | "pendingSubagentRequest"
181 | "latestRun"
182 | "latestUserMessageAt"
183 | "runtime"
184 >,
185 options: { readonly now: string },
186): number | null {
187 if (thread.hasPendingApprovals) return null;
188 if (thread.pendingSubagentRequest != null) return null;
189 if (!hasQueuedTurnStart(thread, options)) return null;
190 const messageAtMs = Date.parse(thread.latestUserMessageAt ?? "");
191 if (Number.isNaN(messageAtMs)) return null;
192 return messageAtMs + QUEUED_TURN_START_GRACE_MS;
194​
195/** The earliest `snoozedUntil` strictly after `nowMs`, in epoch ms. */
196export function nextSnoozeWakeAfter(
197 threads: ReadonlyArray<{ readonly snoozedUntil?: string | null }>,
198 nowMs: number,
199): number | null {
200 let next: number | null = null;
201 for (const thread of threads) {
202 if (thread.snoozedUntil == null) continue;
203 const wakeAtMs = Date.parse(thread.snoozedUntil);
204 if (wakeAtMs > nowMs && (next === null || wakeAtMs < next)) next = wakeAtMs;
205 }
206 return next;
208​
209// Outside search, the clock only changes list membership/order at snooze deadlines.
210export function threadListV2LayoutTime(input: {
211 readonly threads: ReadonlyArray<{ readonly snoozedUntil?: string | null }>;
212 readonly now: string;
213 readonly searchQuery: string;
214}) {
215 if (input.searchQuery !== "") return input.now;
216 const nowMs = Date.parse(input.now);
217 if (!Number.isFinite(nowMs)) return input.now;
218 let previousWakeAt: number | undefined;
219 let nextWakeAt: number | undefined;
220 for (const thread of input.threads) {
221 if (thread.snoozedUntil == null) continue;
222 const wakeAt = Date.parse(thread.snoozedUntil);
223 if (!Number.isFinite(wakeAt)) continue;
224 if (wakeAt <= nowMs) previousWakeAt = Math.max(previousWakeAt ?? wakeAt, wakeAt);
225 else nextWakeAt = Math.min(nextWakeAt ?? wakeAt, wakeAt);
226 }
227 return new Date(previousWakeAt ?? (nextWakeAt === undefined ? 0 : nextWakeAt - 1)).toISOString();
229​
230export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10;
231export const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25;
232​
233export function threadHasUnseenCompletion(
234 thread: Pick<EnvironmentThreadShell, "latestRun" | "lastVisitedAt">,
235): boolean {
236 const completedAt = thread.latestRun?.completedAt;
237 if (!completedAt) return false;
238 const completedAtMs = Date.parse(completedAt);
239 if (Number.isNaN(completedAtMs)) return false;
240 if (!thread.lastVisitedAt) return false;
241 const lastVisitedAtMs = Date.parse(thread.lastVisitedAt);
242 if (Number.isNaN(lastVisitedAtMs)) return true;
243 return completedAtMs > lastVisitedAtMs;
245​
246export function resolveThreadListV2WokeAt(
247 thread: EnvironmentThreadShell,
248 options: { readonly now: string },
249): string | null {
250 // Older servers cannot persist an acknowledgement.
251 if (thread.lastVisitedAt === undefined || thread.settledOverride === "settled") return null;
252 const wokeAt = threadWokeAt(thread, options);
253 const wokeAtMs = Date.parse(wokeAt ?? "");
254 const lastVisitedAtMs = Date.parse(thread.lastVisitedAt ?? "");
255 return !Number.isNaN(wokeAtMs) && (Number.isNaN(lastVisitedAtMs) || lastVisitedAtMs < wokeAtMs)
256 ? wokeAt
257 : null;
259​
260export function resolveThreadListV2Status(
261 thread: Pick<
262 EnvironmentThreadShell,
263 | "hasPendingApprovals"
264 | "hasPendingUserInput"
265 | "pendingSubagentRequest"
266 | "pendingGuestMessageCount"
267 | "runtime"
268 | "blockedOnThreadWait"
269 >,
270): ThreadListV2Status {
271 if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") {
272 return "approval";
273 }
274 if (thread.hasPendingUserInput || thread.pendingSubagentRequest === "user_input") {
275 return "input";
276 }
277 // A guest is waiting on the owner; the agent's own requests come first.
278 if ((thread.pendingGuestMessageCount ?? 0) > 0) {
279 return "request";
280 }
281 if (thread.runtime?.status === "running" && thread.blockedOnThreadWait === true) {
282 return "waiting";
283 }
284 if (
285 thread.runtime !== null &&
286 ["preparing", "queued", "starting", "running", "waiting"].includes(thread.runtime.status)
287 ) {
288 return "working";
289 }
290 if (thread.runtime?.status === "idle") {
291 return "waiting";
292 }
293 if (thread.runtime?.status === "failed") {
294 return thread.runtime.lastErrorClass === "usage_limit" ? "limited" : "failed";
295 }
296 return "ready";
298​
299function parseTimestampMs(isoDate: string): number {
300 const parsed = Date.parse(isoDate);
301 return Number.isNaN(parsed) ? 0 : parsed;
303​
304function sortThreadsForListV2<
305 T extends {
306 readonly id: string;
307 readonly createdAt: string;
308 readonly unsettledAt?: string | null | undefined;
309 readonly activeOrderKey?: string | null | undefined;
310 readonly environmentId?: string | undefined;
311 },
312>(threads: readonly T[]): T[] {
313 return sortActiveThreadsByOrderKey(threads);
315​
316export function getThreadListV2OrderedSection(input: {
317 readonly threads: readonly EnvironmentThreadShell[];
318 readonly section: "pinned" | "active";
319 readonly pendingOrder?: PendingThreadOrder | null;
320 readonly now: string;
321 readonly settlementEnvironmentIds?: ReadonlySet<EnvironmentId>;
322 readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;
323 readonly queuedThreadKeys?: ReadonlySet<string>;
324}): EnvironmentThreadShell[] {
325 const queuedThreadKeys = input.queuedThreadKeys?.size ? input.queuedThreadKeys : undefined;
326 const threads = input.threads.filter((thread) => {
327 if (thread.archivedAt !== null || !isListedThreadLineage(thread.lineage)) return false;
328 if (
329 (input.settlementEnvironmentIds?.has(thread.environmentId) ?? true) &&
330 thread.settledOverride === "settled" &&
331 queuedThreadKeys?.has(`${thread.environmentId}:${thread.id}`) !== true
332 ) {
333 return false;
334 }
335 if (
336 (input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true) &&
337 effectiveSnoozed(thread, { now: input.now })
338 ) {
339 return false;
340 }
341 return (thread.pinnedAt != null) === (input.section === "pinned");
342 });
343 const ordered =
344 input.section === "pinned"
345 ? sortPinnedThreadsByOrderKey(threads)
346 : sortActiveThreadsByOrderKey(threads);
347 const pending =
348 input.pendingOrder?.section === input.section
349 ? reconcilePendingThreadOrder(input.pendingOrder, ordered)
350 : null;
351 return applyPendingThreadOrder(ordered, input.section, pending);
353​
354export interface ThreadListV2Item {
355 readonly thread: EnvironmentThreadShell;
356 readonly variant: "card" | "slim";
357 readonly snoozed: boolean;
358 readonly pinned: boolean;
359 readonly wokeAt: string | null;
360 readonly isLast: boolean;
362​
363export interface ThreadListV2Layout {
364 readonly items: ThreadListV2Item[];
365 readonly hiddenSettledCount: number;
366 /** The settled rows the shelf may list, before its visible limit. */
367 readonly listedSettledThreads: ReadonlyArray<EnvironmentThreadShell>;
368 readonly workingCount: number;
369 readonly workingShelfHeaderIndex: number | null;
370 readonly snoozedCount: number;
371 readonly snoozedShelfHeaderIndex: number | null;
372 readonly settledCount: number;
373 readonly settledShelfHeaderIndex: number | null;
374 /** Arm a timeout here to repartition at snooze expiry rather than the next minute tick. */
375 readonly nextSnoozeWakeAt: string | null;
376 /** Threads listed only because their messages match the search. */
377 readonly contentOnlyThreadKeys: ReadonlySet<string>;
379​
380export interface ThreadListV2ThreadListItem {
381 readonly type: "v2-thread";
382 readonly key: string;
383 readonly item: ThreadListV2Item;
384 readonly snoozeWakeLabelText: string | undefined;
385 readonly timeLabel: string;
386 readonly snoozePresetMinute: string | undefined;
387 readonly showTrailingDivider: boolean;
388 /** Include queued state in item equality; outbox writes do not update thread shells. */
389 readonly hasQueuedMessages: boolean;
390 readonly canMoveUp: boolean;
391 readonly canMoveDown: boolean;
393​
394export interface ThreadListV2PendingListItem {
395 readonly type: "v2-pending";
396 readonly key: string;
397 readonly pendingTask: PendingNewTask;
398 readonly showPendingDivider: boolean;
399 readonly showTrailingDivider: boolean;
401​
402export interface ThreadListV2WorkingShelfListItem {
403 readonly type: "v2-working-shelf";
404 readonly key: "v2-working-shelf";
405 readonly count: number;
406 readonly expanded: boolean;
407 readonly disabled: boolean;
409​
410export interface ThreadListV2SnoozedShelfListItem {
411 readonly type: "v2-snoozed-shelf";
412 readonly key: "v2-snoozed-shelf";
413 readonly count: number;
414 readonly expanded: boolean;
415 /** Include disabled state in item equality; recycled cells ignore render-closure updates. */
416 readonly disabled: boolean;
418​
419export interface ThreadListV2SettledShelfListItem {
420 readonly type: "v2-settled-shelf";
421 readonly key: "v2-settled-shelf";
422 readonly count: number;
423 readonly expanded: boolean;
424 readonly disabled: boolean;
426​
427export type ThreadListV2ListItem =
428 | ThreadListV2ThreadListItem
429 | ThreadListV2PendingListItem
430 | ThreadListV2WorkingShelfListItem
431 | ThreadListV2SnoozedShelfListItem
432 | ThreadListV2SettledShelfListItem;
433​
434export function isThreadListV2ListItem(value: {
435 readonly type: string;
436}): value is ThreadListV2ListItem {
437 return (
438 value.type === "v2-thread" ||
439 value.type === "v2-pending" ||
440 value.type === "v2-working-shelf" ||
441 value.type === "v2-snoozed-shelf" ||
442 value.type === "v2-settled-shelf"
443 );
445​
446/** Compare rebuilt items by content so minute ticks and partition runs do not re-render every mounted row. */
447export function threadListV2ListItemsAreEqual(
448 previous: ThreadListV2ListItem,
449 item: ThreadListV2ListItem,
450): boolean {
451 switch (item.type) {
452 case "v2-thread":
453 return (
454 previous.type === "v2-thread" &&
455 previous.key === item.key &&
456 previous.item.thread === item.item.thread &&
457 previous.item.variant === item.item.variant &&
458 previous.item.snoozed === item.item.snoozed &&
459 previous.item.pinned === item.item.pinned &&
460 previous.item.wokeAt === item.item.wokeAt &&
461 previous.snoozeWakeLabelText === item.snoozeWakeLabelText &&
462 previous.timeLabel === item.timeLabel &&
463 previous.snoozePresetMinute === item.snoozePresetMinute &&
464 previous.showTrailingDivider === item.showTrailingDivider &&
465 previous.hasQueuedMessages === item.hasQueuedMessages &&
466 previous.canMoveUp === item.canMoveUp &&
467 previous.canMoveDown === item.canMoveDown
468 );
469 case "v2-pending":
470 return (
471 previous.type === "v2-pending" &&
472 previous.key === item.key &&
473 previous.pendingTask === item.pendingTask &&
474 previous.showPendingDivider === item.showPendingDivider &&
475 previous.showTrailingDivider === item.showTrailingDivider
476 );
477 case "v2-working-shelf":
478 return (
479 previous.type === "v2-working-shelf" &&
480 previous.count === item.count &&
481 previous.expanded === item.expanded &&
482 previous.disabled === item.disabled
483 );
484 case "v2-snoozed-shelf":
485 return (
486 previous.type === "v2-snoozed-shelf" &&
487 previous.count === item.count &&
488 previous.expanded === item.expanded &&
489 previous.disabled === item.disabled
490 );
491 case "v2-settled-shelf":
492 return (
493 previous.type === "v2-settled-shelf" &&
494 previous.count === item.count &&
495 previous.expanded === item.expanded &&
496 previous.disabled === item.disabled
497 );
498 }
500​
501function resolveThreadListV2ItemTimeLabel(
502 item: ThreadListV2Item,
503 showSnoozeWakeLabel: boolean,
504): string {
505 const { thread, variant, snoozed } = item;
506 if (showSnoozeWakeLabel) return "";
507 if (
508 variant === "card" &&
509 (resolveThreadListV2Status(thread) !== "ready" ||
510 item.wokeAt !== null ||
511 threadHasUnseenCompletion(thread))
512 )
513 return "";
514 const settledTimestamp =
515 variant === "slim" && !snoozed ? resolveSettledThreadTimestamp(thread) : null;
516 return relativeTime(
517 settledTimestamp ?? resolveThreadActivityTimestamp(thread) ?? thread.createdAt,
518 );
520​
521export function buildThreadListV2ListItems(input: {
522 readonly items: ReadonlyArray<ThreadListV2Item>;
523 readonly pendingTasks: ReadonlyArray<PendingNewTask>;
524 readonly workingCount?: number;
525 readonly workingShelfExpanded?: boolean;
526 readonly workingShelfHeaderIndex?: number | null;
527 readonly snoozedCount?: number;
528 readonly snoozedShelfExpanded?: boolean;
529 readonly snoozedShelfHeaderIndex?: number | null;
530 readonly settledCount?: number;
531 readonly settledShelfExpanded?: boolean;
532 readonly settledShelfHeaderIndex?: number | null;
533 readonly snoozeLabelNow?: string;
534 readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;
535 readonly queuedThreadKeys?: ReadonlySet<string>;
536 readonly moveAvailability?: ReadonlyMap<string, ThreadMoveAvailability>;
537 readonly shelfPreferencesLoading?: boolean;
538}): ThreadListV2ListItem[] {
539 const queuedThreadKeys = input.queuedThreadKeys?.size ? input.queuedThreadKeys : undefined;
540 const threadItems = input.items.map((item): ThreadListV2ListItem => {
541 const snoozeWakeLabelText =
542 item.snoozed && item.thread.snoozedUntil != null && input.snoozeLabelNow !== undefined
543 ? snoozeWakeLabel(item.thread.snoozedUntil, { now: input.snoozeLabelNow })
544 : undefined;
545 // Put the clock on items so recycler equality limits ticks to rows displaying snooze preset times.
546 const snoozePresetMinute =
547 !item.snoozed &&
548 input.snoozeLabelNow !== undefined &&
549 (input.snoozeEnvironmentIds?.has(item.thread.environmentId) ?? true) &&
550 canSnooze(item.thread, { now: input.snoozeLabelNow })
551 ? input.snoozeLabelNow
552 : undefined;
553 const move =
554 item.variant === "card"
555 ? input.moveAvailability?.get(`${item.thread.environmentId}:${item.thread.id}`)
556 : undefined;
557 return {
558 type: "v2-thread",
559 key: `v2-thread:${item.thread.environmentId}:${item.thread.id}`,
560 item,
561 snoozeWakeLabelText,
562 timeLabel: resolveThreadListV2ItemTimeLabel(item, snoozeWakeLabelText !== undefined),
563 snoozePresetMinute,
564 showTrailingDivider: false,
565 hasQueuedMessages:
566 queuedThreadKeys?.has(`${item.thread.environmentId}:${item.thread.id}`) === true,
567 canMoveUp: move?.canMoveUp === true,
568 canMoveDown: move?.canMoveDown === true,
569 };
570 });
571 const pendingItems = input.pendingTasks.map((pendingTask, index): ThreadListV2ListItem => ({
572 type: "v2-pending",
573 key: `v2-${pendingTask.key}`,
574 pendingTask,
575 showPendingDivider: index === 0,
576 showTrailingDivider: false,
577 }));
578 const workingCount = input.workingCount ?? 0;
579 const workingShelfHeaderIndex = input.workingShelfHeaderIndex ?? null;
580 const snoozedCount = input.snoozedCount ?? 0;
581 const snoozedShelfHeaderIndex = input.snoozedShelfHeaderIndex ?? null;
582 const settledCount = input.settledCount ?? 0;
583 const settledShelfHeaderIndex = input.settledShelfHeaderIndex ?? null;
584 const snoozedEnd = settledShelfHeaderIndex ?? threadItems.length;
585 const workingEnd = snoozedShelfHeaderIndex ?? snoozedEnd;
586 const activeEnd = workingShelfHeaderIndex ?? workingEnd;
587 const result: ThreadListV2ListItem[] = [...pendingItems, ...threadItems.slice(0, activeEnd)];
588 const shelfDisabled = input.shelfPreferencesLoading === true;
589 if (workingShelfHeaderIndex !== null && workingCount > 0) {
590 result.push({
591 type: "v2-working-shelf",
592 key: "v2-working-shelf",
593 count: workingCount,
594 expanded: input.workingShelfExpanded === true,
595 disabled: shelfDisabled,
596 });
597 result.push(...threadItems.slice(workingShelfHeaderIndex, workingEnd));
598 }
599 if (snoozedShelfHeaderIndex !== null && snoozedCount > 0) {
600 result.push({
601 type: "v2-snoozed-shelf",
602 key: "v2-snoozed-shelf",
603 count: snoozedCount,
604 expanded: input.snoozedShelfExpanded === true,
605 disabled: shelfDisabled,
606 });
607 result.push(...threadItems.slice(snoozedShelfHeaderIndex, snoozedEnd));
608 }
609 if (settledShelfHeaderIndex !== null && settledCount > 0) {
610 result.push({
611 type: "v2-settled-shelf",
612 key: "v2-settled-shelf",
613 count: settledCount,
614 expanded: input.settledShelfExpanded !== false,
615 disabled: shelfDisabled,
616 });
617 result.push(...threadItems.slice(settledShelfHeaderIndex));
618 }
619 return result.map((entry, index) => {
620 if (entry.type !== "v2-thread" && entry.type !== "v2-pending") return entry;
621 const next = result[index + 1];
622 const showTrailingDivider =
623 next?.type === "v2-thread" || (next?.type === "v2-pending" && !next.showPendingDivider);
624 return showTrailingDivider === entry.showTrailingDivider
625 ? entry
626 : { ...entry, showTrailingDivider };
627 });
629​
630// Immutable shells and field-only sorting make element-identical inputs produce identical output.
631let lastSettledInput: ReadonlyArray<EnvironmentThreadShell> = [];
632let lastSettledOutput: ReadonlyArray<EnvironmentThreadShell> = [];
633​
634function sortSettledThreadsReusingLast(
635 settled: ReadonlyArray<EnvironmentThreadShell>,
636): ReadonlyArray<EnvironmentThreadShell> {
637 if (settled.length === lastSettledInput.length) {
638 let same = true;
639 for (let index = 0; index < settled.length; index += 1) {
640 if (settled[index] !== lastSettledInput[index]) {
641 same = false;
642 break;
643 }
644 }
645 if (same) return lastSettledOutput;
646 }
647 lastSettledInput = settled;
648 lastSettledOutput = sortSettledThreads(settled);
649 return lastSettledOutput;
651​
652export function buildThreadListV2Items(input: {
653 readonly pendingOrder?: PendingThreadOrder | null;
654 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
655 readonly environmentId: EnvironmentId | null;
656 readonly projectRefs?: ReadonlyArray<{
657 readonly environmentId: EnvironmentId;
658 readonly projectId: ProjectId;
659 }> | null;
660 readonly searchQuery: string;
661 readonly matchedThreadKeys?: ReadonlySet<string>;
662 readonly settlementEnvironmentIds?: ReadonlySet<EnvironmentId>;
663 readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;
664 readonly settledLimit?: number;
665 readonly now: string;
666 readonly workingShelfEnabled?: boolean;
667 readonly workingShelfExpanded?: boolean;
668 readonly isWorking?: (thread: EnvironmentThreadShell) => boolean;
669 readonly inboxReturnAt?: (thread: EnvironmentThreadShell) => number | undefined;
670 readonly snoozedShelfExpanded?: boolean;
671 readonly settledShelfExpanded?: boolean;
672 readonly selectedThreadKey?: string | null;
673 readonly queuedThreadKeys?: ReadonlySet<string>;
674 /** Whether the shelf may list a settled row yet; older settled rows load in pages. */
675 readonly isListedSettled?: (thread: EnvironmentThreadShell) => boolean;
676 /** Settled threads in scope that are not local (older settled threads not loaded). */
677 readonly unloadedSettledCount?: number;
678}): ThreadListV2Layout {
679 const now = input.now;
680 const pending =
681 input.pendingOrder == null
682 ? null
683 : reconcilePendingThreadOrder(
684 input.pendingOrder,
685 getThreadListV2OrderedSection({
686 ...input,
687 section: input.pendingOrder.section,
688 pendingOrder: null,
689 }),
690 );
691 const query = foldSearchQuery(input.searchQuery);
692 const nowMs = Date.parse(now);
693 const projectKeys = input.projectRefs
694 ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))
695 : null;
696​
697 const workingShelfEnabled = input.workingShelfEnabled === true;
698 const pinned: EnvironmentThreadShell[] = [];
699 const active: EnvironmentThreadShell[] = [];
700 const working: EnvironmentThreadShell[] = [];
701 const settled: EnvironmentThreadShell[] = [];
702 const snoozed: EnvironmentThreadShell[] = [];
703 let nextSnoozeWakeAt: string | null = null;
704 const queuedThreadKeys = input.queuedThreadKeys?.size ? input.queuedThreadKeys : undefined;
705 const listedThreads = input.threads.filter(
706 (thread) =>
707 thread.archivedAt === null &&
708 isListedThreadLineage(thread.lineage) &&
709 (input.environmentId === null || thread.environmentId === input.environmentId) &&
710 (projectKeys === null || projectKeys.has(`${thread.environmentId}:${thread.projectId}`)),
711 );
712 // The same matches as the command palette, typo fallback included. Only
713 // listed threads are ranked: a hidden exact match must not turn typos off.
714 const rankedMatches =
715 query.length > 0
716 ? rankSearchItems(listedThreads, (thread, options) =>
717 scoreThreadSearch(query, threadSearchSubject(thread, null), nowMs, {
718 ...options,
719 picks: false,
720 }),
721 ).map(({ item }) => item)
722 : null;
723 const matchingThreads = rankedMatches === null ? null : new Set(rankedMatches);
724 // Threads listed only because their messages match; only their rows show
725 // the message and open at it.
726 const contentOnlyThreadKeys = new Set<string>();
727 for (const thread of listedThreads) {
728 if (matchingThreads !== null && !matchingThreads.has(thread)) {
729 const key = threadSearchMatchKey({
730 environmentId: thread.environmentId,
731 threadId: thread.id,
732 });
733 if (input.matchedThreadKeys?.has(key) !== true) continue;
734 contentOnlyThreadKeys.add(key);
735 }
736 const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true;
737 const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true;
738 if (supportsSnooze && effectiveSnoozed(thread, { now })) {
739 snoozed.push(thread);
740 if (
741 thread.snoozedUntil != null &&
742 (nextSnoozeWakeAt === null ||
743 parseTimestampMs(thread.snoozedUntil) < parseTimestampMs(nextSnoozeWakeAt))
744 ) {
745 nextSnoozeWakeAt = thread.snoozedUntil;
746 }
747 continue;
748 }
749 const hasQueuedMessages =
750 queuedThreadKeys?.has(`${thread.environmentId}:${thread.id}`) === true;
751 if (supportsSettlement && thread.settledOverride === "settled" && !hasQueuedMessages) {
752 settled.push(thread);
753 } else if (thread.pinnedAt != null) {
754 pinned.push(thread);
755 } else if (workingShelfEnabled && (input.isWorking ?? isThreadWorking)(thread)) {
756 working.push(thread);
757 } else {
758 active.push(thread);
759 }
760 }
761​
762 if (rankedMatches !== null) {
763 // Searching lists every match by rank across shelves, as the palette and
764 // the web sidebar do, then threads listed for their messages in the
765 // server's order. Each row keeps its shelf's look.
766 const slim = new Set([...snoozed, ...settled]);
767 const snoozedThreads = new Set(snoozed);
768 const pinnedThreads = new Set(pinned);
769 const threadByKey = new Map(
770 listedThreads.map((thread) => [
771 threadSearchMatchKey({ environmentId: thread.environmentId, threadId: thread.id }),
772 thread,
773 ]),
774 );
775 const contentOnly = [...(input.matchedThreadKeys ?? [])].flatMap((key) => {
776 const thread = contentOnlyThreadKeys.has(key) ? threadByKey.get(key) : undefined;
777 return thread ? [thread] : [];
778 });
779 const results = [...rankedMatches, ...contentOnly];
780 return {
781 items: results.map((thread, index) => ({
782 thread,
783 variant: slim.has(thread) ? "slim" : "card",
784 snoozed: snoozedThreads.has(thread),
785 pinned: pinnedThreads.has(thread),
786 wokeAt: slim.has(thread) ? null : resolveThreadListV2WokeAt(thread, { now }),
787 isLast: index === results.length - 1,
788 })),
789 hiddenSettledCount: 0,
790 listedSettledThreads: [],
791 workingCount: 0,
792 workingShelfHeaderIndex: null,
793 snoozedCount: 0,
794 snoozedShelfHeaderIndex: null,
795 settledCount: 0,
796 settledShelfHeaderIndex: null,
797 nextSnoozeWakeAt,
798 contentOnlyThreadKeys,
799 };
800 }
801​
802 const orderedActive = workingShelfEnabled
803 ? sortInboxThreadsByReturn(active, input.inboxReturnAt)
804 : applyPendingThreadOrder(sortThreadsForListV2(active), "active", pending);
805 const orderedWorking = sortWorkingThreadsBySend(working);
806 const orderedSnoozed = [...snoozed].sort(
807 (left, right) =>
808 parseTimestampMs(left.snoozedUntil ?? "") - parseTimestampMs(right.snoozedUntil ?? ""),
809 );
810 const selectedThreadKey = input.selectedThreadKey ?? null;
811 const visibleWorking =
812 input.workingShelfExpanded === true
813 ? orderedWorking
814 : orderedWorking.filter(
815 (thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey,
816 );
817 const visibleSnoozed =
818 input.snoozedShelfExpanded === true
819 ? orderedSnoozed
820 : orderedSnoozed.filter(
821 (thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey,
822 );
823 const orderedSettled = sortSettledThreadsReusingLast(settled);
824 const listedSettled =
825 input.isListedSettled === undefined
826 ? orderedSettled
827 : orderedSettled.filter(input.isListedSettled);
828 const settledTotal = orderedSettled.length + (input.unloadedSettledCount ?? 0);
829 const settledLimit = input.settledLimit ?? Number.POSITIVE_INFINITY;
830 const limitedSettled =
831 listedSettled.length > settledLimit ? listedSettled.slice(0, settledLimit) : listedSettled;
832 const selectedSettled =
833 selectedThreadKey === null ||
834 limitedSettled.some((thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey)
835 ? undefined
836 : orderedSettled.find(
837 (thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey,
838 );
839 const pagedSettled =
840 selectedSettled === undefined ? limitedSettled : [...limitedSettled, selectedSettled];
841 const visibleSettled =
842 input.settledShelfExpanded !== false
843 ? pagedSettled
844 : pagedSettled.filter(
845 (thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey,
846 );
847​
848 const items: ThreadListV2Item[] = [];
849 for (const thread of applyPendingThreadOrder(
850 sortPinnedThreadsByOrderKey(pinned),
851 "pinned",
852 pending,
853 )) {
854 items.push({
855 thread,
856 variant: "card",
857 snoozed: false,
858 pinned: true,
859 wokeAt: resolveThreadListV2WokeAt(thread, { now }),
860 isLast: false,
861 });
862 }
863 for (const thread of orderedActive) {
864 items.push({
865 thread,
866 variant: "card",
867 snoozed: false,
868 pinned: false,
869 wokeAt: resolveThreadListV2WokeAt(thread, { now }),
870 isLast: false,
871 });
872 }
873 const workingShelfHeaderIndex = orderedWorking.length > 0 ? items.length : null;
874 for (const thread of visibleWorking) {
875 items.push({
876 thread,
877 variant: "card",
878 snoozed: false,
879 pinned: false,
880 wokeAt: resolveThreadListV2WokeAt(thread, { now }),
881 isLast: false,
882 });
883 }
884 const snoozedShelfHeaderIndex = orderedSnoozed.length > 0 ? items.length : null;
885 for (const thread of visibleSnoozed) {
886 items.push({
887 thread,
888 variant: "slim",
889 snoozed: true,
890 pinned: false,
891 wokeAt: null,
892 isLast: false,
893 });
894 }
895 const settledShelfHeaderIndex = settledTotal > 0 ? items.length : null;
896 for (const thread of visibleSettled) {
897 items.push({
898 thread,
899 variant: "slim",
900 snoozed: false,
901 pinned: false,
902 wokeAt: null,
903 isLast: false,
904 });
905 }
906 const last = items.at(-1);
907 if (last) {
908 items[items.length - 1] = { ...last, isLast: true };
909 }
910 return {
911 items,
912 hiddenSettledCount: Math.max(0, settledTotal - pagedSettled.length),
913 listedSettledThreads: listedSettled,
914 workingCount: orderedWorking.length,
915 workingShelfHeaderIndex,
916 snoozedCount: orderedSnoozed.length,
917 snoozedShelfHeaderIndex,
918 settledCount: settledTotal,
919 settledShelfHeaderIndex,
920 nextSnoozeWakeAt,
921 contentOnlyThreadKeys,
922 };