| 1 | import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; |
| 2 | import { |
| 3 | canSnooze, |
| 4 | effectiveSnoozed, |
| 5 | hasQueuedTurnStart, |
| 6 | QUEUED_TURN_START_GRACE_MS, |
| 7 | resolveSnoozePresets, |
| 8 | snoozeWakeLabel, |
| 9 | } from "@t3tools/client-runtime/state/thread-settled"; |
| 10 | import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; |
| 11 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 12 | import { resolveThreadProviderStack } from "@t3tools/client-runtime/state/models"; |
| 13 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; |
| 14 | import { |
| 15 | createInboxReturnTracker, |
| 16 | isThreadWorking, |
| 17 | sortInboxThreadsByReturn, |
| 18 | sortWorkingThreadsBySend, |
| 19 | } from "@t3tools/client-runtime/state/thread-inbox"; |
| 20 | import { |
| 21 | sortActiveThreadsByOrderKey, |
| 22 | resolveSettledThreadTimestamp, |
| 23 | sortPinnedThreadsByOrderKey, |
| 24 | sortSettledThreads, |
| 25 | } from "@t3tools/client-runtime/state/thread-sort"; |
| 26 | import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; |
| 27 | |
| 28 | import type { ThreadListProvider } from "../../state/thread-list-environments"; |
| 29 | import type { ThreadMoveAvailability } from "./threadOrder"; |
| 30 | |
| 31 | import { relativeTime } from "../../lib/time"; |
| 32 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 33 | |
| 34 | import { |
| 35 | applyPendingThreadOrder, |
| 36 | reconcilePendingThreadOrder, |
| 37 | type PendingThreadOrder, |
| 38 | } from "./threadOrder"; |
| 39 | |
| 40 | export { 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. */ |
| 45 | export 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 | */ |
| 52 | export 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 | */ |
| 79 | export type ThreadListV2Status = |
| 80 | | "approval" |
| 81 | | "input" |
| 82 | | "working" |
| 83 | | "waiting" |
| 84 | | "failed" |
| 85 | | "limited" |
| 86 | | "ready"; |
| 87 | export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze"; |
| 88 | |
| 89 | export 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 | |
| 113 | export 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 | */ |
| 144 | export 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. |
| 167 | export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10; |
| 168 | export 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 | */ |
| 178 | export 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 | |
| 191 | export 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. */ |
| 220 | function 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. */ |
| 227 | export 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. */ |
| 240 | export 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 | |
| 280 | export 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 | |
| 290 | export 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 | |
| 313 | export 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 | |
| 343 | export 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 | |
| 354 | export 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 | |
| 363 | export 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 | |
| 375 | export 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 | |
| 384 | export 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. */ |
| 393 | export 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. */ |
| 412 | export 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. */ |
| 469 | function 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 | */ |
| 493 | export 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. |
| 626 | let lastSettledInput: ReadonlyArray<EnvironmentThreadShell> = []; |
| 627 | let lastSettledOutput: ReadonlyArray<EnvironmentThreadShell> = []; |
| 628 | |
| 629 | function 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 | */ |
| 651 | export 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 | } |