1import { presentThreadShell } from "@t3tools/client-runtime/state/models";
2import * as DateTime from "effect/DateTime";
3import { planPinnedMove } from "@t3tools/client-runtime/state/thread-sort";
4import {
5 createPendingThreadOrder,
6 createThreadMovePlanner,
7 threadOrderAfterMove,
8 threadDropLifecycle,
9 reconcilePendingThreadOrder,
10 type PendingThreadOrder,
11 type ThreadMoveAvailability,
12} from "./threadOrder";
13import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
14import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
15import { resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
16import {
17 CommandId,
18 EnvironmentId,
19 MessageId,
20 ProjectId,
21 ProviderInstanceId,
22 RunId,
23 ThreadId,
24} from "@t3tools/contracts";
25import { describe, expect, it, vi } from "vite-plus/test";
26
27import type { PendingNewTask } from "../../state/use-pending-new-tasks";
28import { makeRawThreadShell, makeThreadShellFixture } from "../../test-fixtures";
29import { threadJumpTarget } from "../keyboard/threadKeyboardShortcuts";
30import {
31 buildThreadListV2Items,
32 buildThreadListV2ListItems,
33 getThreadListV2OrderedSection,
34 isThreadListV2ListItem,
35 resolveThreadListV2SnoozeMenuSelection,
36 resolveThreadListV2SnoozeGateExpiryMs,
37 resolveThreadListV2Status,
38 resolveThreadListV2SwipeActions,
39 sortThreadsForListV2,
40 threadListV2ListItemsAreEqual,
41 threadHasUnseenCompletion,
42 type ThreadListV2ListItem,
43} from "./threadListV2";
44
45const environmentId = EnvironmentId.make("environment-1");
46
47function makeThread(
48 input: Partial<EnvironmentThreadShell> & Pick<EnvironmentThreadShell, "id" | "title">,
49): EnvironmentThreadShell {
50 return makeThreadShellFixture({
51 environmentId,
52 ...input,
53 });
54}
55
56const NOW = "2026-06-02T00:00:00.000Z";
57
58const linkedPullRequest = {
59 projectId: ProjectId.make("project-1"),
60 repository: "pingdotgg/t3code",
61 number: 42,
62 url: "https://github.com/pingdotgg/t3code/pull/42",
63};
64
65describe("resolveThreadListV2SnoozeMenuSelection", () => {
66 it("accepts a displayed evening preset while its wake time is still future", () => {
67 const menuOpenedAt = new Date(2026, 4, 8, 16, 59, 30);
68 const selectedAt = new Date(2026, 4, 8, 17, 0, 30);
69 const displayedPresets = resolveSnoozePresets(menuOpenedAt);
70
71 const selection = resolveThreadListV2SnoozeMenuSelection({
72 event: "snooze:evening",
73 displayedPresets,
74 now: selectedAt,
75 });
76
77 expect(selection).toEqual({
78 _tag: "selected",
79 preset: displayedPresets.find((preset) => preset.id === "evening"),
80 });
81 });
82
83 it("expires a displayed preset once its wake time has passed", () => {
84 const displayedPresets = resolveSnoozePresets(new Date(2026, 4, 8, 16, 59, 30));
85
86 expect(
87 resolveThreadListV2SnoozeMenuSelection({
88 event: "snooze:evening",
89 displayedPresets,
90 now: new Date(2026, 4, 8, 18, 0, 1),
91 }),
92 ).toEqual({ _tag: "expired" });
93 });
94
95 it("recomputes presets that remain available instead of using old timestamps", () => {
96 const displayedPresets = resolveSnoozePresets(new Date(2026, 4, 8, 10));
97 const selectedAt = new Date(2026, 4, 8, 10, 30);
98 const selection = resolveThreadListV2SnoozeMenuSelection({
99 event: "snooze:hour",
100 displayedPresets,
101 now: selectedAt,
102 });
103
104 expect(selection._tag).toBe("selected");
105 if (selection._tag === "selected") {
106 expect(selection.preset.snoozedUntil).toBe(
107 new Date(selectedAt.getTime() + 60 * 60 * 1_000).toISOString(),
108 );
109 }
110 });
111});
112
113describe("resolveThreadListV2Status", () => {
114 it("distinguishes usage limits from ordinary failures and clears the label after recovery", () => {
115 const thread = makeThread({
116 id: ThreadId.make("limited"),
117 title: "Limited",
118 runtime: {
119 status: "failed",
120 activeRunId: null,
121 providerInstanceId: ProviderInstanceId.make("codex"),
122 providerName: "Codex",
123 lastError: "Plan limit reached",
124 lastErrorClass: "usage_limit",
125 updatedAt: NOW,
126 },
127 });
128 expect(resolveThreadListV2Status(thread)).toBe("limited");
129 expect(
130 resolveThreadListV2Status({
131 ...thread,
132 runtime: { ...thread.runtime!, lastErrorClass: null },
133 }),
134 ).toBe("failed");
135 expect(
136 resolveThreadListV2Status({
137 ...thread,
138 runtime: { ...thread.runtime!, status: "completed" },
139 }),
140 ).toBe("ready");
141 });
142
143 it("prioritizes approval over a running runtime", () => {
144 const thread = makeThread({
145 id: ThreadId.make("t"),
146 title: "t",
147 hasPendingApprovals: true,
148 runtime: {
149 status: "running",
150 activeRunId: RunId.make("run-t"),
151 providerName: "Codex",
152 providerInstanceId: ProviderInstanceId.make("codex"),
153 lastError: null,
154 updatedAt: NOW,
155 },
156 });
157 expect(resolveThreadListV2Status(thread)).toBe("approval");
158 });
159
160 it("reports waiting when presentation parks runtime idle for background tasks", () => {
161 expect(
162 resolveThreadListV2Status(
163 makeThread({
164 id: ThreadId.make("t"),
165 title: "t",
166 pendingBackgroundTasks: [{ taskId: "bg-1", description: "Watch build", kind: "monitor" }],
167 runtime: {
168 status: "idle",
169 activeRunId: null,
170 providerInstanceId: ProviderInstanceId.make("codex"),
171 providerName: "Codex",
172 lastError: null,
173 updatedAt: NOW,
174 },
175 }),
176 ),
177 ).toBe("waiting");
178 });
179
180 it.each([
181 { kind: "command", status: "ready" },
182 { kind: "monitor", status: "waiting" },
183 ] as const)(
184 "presents an unseen completion with a $kind roster as $status",
185 ({ kind, status }) => {
186 const thread = presentThreadShell(
187 environmentId,
188 makeRawThreadShell({
189 latestRunId: RunId.make("run-background-completion"),
190 status: "completed",
191 latestRunCompletedAt: DateTime.makeUnsafe(NOW),
192 lastVisitedAt: DateTime.makeUnsafe("2026-06-01T23:59:00.000Z"),
193 pendingBackgroundTasks: [{ taskId: "background-work", kind }],
194 }),
195 );
196
197 expect(resolveThreadListV2Status(thread)).toBe(status);
198 expect(threadHasUnseenCompletion(thread)).toBe(true);
199 },
200 );
201
202 it("resolves ready for quiescent threads", () => {
203 expect(resolveThreadListV2Status(makeThread({ id: ThreadId.make("t"), title: "t" }))).toBe(
204 "ready",
205 );
206 });
207});
208
209describe("queued messages keep a settled thread active", () => {
210 const threads = [
211 makeThread({ id: ThreadId.make("active"), title: "Active" }),
212 makeThread({ id: ThreadId.make("settled"), title: "Settled", settledOverride: "settled" }),
213 makeThread({
214 id: ThreadId.make("settled-queued"),
215 title: "Settled with outbox",
216 settledOverride: "settled",
217 }),
218 ];
219 const queuedThreadKeys = new Set([`${environmentId}:settled-queued`]);
220
221 it("lists the thread in the active block instead of the settled shelf", () => {
222 const layout = buildThreadListV2Items({
223 threads,
224 environmentId: null,
225 searchQuery: "",
226 now: NOW,
227 queuedThreadKeys,
228 });
229 expect(layout.items.map((item) => [item.thread.id, item.variant] as const)).toEqual([
230 ["active", "card"],
231 ["settled-queued", "card"],
232 ["settled", "slim"],
233 ]);
234 expect(layout.settledCount).toBe(1);
235 });
236
237 it("includes it in the reorderable active section", () => {
238 expect(
239 getThreadListV2OrderedSection({ threads, section: "active", now: NOW, queuedThreadKeys }).map(
240 (thread) => thread.id,
241 ),
242 ).toEqual(["active", "settled-queued"]);
243 expect(
244 getThreadListV2OrderedSection({ threads, section: "active", now: NOW }).map(
245 (thread) => thread.id,
246 ),
247 ).toEqual(["active"]);
248 });
249});
250
251describe("resolveThreadListV2SwipeActions", () => {
252 it("offers settle and snooze for an active snoozable thread", () => {
253 expect(
254 resolveThreadListV2SwipeActions({
255 variant: "card",
256 settlementSupported: true,
257 snoozeSupported: true,
258 snoozable: true,
259 }),
260 ).toEqual({ primary: "settle", secondary: "snooze" });
261 });
262
263 it("offers un-settle and snooze for settled history", () => {
264 expect(
265 resolveThreadListV2SwipeActions({
266 variant: "slim",
267 settlementSupported: true,
268 snoozeSupported: true,
269 snoozable: true,
270 }),
271 ).toEqual({ primary: "unsettle", secondary: "snooze" });
272 });
273
274 it("omits snooze when the server or thread does not allow it", () => {
275 expect(
276 resolveThreadListV2SwipeActions({
277 variant: "card",
278 settlementSupported: true,
279 snoozeSupported: false,
280 snoozable: true,
281 }),
282 ).toEqual({ primary: "settle", secondary: null });
283 expect(
284 resolveThreadListV2SwipeActions({
285 variant: "card",
286 settlementSupported: true,
287 snoozeSupported: true,
288 snoozable: false,
289 }),
290 ).toEqual({ primary: "settle", secondary: null });
291 });
292
293 it("falls back to archive only for a pre-lifecycle server", () => {
294 expect(
295 resolveThreadListV2SwipeActions({
296 variant: "card",
297 settlementSupported: false,
298 snoozeSupported: false,
299 snoozable: true,
300 }),
301 ).toEqual({ primary: "archive", secondary: null });
302 });
303
304 it("offers wake and no snooze on a snoozed row", () => {
305 expect(
306 resolveThreadListV2SwipeActions({
307 variant: "slim",
308 settlementSupported: true,
309 snoozeSupported: true,
310 snoozable: true,
311 snoozed: true,
312 }),
313 ).toEqual({ primary: "unsnooze", secondary: null });
314 });
315});
316
317describe("resolveThreadListV2SnoozeGateExpiryMs", () => {
318 it("reports when an unadopted turn's grace window lapses", () => {
319 const thread = makeThread({
320 id: ThreadId.make("t"),
321 title: "t",
322 latestUserMessageAt: "2026-06-02T00:00:30.000Z",
323 });
324 expect(resolveThreadListV2SnoozeGateExpiryMs(thread, { now: "2026-06-02T00:01:00.000Z" })).toBe(
325 Date.parse("2026-06-02T00:02:30.000Z"),
326 );
327 });
328
329 it("returns null once the thread is snoozable or when only data can unblock it", () => {
330 expect(
331 resolveThreadListV2SnoozeGateExpiryMs(
332 makeThread({ id: ThreadId.make("ready"), title: "Ready" }),
333 { now: NOW },
334 ),
335 ).toBe(null);
336 expect(
337 resolveThreadListV2SnoozeGateExpiryMs(
338 makeThread({
339 id: ThreadId.make("blocked"),
340 title: "Blocked",
341 hasPendingApprovals: true,
342 latestUserMessageAt: NOW,
343 }),
344 { now: NOW },
345 ),
346 ).toBe(null);
347 });
348});
349
350describe("sortThreadsForListV2", () => {
351 it("honors a saved active order and leaves new threads above it", () => {
352 const sorted = sortThreadsForListV2([
353 { id: "newer-arranged", createdAt: "2026-06-01T12:00:00.000Z", activeOrderKey: "t" },
354 { id: "older-arranged", createdAt: "2026-06-01T08:00:00.000Z", activeOrderKey: "f" },
355 { id: "new", createdAt: "2026-06-01T13:00:00.000Z" },
356 ]);
357 expect(sorted.map((thread) => thread.id)).toEqual(["new", "older-arranged", "newer-arranged"]);
358 });
359
360 it("orders by creation time, newest first, ignoring activity", () => {
361 const sorted = sortThreadsForListV2([
362 { id: "oldest", createdAt: "2026-06-01T08:00:00.000Z" },
363 { id: "newest", createdAt: "2026-06-01T12:00:00.000Z" },
364 { id: "middle", createdAt: "2026-06-01T10:00:00.000Z" },
365 ]);
366 expect(sorted.map((thread) => thread.id)).toEqual(["newest", "middle", "oldest"]);
367 });
368});
369
370describe("getThreadListV2OrderedSection", () => {
371 it("uses each saved order and excludes settled, snoozed, and archived rows", () => {
372 const threads = [
373 makeThread({ id: ThreadId.make("active-later"), title: "Later", activeOrderKey: "t" }),
374 makeThread({ id: ThreadId.make("active-first"), title: "First", activeOrderKey: "f" }),
375 makeThread({ id: ThreadId.make("active-new"), title: "New" }),
376 makeThread({
377 id: ThreadId.make("pinned-later"),
378 title: "Pinned later",
379 pinnedAt: NOW,
380 pinOrderKey: "t",
381 activeOrderKey: "f",
382 }),
383 makeThread({
384 id: ThreadId.make("pinned-first"),
385 title: "Pinned first",
386 pinnedAt: NOW,
387 pinOrderKey: "f",
388 activeOrderKey: "t",
389 }),
390 makeThread({ id: ThreadId.make("settled"), title: "Settled", settledOverride: "settled" }),
391 makeThread({ id: ThreadId.make("archived"), title: "Archived", archivedAt: NOW }),
392 makeThread({
393 id: ThreadId.make("snoozed"),
394 title: "Snoozed",
395 snoozedUntil: "2026-06-03T10:00:00.000Z",
396 snoozedAt: NOW,
397 }),
398 makeThread({
399 id: ThreadId.make("pinned-snoozed"),
400 title: "Pinned snoozed",
401 pinnedAt: NOW,
402 snoozedUntil: "2026-06-03T10:00:00.000Z",
403 snoozedAt: NOW,
404 }),
405 ];
406 expect(
407 getThreadListV2OrderedSection({ threads, section: "active", now: NOW }).map(
408 (thread) => thread.id,
409 ),
410 ).toEqual(["active-new", "active-first", "active-later"]);
411 expect(
412 getThreadListV2OrderedSection({ threads, section: "pinned", now: NOW }).map(
413 (thread) => thread.id,
414 ),
415 ).toEqual(["pinned-first", "pinned-later"]);
416 });
417});
418
419describe("buildThreadListV2Items", () => {
420 it("places a persisted settled thread in the settled shelf", () => {
421 const thread = makeThread({
422 id: ThreadId.make("linked-merged"),
423 title: "Linked merged pull request",
424 linkedPullRequest,
425 settledOverride: "settled",
426 settledAt: NOW,
427 });
428 const layout = buildThreadListV2Items({
429 threads: [thread],
430 environmentId: null,
431 searchQuery: "",
432 now: NOW,
433 });
434
435 expect(layout.settledCount).toBe(1);
436 expect(layout.items[0]?.variant).toBe("slim");
437 });
438
439 it("hides snoozed threads and counts them — visibility parity with web", () => {
440 const layout = buildThreadListV2Items({
441 threads: [
442 makeThread({ id: ThreadId.make("active"), title: "Active" }),
443 makeThread({
444 id: ThreadId.make("snoozed"),
445 title: "Snoozed",
446 snoozedUntil: "2026-06-03T09:00:00.000Z",
447 snoozedAt: "2026-06-01T12:00:00.000Z",
448 }),
449 makeThread({
450 id: ThreadId.make("woken"),
451 title: "Woken",
452 // Wake time already passed: back in the active list.
453 snoozedUntil: "2026-06-01T18:00:00.000Z",
454 snoozedAt: "2026-06-01T12:00:00.000Z",
455 }),
456 ],
457 environmentId: null,
458 searchQuery: "",
459 now: NOW,
460 });
461
462 // Same createdAt → static sort tiebreaks by id; the point is the woken
463 // thread is BACK in the card block and the snoozed one is gone.
464 expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "woken"]);
465 expect(layout.snoozedCount).toBe(1);
466 });
467
468 it("places settled pinned threads in the settled shelf", () => {
469 const layout = buildThreadListV2Items({
470 threads: [
471 makeThread({ id: ThreadId.make("active"), title: "Active" }),
472 makeThread({
473 id: ThreadId.make("pinned-settled"),
474 title: "Pinned while settled",
475 pinnedAt: "2026-06-01T12:00:00.000Z",
476 settledOverride: "settled",
477 settledAt: "2026-06-01T12:00:00.000Z",
478 }),
479 ],
480 environmentId: null,
481 searchQuery: "",
482 now: NOW,
483 });
484
485 expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "pinned-settled"]);
486 expect(layout.items.map((item) => item.pinned)).toEqual([false, false]);
487 expect(layout.settledCount).toBe(1);
488 });
489
490 it("keeps active pinned threads in the pinned block", () => {
491 const pinned = makeThread({
492 id: ThreadId.make("pinned"),
493 title: "Pinned thread",
494 pinnedAt: "2026-06-01T12:00:00.000Z",
495 });
496 const layout = buildThreadListV2Items({
497 threads: [pinned],
498 environmentId: null,
499 searchQuery: "",
500 now: NOW,
501 });
502
503 expect(layout.items[0]).toMatchObject({
504 thread: { id: "pinned" },
505 variant: "card",
506 pinned: true,
507 });
508 expect(layout.settledCount).toBe(0);
509 });
510
511 it("hides snoozed threads and counts them — visibility parity with web", () => {
512 const layout = buildThreadListV2Items({
513 threads: [
514 makeThread({ id: ThreadId.make("active"), title: "Active" }),
515 makeThread({
516 id: ThreadId.make("snoozed"),
517 title: "Snoozed",
518 snoozedUntil: "2026-06-03T09:00:00.000Z",
519 snoozedAt: "2026-06-01T12:00:00.000Z",
520 }),
521 makeThread({
522 id: ThreadId.make("woken"),
523 title: "Woken",
524 // Wake time already passed: back in the active list.
525 snoozedUntil: "2026-06-01T18:00:00.000Z",
526 snoozedAt: "2026-06-01T12:00:00.000Z",
527 }),
528 ],
529 environmentId: null,
530 searchQuery: "",
531 now: NOW,
532 });
533
534 // Same createdAt → static sort tiebreaks by id; the point is the woken
535 // thread is BACK in the card block and the snoozed one is gone.
536 expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "woken"]);
537 expect(layout.snoozedCount).toBe(1);
538 });
539
540 it("moves a settled pinned thread into the settled shelf — parity with web (#7969)", () => {
541 const layout = buildThreadListV2Items({
542 threads: [
543 makeThread({ id: ThreadId.make("active"), title: "Active" }),
544 makeThread({
545 id: ThreadId.make("pinned-settled"),
546 title: "Pinned while settled",
547 pinnedAt: "2026-06-01T12:00:00.000Z",
548 // Stale settled state (the decider clears it on pin): the pin wins.
549 settledOverride: "settled",
550 settledAt: "2026-06-01T12:00:00.000Z",
551 }),
552 ],
553 environmentId: null,
554 searchQuery: "",
555 now: NOW,
556 });
557
558 // Since #7969 a settled thread leaves the active block even while pinned;
559 // the pin re-applies when the thread is un-settled.
560 expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "pinned-settled"]);
561 expect(layout.items.map((item) => item.pinned)).toEqual([false, false]);
562 expect(layout.settledCount).toBe(1);
563 });
564
565 it("snooze hides a pinned thread and wake restores it to the pinned block", () => {
566 const snoozedInput = {
567 threads: [
568 makeThread({ id: ThreadId.make("active"), title: "Active" }),
569 makeThread({
570 id: ThreadId.make("pinned-snoozed"),
571 title: "Pinned and snoozed",
572 pinnedAt: "2026-06-01T12:00:00.000Z",
573 snoozedUntil: "2026-06-03T09:00:00.000Z",
574 snoozedAt: "2026-06-01T11:00:00.000Z",
575 }),
576 ],
577 environmentId: null,
578 searchQuery: "",
579 };
580
581 // Before the wake time: the snooze wins; the pin holds underneath.
582 const whileSnoozed = buildThreadListV2Items({ ...snoozedInput, now: NOW });
583 expect(whileSnoozed.items.map((item) => item.thread.id)).toEqual(["active"]);
584 expect(whileSnoozed.snoozedCount).toBe(1);
585
586 // After the wake time: the thread returns pinned, back on top.
587 const afterWake = buildThreadListV2Items({ ...snoozedInput, now: "2026-06-03T10:00:00.000Z" });
588 expect(afterWake.items.map((item) => item.thread.id)).toEqual(["pinned-snoozed", "active"]);
589 expect(afterWake.items[0]?.pinned).toBe(true);
590 expect(afterWake.snoozedCount).toBe(0);
591 });
592
593 it("classifies snooze with the second-precise clock and reports the next wake", () => {
594 const layout = buildThreadListV2Items({
595 threads: [
596 makeThread({
597 id: ThreadId.make("just-woke"),
598 title: "Just woke",
599 // Woke 30s ago: hidden under the minute-floored clock, visible
600 // under the precise one.
601 snoozedUntil: "2026-06-02T00:00:30.000Z",
602 snoozedAt: "2026-06-01T12:00:00.000Z",
603 }),
604 makeThread({
605 id: ThreadId.make("still-snoozed"),
606 title: "Still snoozed",
607 snoozedUntil: "2026-06-02T09:00:00.000Z",
608 snoozedAt: "2026-06-01T12:00:00.000Z",
609 }),
610 ],
611 environmentId: null,
612 searchQuery: "",
613 now: "2026-06-02T00:01:07.500Z",
614 });
615
616 expect(layout.items.map((item) => item.thread.id)).toEqual(["just-woke"]);
617 expect(layout.snoozedCount).toBe(1);
618 expect(layout.nextSnoozeWakeAt).toBe("2026-06-02T09:00:00.000Z");
619 });
620
621 it("builds snoozed rows between active and settled when the shelf is expanded", () => {
622 const layout = buildThreadListV2Items({
623 threads: [
624 makeThread({ id: ThreadId.make("active"), title: "Active" }),
625 makeThread({
626 id: ThreadId.make("settled"),
627 title: "Settled",
628 settledOverride: "settled",
629 settledAt: NOW,
630 }),
631 makeThread({
632 id: ThreadId.make("later"),
633 title: "Wakes later",
634 snoozedUntil: "2026-06-03T09:00:00.000Z",
635 snoozedAt: "2026-06-01T12:00:00.000Z",
636 }),
637 makeThread({
638 id: ThreadId.make("sooner"),
639 title: "Wakes sooner",
640 snoozedUntil: "2026-06-02T09:00:00.000Z",
641 snoozedAt: "2026-06-01T12:00:00.000Z",
642 }),
643 ],
644 environmentId: null,
645 searchQuery: "",
646 now: NOW,
647 snoozedShelfExpanded: true,
648 });
649
650 expect(layout.items.map((item) => item.thread.id)).toEqual([
651 "active",
652 "sooner",
653 "later",
654 "settled",
655 ]);
656 expect(layout.items.map((item) => item.snoozed)).toEqual([false, true, true, false]);
657 expect(layout.snoozedShelfHeaderIndex).toBe(1);
658 expect(layout.snoozedCount).toBe(2);
659 });
660
661 it("collapses to a header-only shelf", () => {
662 const layout = buildThreadListV2Items({
663 threads: [
664 makeThread({
665 id: ThreadId.make("snoozed"),
666 title: "Snoozed",
667 snoozedUntil: "2026-06-03T09:00:00.000Z",
668 snoozedAt: "2026-06-01T12:00:00.000Z",
669 }),
670 ],
671 environmentId: null,
672 searchQuery: "",
673 now: NOW,
674 });
675
676 expect(layout.items).toEqual([]);
677 expect(layout.snoozedCount).toBe(1);
678 expect(layout.snoozedShelfHeaderIndex).toBe(0);
679 });
680
681 it("keeps the selected thread on a collapsed shelf", () => {
682 const layout = buildThreadListV2Items({
683 threads: [
684 makeThread({
685 id: ThreadId.make("open"),
686 title: "Open",
687 snoozedUntil: "2026-06-03T09:00:00.000Z",
688 snoozedAt: "2026-06-01T12:00:00.000Z",
689 }),
690 makeThread({
691 id: ThreadId.make("other"),
692 title: "Other",
693 snoozedUntil: "2026-06-03T10:00:00.000Z",
694 snoozedAt: "2026-06-01T12:00:00.000Z",
695 }),
696 ],
697 environmentId: null,
698 searchQuery: "",
699 now: NOW,
700 selectedThreadKey: `${environmentId}:open`,
701 });
702
703 expect(layout.items.map((item) => item.thread.id)).toEqual(["open"]);
704 expect(layout.items[0]?.snoozed).toBe(true);
705 expect(layout.snoozedCount).toBe(2);
706 });
707
708 it("keeps snoozed threads visible on environments without the snooze capability", () => {
709 const layout = buildThreadListV2Items({
710 threads: [
711 makeThread({
712 id: ThreadId.make("snoozed"),
713 title: "Snoozed",
714 snoozedUntil: "2026-06-03T09:00:00.000Z",
715 snoozedAt: "2026-06-01T12:00:00.000Z",
716 }),
717 ],
718 environmentId: null,
719 searchQuery: "",
720 snoozeEnvironmentIds: new Set(),
721 now: NOW,
722 });
723
724 expect(layout.items.map((item) => item.thread.id)).toEqual(["snoozed"]);
725 expect(layout.snoozedCount).toBe(0);
726 });
727
728 it("partitions settled threads into a slim shelf", () => {
729 const layout = buildThreadListV2Items({
730 threads: [
731 makeThread({ id: ThreadId.make("active"), title: "Active" }),
732 makeThread({
733 id: ThreadId.make("settled"),
734 title: "Settled",
735 settledOverride: "settled",
736 settledAt: NOW,
737 }),
738 makeThread({
739 id: ThreadId.make("settled-2"),
740 title: "Settled 2",
741 settledOverride: "settled",
742 settledAt: NOW,
743 }),
744 ],
745 environmentId: null,
746 searchQuery: "",
747 now: NOW,
748 });
749
750 expect(layout.items.map((item) => [item.thread.id, item.variant])).toEqual([
751 ["active", "card"],
752 ["settled", "slim"],
753 ["settled-2", "slim"],
754 ]);
755 expect(layout.items.map((item) => item.isLast)).toEqual([false, false, true]);
756 expect(layout.settledCount).toBe(2);
757 expect(layout.settledShelfHeaderIndex).toBe(1);
758 });
759
760 it("collapses settled threads to a counted shelf header", () => {
761 const layout = buildThreadListV2Items({
762 threads: [
763 makeThread({ id: ThreadId.make("active"), title: "Active" }),
764 makeThread({
765 id: ThreadId.make("settled"),
766 title: "Settled",
767 settledOverride: "settled",
768 settledAt: NOW,
769 }),
770 ],
771 environmentId: null,
772 searchQuery: "",
773 now: NOW,
774 settledShelfExpanded: false,
775 });
776
777 expect(layout.items.map((item) => item.thread.id)).toEqual(["active"]);
778 expect(layout.settledCount).toBe(1);
779 expect(layout.settledShelfHeaderIndex).toBe(1);
780 });
781
782 it("keeps the selected settled thread visible when its shelf is collapsed", () => {
783 const layout = buildThreadListV2Items({
784 threads: [
785 makeThread({
786 id: ThreadId.make("selected"),
787 title: "Selected",
788 settledOverride: "settled",
789 settledAt: NOW,
790 }),
791 makeThread({
792 id: ThreadId.make("other"),
793 title: "Other",
794 settledOverride: "settled",
795 settledAt: NOW,
796 }),
797 ],
798 environmentId: null,
799 searchQuery: "",
800 now: NOW,
801 settledShelfExpanded: false,
802 selectedThreadKey: `${environmentId}:selected`,
803 });
804
805 expect(layout.items.map((item) => item.thread.id)).toEqual(["selected"]);
806 expect(layout.settledCount).toBe(2);
807 expect(layout.settledShelfHeaderIndex).toBe(0);
808 });
809
810 it("keeps cards in creation order while settled sorts by recency", () => {
811 const { items } = buildThreadListV2Items({
812 threads: [
813 makeThread({
814 id: ThreadId.make("older-created"),
815 title: "Older",
816 createdAt: "2026-06-01T08:00:00.000Z",
817 updatedAt: NOW, // recent activity must NOT promote it
818 }),
819 makeThread({
820 id: ThreadId.make("newer-created"),
821 title: "Newer",
822 createdAt: "2026-06-01T12:00:00.000Z",
823 }),
824 ],
825 environmentId: null,
826 searchQuery: "",
827 now: NOW,
828 });
829
830 expect(items.map((item) => item.thread.id)).toEqual(["newer-created", "older-created"]);
831 });
832
833 it("sorts settled threads by their persisted settlement timestamp", () => {
834 const { items } = buildThreadListV2Items({
835 threads: [
836 makeThread({
837 id: ThreadId.make("settled-newer"),
838 title: "Settled newer",
839 settledOverride: "settled",
840 settledAt: "2026-06-01T12:00:00.000Z",
841 latestUserMessageAt: "2026-06-01T08:00:00.000Z",
842 }),
843 makeThread({
844 id: ThreadId.make("settled-older"),
845 title: "Settled older",
846 settledOverride: "settled",
847 settledAt: "2026-06-01T10:00:00.000Z",
848 latestUserMessageAt: "2026-06-01T09:00:00.000Z",
849 }),
850 ],
851 environmentId: null,
852 searchQuery: "",
853 now: NOW,
854 });
855
856 expect(items.map((item) => item.thread.id)).toEqual(["settled-newer", "settled-older"]);
857 });
858
859 it("keeps settled threads in the tail and filters by search query", () => {
860 const { items } = buildThreadListV2Items({
861 threads: [
862 makeThread({ id: ThreadId.make("match"), title: "Fix login bug" }),
863 makeThread({ id: ThreadId.make("miss"), title: "Greeting" }),
864 makeThread({
865 id: ThreadId.make("settled"),
866 title: "Fix login again",
867 settledOverride: "settled",
868 settledAt: NOW,
869 }),
870 ],
871 environmentId: null,
872 searchQuery: "login",
873 now: NOW,
874 });
875
876 expect(items.map((item) => [item.thread.id, item.variant])).toEqual([
877 ["match", "card"],
878 ["settled", "slim"],
879 ]);
880 });
881
882 it("includes a thread matched by message content", () => {
883 const thread = makeThread({
884 id: ThreadId.make("content-match"),
885 title: "Unrelated title",
886 });
887 const { items } = buildThreadListV2Items({
888 threads: [thread],
889 environmentId: null,
890 searchQuery: "relay reconnect",
891 matchedThreadKeys: new Set([
892 threadSearchMatchKey({
893 environmentId,
894 threadId: thread.id,
895 }),
896 ]),
897 now: NOW,
898 });
899
900 expect(items.map((item) => item.thread.id)).toEqual(["content-match"]);
901 });
902
903 it("scopes the flat list to one project", () => {
904 const projectId = ProjectId.make("project-1");
905 const otherProjectId = ProjectId.make("project-2");
906 const { items } = buildThreadListV2Items({
907 threads: [
908 makeThread({ id: ThreadId.make("included"), projectId, title: "Included" }),
909 makeThread({
910 id: ThreadId.make("excluded"),
911 projectId: otherProjectId,
912 title: "Excluded",
913 }),
914 ],
915 environmentId: null,
916 projectRefs: [{ environmentId, projectId }],
917 searchQuery: "",
918 now: NOW,
919 });
920
921 expect(items.map((item) => item.thread.id)).toEqual(["included"]);
922 });
923
924 it("scopes the flat list to every environment member of a logical project", () => {
925 const remoteEnvironmentId = EnvironmentId.make("environment-remote");
926 const projectId = ProjectId.make("project-1");
927 const { items } = buildThreadListV2Items({
928 threads: [
929 makeThread({ id: ThreadId.make("local"), projectId, title: "Local" }),
930 makeThread({
931 environmentId: remoteEnvironmentId,
932 id: ThreadId.make("remote"),
933 projectId,
934 title: "Remote",
935 }),
936 ],
937 environmentId: null,
938 projectRefs: [
939 { environmentId, projectId },
940 { environmentId: remoteEnvironmentId, projectId },
941 ],
942 searchQuery: "",
943 now: NOW,
944 });
945
946 expect(items.map((item) => item.thread.id)).toEqual(["local", "remote"]);
947 });
948});
949
950describe("buildThreadListV2Items settled paging", () => {
951 it("caps the settled tail at settledLimit and reports the hidden count", () => {
952 const threads = [
953 makeThread({ id: ThreadId.make("active"), title: "Active" }),
954 ...Array.from({ length: 4 }, (_, index) =>
955 makeThread({
956 id: ThreadId.make(`settled-${index}`),
957 title: `Settled ${index}`,
958 settledOverride: "settled",
959 settledAt: `2026-06-01T0${index}:10:00.000Z`,
960 latestUserMessageAt: `2026-06-01T0${index}:00:00.000Z`,
961 // A turn adopted the message (same requestedAt): without it the
962 // thread reads as a queued turn start, which never settles.
963 latestRun: {
964 runId: RunId.make(`run-${index}`),
965 status: "completed",
966 requestedAt: `2026-06-01T0${index}:00:00.000Z`,
967 startedAt: `2026-06-01T0${index}:00:00.000Z`,
968 completedAt: `2026-06-01T0${index}:10:00.000Z`,
969 assistantMessageId: null,
970 },
971 }),
972 ),
973 ];
974
975 const layout = buildThreadListV2Items({
976 threads,
977 environmentId: null,
978 searchQuery: "",
979 settledLimit: 2,
980 now: NOW,
981 });
982
983 expect(layout.hiddenSettledCount).toBe(2);
984 expect(layout.items.filter((item) => item.variant === "slim")).toHaveLength(2);
985 // Most recent settled first — the hidden ones are the oldest.
986 expect(layout.items.map((item) => item.thread.id)).toEqual([
987 "active",
988 "settled-3",
989 "settled-2",
990 ]);
991 });
992});
993
994describe("buildThreadListV2Items settled sort reuse", () => {
995 const otherEnvironmentId = EnvironmentId.make("environment-2");
996
997 function makeSettled(
998 id: string,
999 settledAt: string,
1000 overrides: Partial<EnvironmentThreadShell> = {},
1001 ): EnvironmentThreadShell {
1002 return makeThread({
1003 id: ThreadId.make(id),
1004 title: id,
1005 settledOverride: "settled",
1006 settledAt,
1007 ...overrides,
1008 });
1009 }
1010
1011 function settledOrder(
1012 threads: ReadonlyArray<EnvironmentThreadShell>,
1013 scope: EnvironmentId | null = null,
1014 ) {
1015 return buildThreadListV2Items({
1016 threads,
1017 environmentId: scope,
1018 searchQuery: "",
1019 now: NOW,
1020 })
1021 .items.filter((item) => item.variant === "slim")
1022 .map((item) => `${item.thread.environmentId}:${item.thread.id}`);
1023 }
1024
1025 function countDateParses(build: () => void): number {
1026 const spy = vi.spyOn(Date, "parse");
1027 try {
1028 build();
1029 return spy.mock.calls.length;
1030 } finally {
1031 spy.mockRestore();
1032 }
1033 }
1034
1035 const older = makeSettled("older", "2026-06-01T01:00:00.000Z");
1036 const newer = makeSettled("newer", "2026-06-01T03:00:00.000Z");
1037 const middle = makeSettled("middle", "2026-06-01T02:00:00.000Z");
1038
1039 it("orders settled rows newest first regardless of input order", () => {
1040 expect(settledOrder([older, newer, middle])).toEqual([
1041 `${environmentId}:newer`,
1042 `${environmentId}:middle`,
1043 `${environmentId}:older`,
1044 ]);
1045 expect(settledOrder([middle, older, newer])).toEqual([
1046 `${environmentId}:newer`,
1047 `${environmentId}:middle`,
1048 `${environmentId}:older`,
1049 ]);
1050 });
1051
1052 it("re-sorts when a settled row is replaced, added, or removed", () => {
1053 expect(settledOrder([older, newer])).toEqual([
1054 `${environmentId}:newer`,
1055 `${environmentId}:older`,
1056 ]);
1057 const resettledOlder = makeSettled("older", "2026-06-01T04:00:00.000Z");
1058 expect(settledOrder([resettledOlder, newer])).toEqual([
1059 `${environmentId}:older`,
1060 `${environmentId}:newer`,
1061 ]);
1062 expect(settledOrder([resettledOlder, newer, middle])).toEqual([
1063 `${environmentId}:older`,
1064 `${environmentId}:newer`,
1065 `${environmentId}:middle`,
1066 ]);
1067 expect(settledOrder([newer, middle])).toEqual([
1068 `${environmentId}:newer`,
1069 `${environmentId}:middle`,
1070 ]);
1071 });
1072
1073 it("re-sorts when the same settled rows arrive in a different order", () => {
1074 // Same id and timestamp on two environments: only the stable input order
1075 // breaks the tie, so a reordered input must not reuse the previous output.
1076 const first = makeSettled("twin", "2026-06-01T01:00:00.000Z");
1077 const second = makeSettled("twin", "2026-06-01T01:00:00.000Z", {
1078 environmentId: otherEnvironmentId,
1079 });
1080 expect(settledOrder([first, second])).toEqual([
1081 `${environmentId}:twin`,
1082 `${otherEnvironmentId}:twin`,
1083 ]);
1084 expect(settledOrder([second, first])).toEqual([
1085 `${otherEnvironmentId}:twin`,
1086 `${environmentId}:twin`,
1087 ]);
1088 });
1089
1090 it("skips the settled sort when only unsettled rows change", () => {
1091 const settled = Array.from({ length: 50 }, (_, index) =>
1092 makeSettled(`settled-${index}`, `2026-06-01T00:${String(index).padStart(2, "0")}:00.000Z`),
1093 );
1094 const streaming = makeThread({ id: ThreadId.make("streaming"), title: "Streaming" });
1095 const baseline = countDateParses(() => settledOrder([streaming, ...settled]));
1096 const updated = { ...streaming, updatedAt: "2026-06-01T23:00:00.000Z" };
1097 let order: string[] = [];
1098 const reused = countDateParses(() => {
1099 order = settledOrder([updated, ...settled]);
1100 });
1101
1102 expect(order).toEqual(settled.toReversed().map((thread) => `${environmentId}:${thread.id}`));
1103 // The settled sort parses every settled row at least once.
1104 expect(baseline - reused).toBeGreaterThanOrEqual(settled.length);
1105 });
1106
1107 it("recomputes when builds alternate between environments", () => {
1108 const remoteOlder = makeSettled("remote-older", "2026-06-01T01:30:00.000Z", {
1109 environmentId: otherEnvironmentId,
1110 });
1111 const remoteNewer = makeSettled("remote-newer", "2026-06-01T05:00:00.000Z", {
1112 environmentId: otherEnvironmentId,
1113 });
1114 const threads = [older, newer, remoteOlder, remoteNewer];
1115
1116 for (let round = 0; round < 2; round += 1) {
1117 expect(settledOrder(threads, environmentId)).toEqual([
1118 `${environmentId}:newer`,
1119 `${environmentId}:older`,
1120 ]);
1121 expect(settledOrder(threads, otherEnvironmentId)).toEqual([
1122 `${otherEnvironmentId}:remote-newer`,
1123 `${otherEnvironmentId}:remote-older`,
1124 ]);
1125 }
1126 });
1127});
1128
1129function makePendingTask(id: string): PendingNewTask {
1130 const creation = {
1131 projectId: ProjectId.make("project-1"),
1132 workspaceMode: "worktree" as const,
1133 branch: null,
1134 worktreePath: null,
1135 };
1136 return {
1137 kind: "pending",
1138 key: `pending-task:${id}`,
1139 environmentId,
1140 projectId: creation.projectId,
1141 projectTitle: undefined,
1142 projectCwd: undefined,
1143 branch: null,
1144 title: id,
1145 createdAt: NOW,
1146 message: {
1147 environmentId,
1148 threadId: ThreadId.make(`thread-${id}`),
1149 messageId: MessageId.make(id),
1150 commandId: CommandId.make(`command-${id}`),
1151 text: id,
1152 attachments: [],
1153 createdAt: NOW,
1154 creation,
1155 },
1156 creation,
1157 };
1158}
1159
1160describe("buildThreadListV2ListItems", () => {
1161 const layout = buildThreadListV2Items({
1162 threads: [
1163 makeThread({ id: ThreadId.make("active"), title: "active" }),
1164 makeThread({
1165 id: ThreadId.make("settled"),
1166 title: "settled",
1167 settledOverride: "settled",
1168 settledAt: NOW,
1169 }),
1170 ],
1171 environmentId: null,
1172 searchQuery: "",
1173 now: NOW,
1174 });
1175
1176 it("splices queued tasks between the active block and the settled tail", () => {
1177 const items = buildThreadListV2ListItems({
1178 items: layout.items,
1179 pendingTasks: [makePendingTask("queued-1"), makePendingTask("queued-2")],
1180 settledCount: layout.settledCount,
1181 settledShelfHeaderIndex: layout.settledShelfHeaderIndex,
1182 });
1183
1184 expect(
1185 items.map((item) =>
1186 item.type === "v2-pending"
1187 ? item.pendingTask.title
1188 : item.type === "v2-thread"
1189 ? item.item.thread.id
1190 : item.type === "v2-snoozed-shelf"
1191 ? "snoozed-shelf"
1192 : "settled-shelf",
1193 ),
1194 ).toEqual(["active", "queued-1", "queued-2", "settled-shelf", "settled"]);
1195 // Only the leading queued row labels the section, exactly like Settled.
1196 expect(
1197 items.filter((item) => item.type === "v2-pending" && item.showPendingDivider),
1198 ).toHaveLength(1);
1199 });
1200
1201 it("ends the list with queued tasks when nothing has settled yet", () => {
1202 const activeOnly = buildThreadListV2Items({
1203 threads: [makeThread({ id: ThreadId.make("active"), title: "active" })],
1204 environmentId: null,
1205 searchQuery: "",
1206 now: NOW,
1207 });
1208 const items = buildThreadListV2ListItems({
1209 items: activeOnly.items,
1210 pendingTasks: [makePendingTask("queued-1")],
1211 });
1212
1213 expect(items.map((item) => item.type)).toEqual(["v2-thread", "v2-pending"]);
1214 });
1215
1216 it("keeps the settled shelf between active and settled rows when nothing is queued", () => {
1217 const items = buildThreadListV2ListItems({
1218 items: layout.items,
1219 pendingTasks: [],
1220 settledCount: layout.settledCount,
1221 settledShelfHeaderIndex: layout.settledShelfHeaderIndex,
1222 });
1223
1224 expect(items.map((item) => item.key)).toEqual([
1225 `v2-thread:${environmentId}:active`,
1226 "v2-settled-shelf",
1227 `v2-thread:${environmentId}:settled`,
1228 ]);
1229 });
1230
1231 it("places queued tasks before a collapsed snoozed shelf", () => {
1232 const snoozedLayout = buildThreadListV2Items({
1233 threads: [
1234 makeThread({ id: ThreadId.make("active"), title: "active" }),
1235 makeThread({
1236 id: ThreadId.make("snoozed"),
1237 title: "snoozed",
1238 snoozedUntil: "2026-06-03T09:00:00.000Z",
1239 snoozedAt: "2026-06-01T12:00:00.000Z",
1240 }),
1241 makeThread({
1242 id: ThreadId.make("settled"),
1243 title: "settled",
1244 settledOverride: "settled",
1245 settledAt: NOW,
1246 }),
1247 ],
1248 environmentId: null,
1249 searchQuery: "",
1250 now: NOW,
1251 });
1252 const items = buildThreadListV2ListItems({
1253 items: snoozedLayout.items,
1254 pendingTasks: [makePendingTask("queued")],
1255 snoozedCount: snoozedLayout.snoozedCount,
1256 snoozedShelfExpanded: false,
1257 snoozedShelfHeaderIndex: snoozedLayout.snoozedShelfHeaderIndex,
1258 settledCount: snoozedLayout.settledCount,
1259 settledShelfHeaderIndex: snoozedLayout.settledShelfHeaderIndex,
1260 });
1261
1262 expect(items.map((item) => item.type)).toEqual([
1263 "v2-thread",
1264 "v2-pending",
1265 "v2-snoozed-shelf",
1266 "v2-settled-shelf",
1267 "v2-thread",
1268 ]);
1269 expect(threadJumpTarget(items, "thread.jump.1")?.id).toBe("active");
1270 expect(threadJumpTarget(items, "thread.jump.2")?.id).toBe("settled");
1271 expect(threadJumpTarget(items, "thread.jump.3")).toBeNull();
1272 });
1273});
1274
1275describe("pending mobile thread moves", () => {
1276 function fixture(section: "active" | "pinned" = "active") {
1277 const rows = ["a", "b", "c"].map((id, index) =>
1278 makeThread({
1279 id: ThreadId.make(id),
1280 title: id === "a" ? "hidden" : "match",
1281 createdAt: `2026-06-01T0${3 - index}:00:00.000Z`,
1282 pinnedAt: section === "pinned" ? `2026-06-01T0${3 - index}:00:00.000Z` : null,
1283 }),
1284 );
1285 const ordered = getThreadListV2OrderedSection({ threads: rows, section, now: NOW });
1286 const orderedIds = ordered.map((row) => `${row.environmentId}:${row.id}`);
1287 const movedId = orderedIds[2]!;
1288 const assignments = planPinnedMove({
1289 orderedIds,
1290 keysById: new Map(orderedIds.map((id) => [id, null])),
1291 movedId,
1292 direction: "up",
1293 })!;
1294 const pending = createPendingThreadOrder({
1295 section,
1296 ordered,
1297 movedId,
1298 direction: "up",
1299 assignments,
1300 });
1301 const update = (current: EnvironmentThreadShell[], assignment: (typeof assignments)[number]) =>
1302 current.map((row) =>
1303 `${row.environmentId}:${row.id}` === assignment.id
1304 ? {
1305 ...row,
1306 [section === "pinned" ? "pinOrderKey" : "activeOrderKey"]: assignment.orderKey,
1307 }
1308 : row,
1309 );
1310 return { rows, assignments, pending, update };
1311 }
1312
1313 function layout(
1314 rows: EnvironmentThreadShell[],
1315 pendingOrder: PendingThreadOrder | null,
1316 searchQuery = "",
1317 ) {
1318 return buildThreadListV2Items({
1319 threads: rows,
1320 pendingOrder,
1321 environmentId: null,
1322 searchQuery,
1323 now: NOW,
1324 }).items.map((item) => item.thread.id);
1325 }
1326
1327 it.each(["active", "pinned"] as const)(
1328 "holds %s order through every intermediate key upsert",
1329 (section) => {
1330 const { rows, assignments, pending, update } = fixture(section);
1331 let current = rows;
1332 let hold: PendingThreadOrder | null = pending;
1333 const desired = pending.orderedIds.map((id) => id.split(":")[1]);
1334 expect(layout(current, hold)).toEqual(desired);
1335 for (const assignment of assignments) {
1336 current = update(current, assignment);
1337 hold = reconcilePendingThreadOrder(
1338 hold!,
1339 getThreadListV2OrderedSection({ threads: current, section, now: NOW }),
1340 );
1341 expect(hold).not.toBeNull();
1342 expect(layout(current, hold)).toEqual(desired);
1343 }
1344 expect(reconcilePendingThreadOrder({ ...hold!, commandsComplete: true }, current)).toBeNull();
1345 expect(layout(current, null)).toEqual(desired);
1346 },
1347 );
1348
1349 it("keeps the action guard pending when receipts precede canonical shells", () => {
1350 const { rows, assignments, pending, update } = fixture();
1351 let hold: PendingThreadOrder | null = { ...pending, commandsComplete: true };
1352 let current = rows;
1353 expect(reconcilePendingThreadOrder(hold, current)).toBe(hold);
1354 for (const [index, assignment] of assignments.entries()) {
1355 current = update(current, assignment);
1356 hold = reconcilePendingThreadOrder(hold!, current);
1357 expect(hold === null).toBe(index === assignments.length - 1);
1358 expect(layout(current, hold)).toEqual(["a", "c", "b"]);
1359 }
1360 });
1361
1362 it("keeps search results in the full pending section order", () => {
1363 const { rows, assignments, pending, update } = fixture();
1364 const current = update(update(rows, assignments[0]!), assignments[1]!);
1365 expect(layout(current, pending, "match")).toEqual(["c", "b"]);
1366 });
1367
1368 it("releases for real section membership and foreign key changes", () => {
1369 const { rows, pending } = fixture();
1370 expect(reconcilePendingThreadOrder(pending, rows.slice(1))).toBeNull();
1371 const newRow = makeThread({ id: ThreadId.make("new"), title: "new" });
1372 expect(reconcilePendingThreadOrder(pending, [...rows, newRow])).toBeNull();
1373 expect(
1374 reconcilePendingThreadOrder(
1375 pending,
1376 rows.map((row, index) => (index === 0 ? { ...row, activeOrderKey: "zz" } : row)),
1377 ),
1378 ).toBeNull();
1379 const settled = rows.map((row, index) =>
1380 index === 0 ? { ...row, settledOverride: "settled" as const } : row,
1381 );
1382 expect(layout(settled, pending)).toEqual(layout(settled, null));
1383 });
1384
1385 it("does not hide a concurrent return to a previously confirmed key", () => {
1386 const { rows, assignments, pending, update } = fixture();
1387 const confirmed = reconcilePendingThreadOrder(pending, update(rows, assignments[0]!))!;
1388 expect(reconcilePendingThreadOrder(confirmed, rows)).toBeNull();
1389 });
1390
1391 it("preserves the hold for activity but releases for a reopened sort anchor", () => {
1392 const { rows, pending } = fixture();
1393 expect(
1394 reconcilePendingThreadOrder(
1395 pending,
1396 rows.map((row) => ({ ...row, updatedAt: NOW })),
1397 ),
1398 ).toBe(pending);
1399 expect(
1400 reconcilePendingThreadOrder(
1401 pending,
1402 rows.map((row, index) => (index === 0 ? { ...row, unsettledAt: NOW } : row)),
1403 ),
1404 ).toBeNull();
1405 });
1406});
1407
1408describe("mobile move availability", () => {
1409 const oldEnvironment = EnvironmentId.make("older-server");
1410 function rows(section: "active" | "pinned", keys: readonly (string | null)[]) {
1411 return keys.map((key, index) =>
1412 makeThread({
1413 id: ThreadId.make(`move-${index}`),
1414 title: `Move ${index}`,
1415 environmentId: index === 1 ? oldEnvironment : environmentId,
1416 activeOrderKey: section === "active" ? key : null,
1417 pinOrderKey: section === "pinned" ? key : null,
1418 pinnedAt: section === "pinned" ? NOW : null,
1419 }),
1420 );
1421 }
1422
1423 it.each(["active", "pinned"] as const)(
1424 "keeps unsupported keyed %s neighbors as usable anchors",
1425 (section) => {
1426 const ordered = rows(section, ["bb", "dd", "ff"]);
1427 const plan = createThreadMovePlanner({
1428 ordered,
1429 section,
1430 reorderableEnvironmentIds: new Set([environmentId]),
1431 });
1432 const assignments = plan(`${environmentId}:move-0`, "down");
1433 expect(assignments).toHaveLength(1);
1434 expect(assignments![0]!.id).toBe(`${environmentId}:move-0`);
1435 expect(assignments![0]!.orderKey > "dd").toBe(true);
1436 expect(assignments![0]!.orderKey < "ff").toBe(true);
1437 expect(plan(`${oldEnvironment}:move-1`, "up")).toBeNull();
1438 expect(plan(`${environmentId}:move-0`, "up")).toBeNull();
1439 },
1440 );
1441
1442 it.each(["active", "pinned"] as const)(
1443 "disables %s moves requiring unsupported keyless materialization",
1444 (section) => {
1445 const ordered = rows(section, [null, null, null]);
1446 const plan = createThreadMovePlanner({
1447 ordered,
1448 section,
1449 reorderableEnvironmentIds: new Set([environmentId]),
1450 });
1451 expect(plan(`${environmentId}:move-0`, "down")).toBeNull();
1452 expect(plan(`${environmentId}:move-2`, "up")).toBeNull();
1453 const supported = createThreadMovePlanner({
1454 ordered,
1455 section,
1456 reorderableEnvironmentIds: new Set([environmentId, oldEnvironment]),
1457 });
1458 expect(supported(`${environmentId}:move-0`, "down")).toHaveLength(3);
1459 },
1460 );
1461
1462 it.each(["active", "pinned"] as const)(
1463 "reserves snoozed %s keys when moving visible rows",
1464 (section) => {
1465 const ordered = rows(section, ["bb", "dd", "ff"]);
1466 const input = { ordered, section, reorderableEnvironmentIds: new Set([environmentId]) };
1467 const collision = createThreadMovePlanner(input)(`${environmentId}:move-0`, "down")![0]!
1468 .orderKey;
1469 const hidden = {
1470 ...ordered[0]!,
1471 id: ThreadId.make("snoozed"),
1472 snoozedAt: NOW,
1473 snoozedUntil: "2099-01-01T00:00:00.000Z",
1474 pinOrderKey: section === "pinned" ? collision : null,
1475 activeOrderKey: section === "active" ? collision : null,
1476 };
1477 const assignments = createThreadMovePlanner({ ...input, allThreads: [...ordered, hidden] })(
1478 `${environmentId}:move-0`,
1479 "down",
1480 );
1481 expect(assignments).toHaveLength(1);
1482 expect(assignments![0]!.orderKey).not.toBe(collision);
1483 expect(assignments![0]!.orderKey > "dd" && assignments![0]!.orderKey < "ff").toBe(true);
1484 },
1485 );
1486
1487 it("allows an independent keyed move despite an unsupported keyless row elsewhere", () => {
1488 const ordered = rows("active", [null, null, "bb", "dd", "ff"]);
1489 const plan = createThreadMovePlanner({
1490 ordered,
1491 section: "active",
1492 reorderableEnvironmentIds: new Set([environmentId]),
1493 });
1494 const assignments = plan(`${environmentId}:move-4`, "up");
1495 expect(assignments).toHaveLength(1);
1496 expect(assignments![0]!.id).toBe(`${environmentId}:move-4`);
1497 expect(assignments![0]!.orderKey > "bb").toBe(true);
1498 expect(assignments![0]!.orderKey < "dd").toBe(true);
1499 });
1500});
1501
1502describe("thread drag destinations", () => {
1503 it("moves across multiple rows while keeping hidden anchors in place", () => {
1504 expect(
1505 threadOrderAfterMove(["a", "hidden", "b", "c"], "c", {
1506 targetId: "a",
1507 placement: "before",
1508 }),
1509 ).toEqual(["c", "a", "hidden", "b"]);
1510 expect(
1511 threadOrderAfterMove(["a", "hidden", "b", "c"], "a", {
1512 targetId: "b",
1513 placement: "after",
1514 }),
1515 ).toEqual(["hidden", "b", "a", "c"]);
1516 });
1517
1518 it("rejects missing, self, and unchanged destinations", () => {
1519 for (const targetId of ["missing", "a", "b"]) {
1520 expect(
1521 threadOrderAfterMove(["a", "b", "c"], "a", {
1522 targetId,
1523 placement: "before",
1524 }),
1525 ).toBeNull();
1526 }
1527 expect(threadOrderAfterMove(["a", "b"], "missing", "down")).toBeNull();
1528 });
1529
1530 it.each(["active", "pinned"] as const)(
1531 "persists a dropped %s row and holds its order until confirmed",
1532 (section) => {
1533 const ordered = ["a", "b", "c", "d"].map((id) =>
1534 makeThread({
1535 id: ThreadId.make(id),
1536 title: id,
1537 pinnedAt: section === "pinned" ? NOW : null,
1538 }),
1539 );
1540 const ids = ordered.map((row) => `${row.environmentId}:${row.id}`);
1541 const direction = { targetId: ids[0]!, placement: "before" as const };
1542 const assignments = createThreadMovePlanner({
1543 ordered,
1544 section,
1545 reorderableEnvironmentIds: new Set([environmentId]),
1546 })(ids[3]!, direction)!;
1547 const pending = createPendingThreadOrder({
1548 section,
1549 ordered,
1550 movedId: ids[3]!,
1551 direction,
1552 assignments,
1553 });
1554 expect(pending.orderedIds).toEqual([ids[3], ids[0], ids[1], ids[2]]);
1555 const confirmed = ordered.map((row) => ({
1556 ...row,
1557 [section === "pinned" ? "pinOrderKey" : "activeOrderKey"]: assignments.find(
1558 (a) => a.id === `${row.environmentId}:${row.id}`,
1559 )!.orderKey,
1560 }));
1561 expect(
1562 getThreadListV2OrderedSection({ threads: confirmed, section, now: NOW }).map(
1563 (row) => `${row.environmentId}:${row.id}`,
1564 ),
1565 ).toEqual(pending.orderedIds);
1566 expect(
1567 reconcilePendingThreadOrder({ ...pending, commandsComplete: true }, confirmed),
1568 ).toBeNull();
1569 },
1570 );
1571
1572 it("refuses a drop that would need to rewrite an old server's keyless row", () => {
1573 const old = EnvironmentId.make("old-server");
1574 const ordered = [environmentId, old, environmentId].map((env, index) =>
1575 makeThread({
1576 id: ThreadId.make(String(index)),
1577 title: String(index),
1578 environmentId: env,
1579 }),
1580 );
1581 expect(
1582 createThreadMovePlanner({
1583 ordered,
1584 section: "active",
1585 reorderableEnvironmentIds: new Set([environmentId]),
1586 })(`${environmentId}:2`, { targetId: `${environmentId}:0`, placement: "before" }),
1587 ).toBeNull();
1588 });
1589});
1590
1591it("allows a long drop past an old server even when both adjacent moves fail", () => {
1592 const old = EnvironmentId.make("old-server");
1593 const ordered = [
1594 makeThread({ id: ThreadId.make("a"), title: "a" }),
1595 makeThread({ id: ThreadId.make("b"), title: "b", environmentId: old }),
1596 makeThread({ id: ThreadId.make("c"), title: "c", activeOrderKey: "h" }),
1597 makeThread({ id: ThreadId.make("d"), title: "d", activeOrderKey: "p" }),
1598 ];
1599 const planner = createThreadMovePlanner({
1600 ordered,
1601 section: "active",
1602 reorderableEnvironmentIds: new Set([environmentId]),
1603 });
1604 const movedId = `${environmentId}:a`;
1605 expect(planner(movedId, "up")).toBeNull();
1606 expect(planner(movedId, "down")).toBeNull();
1607 const assignments = planner(movedId, { targetId: `${environmentId}:d`, placement: "after" });
1608 expect(assignments).toHaveLength(1);
1609 expect(assignments![0]!.id).toBe(movedId);
1610 expect(assignments![0]!.orderKey > "p").toBe(true);
1611});
1612
1613describe("cross-section thread drops", () => {
1614 it.each(["pinned", "active"] as const)("inserts into an empty %s section", (section) => {
1615 const thread = makeThread({ id: ThreadId.make("source"), title: "source" });
1616 const id = `${thread.environmentId}:${thread.id}`;
1617 const destination = { section, targetId: null, placement: "before" as const };
1618 expect(threadOrderAfterMove([], id, destination)).toEqual([id]);
1619 const plan = createThreadMovePlanner({
1620 ordered: [],
1621 allThreads: [thread],
1622 section,
1623 reorderableEnvironmentIds: new Set([environmentId]),
1624 })(id, destination);
1625 expect(plan).toHaveLength(1);
1626 expect(plan![0]!.id).toBe(id);
1627 });
1628 it("places an incoming row between existing anchors without rewriting them", () => {
1629 const a = makeThread({ id: ThreadId.make("a"), title: "a", pinOrderKey: "h" });
1630 const b = makeThread({ id: ThreadId.make("b"), title: "b", pinOrderKey: "z" });
1631 const source = makeThread({ id: ThreadId.make("source"), title: "source" });
1632 const id = `${environmentId}:source`;
1633 const destination = {
1634 section: "pinned" as const,
1635 targetId: `${environmentId}:b`,
1636 placement: "before" as const,
1637 };
1638 const plan = createThreadMovePlanner({
1639 ordered: [a, b],
1640 allThreads: [a, b, source],
1641 section: "pinned",
1642 reorderableEnvironmentIds: new Set([environmentId]),
1643 })(id, destination);
1644 expect(plan).toHaveLength(1);
1645 expect(plan![0]!.orderKey > "h" && plan![0]!.orderKey < "z").toBe(true);
1646 expect(
1647 threadOrderAfterMove([`${environmentId}:a`, `${environmentId}:b`], id, destination),
1648 ).toEqual([`${environmentId}:a`, id, `${environmentId}:b`]);
1649 });
1650 it("rejects removed targets and unsupported incoming sources", () => {
1651 expect(
1652 threadOrderAfterMove(["a"], "source", {
1653 section: "active",
1654 targetId: "gone",
1655 placement: "before",
1656 }),
1657 ).toBeNull();
1658 const source = makeThread({ id: ThreadId.make("source"), title: "source" });
1659 expect(
1660 createThreadMovePlanner({
1661 ordered: [],
1662 allThreads: [source],
1663 section: "active",
1664 reorderableEnvironmentIds: new Set(),
1665 })(`${environmentId}:source`, { section: "active", targetId: null, placement: "before" }),
1666 ).toBeNull();
1667 });
1668 it("clears pinning, settlement and snooze when returning a parked thread to Active", () => {
1669 const thread = makeThread({
1670 id: ThreadId.make("parked"),
1671 title: "parked",
1672 pinnedAt: NOW,
1673 settledOverride: "settled",
1674 snoozedAt: NOW,
1675 snoozedUntil: "2099-01-01T00:00:00.000Z",
1676 });
1677 expect(threadDropLifecycle(thread, "active", NOW)).toEqual({
1678 pin: false,
1679 unpin: true,
1680 unsettle: true,
1681 unsnooze: true,
1682 });
1683 expect(threadDropLifecycle(thread, "pinned", NOW)).toEqual({
1684 pin: true,
1685 unpin: false,
1686 unsettle: false,
1687 unsnooze: false,
1688 });
1689 });
1690 it("does not send lifecycle commands for an ordinary Active reorder", () => {
1691 expect(
1692 threadDropLifecycle(
1693 makeThread({ id: ThreadId.make("active"), title: "active" }),
1694 "active",
1695 NOW,
1696 ),
1697 ).toEqual({ pin: false, unpin: false, unsettle: false, unsnooze: false });
1698 });
1699});
1700
1701it("excludes subagents from navigation, search and ordering while retaining user forks", () => {
1702 const root = makeThread({ id: ThreadId.make("root"), title: "Root" });
1703 const child = makeThread({
1704 id: ThreadId.make("child"),
1705 title: "Child",
1706 lineage: { parentThreadId: root.id, rootThreadId: root.id, relationshipToParent: "subagent" },
1707 });
1708 const fork = makeThread({
1709 id: ThreadId.make("fork"),
1710 title: "Fork",
1711 lineage: { parentThreadId: root.id, rootThreadId: root.id, relationshipToParent: "fork" },
1712 });
1713 const threads = [root, child, fork];
1714 expect(
1715 buildThreadListV2Items({ threads, environmentId: null, searchQuery: "", now: NOW }).items.map(
1716 (item) => item.thread.id,
1717 ),
1718 ).toEqual([fork.id, root.id]);
1719 expect(
1720 buildThreadListV2Items({ threads, environmentId: null, searchQuery: "Child", now: NOW }).items,
1721 ).toEqual([]);
1722 expect(
1723 getThreadListV2OrderedSection({ threads, section: "active", now: NOW }).map(
1724 (thread) => thread.id,
1725 ),
1726 ).toEqual([fork.id, root.id]);
1727});
1728
1729/* ─── Recycled-list equality + per-row clock scoping ─────────────────── */
1730
1731const BASE_MS = Date.parse(NOW);
1732const isoAt = (ms: number) => new Date(ms).toISOString();
1733const MINUTE_MS = 60_000;
1734
1735function runningRuntime() {
1736 return {
1737 status: "running" as const,
1738 providerName: "Codex",
1739 providerInstanceId: ProviderInstanceId.make("codex"),
1740 runtimeMode: "full-access" as const,
1741 activeRunId: null,
1742 lastError: null,
1743 updatedAt: NOW,
1744 };
1745}
1746
1747function buildTickThreads() {
1748 return {
1749 ready: makeThread({
1750 id: ThreadId.make("tick-ready"),
1751 title: "tick ready",
1752 latestUserMessageAt: isoAt(BASE_MS - 5 * MINUTE_MS),
1753 }),
1754 approval: makeThread({
1755 id: ThreadId.make("tick-approval"),
1756 title: "tick approval",
1757 hasPendingApprovals: true,
1758 latestUserMessageAt: isoAt(BASE_MS - 5 * MINUTE_MS),
1759 }),
1760 settled: makeThread({
1761 id: ThreadId.make("tick-settled"),
1762 title: "tick settled",
1763 settledOverride: "settled",
1764 settledAt: isoAt(BASE_MS - 3 * 24 * 60 * MINUTE_MS),
1765 }),
1766 snoozed: makeThread({
1767 id: ThreadId.make("tick-snoozed"),
1768 title: "tick snoozed",
1769 snoozedAt: isoAt(BASE_MS - MINUTE_MS),
1770 snoozedUntil: isoAt(BASE_MS + 2 * 60 * MINUTE_MS),
1771 }),
1772 };
1773}
1774
1775function buildTickList(
1776 threads: ReadonlyArray<EnvironmentThreadShell>,
1777 clockMs: number,
1778 pendingTasks: ReadonlyArray<PendingNewTask>,
1779 options?: {
1780 readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;
1781 readonly queuedThreadKeys?: ReadonlySet<string>;
1782 readonly moveAvailability?: ReadonlyMap<string, ThreadMoveAvailability>;
1783 readonly shelfPreferencesLoading?: boolean;
1784 },
1785): ThreadListV2ListItem[] {
1786 const now = isoAt(clockMs);
1787 const layout = buildThreadListV2Items({
1788 threads,
1789 environmentId: null,
1790 searchQuery: "",
1791 now,
1792 snoozedShelfExpanded: true,
1793 });
1794 return buildThreadListV2ListItems({
1795 items: layout.items,
1796 pendingTasks,
1797 snoozedCount: layout.snoozedCount,
1798 snoozedShelfExpanded: true,
1799 snoozedShelfHeaderIndex: layout.snoozedShelfHeaderIndex,
1800 settledCount: layout.settledCount,
1801 settledShelfHeaderIndex: layout.settledShelfHeaderIndex,
1802 snoozeLabelNow: now,
1803 ...(options?.snoozeEnvironmentIds
1804 ? { snoozeEnvironmentIds: options.snoozeEnvironmentIds }
1805 : {}),
1806 ...(options?.queuedThreadKeys ? { queuedThreadKeys: options.queuedThreadKeys } : {}),
1807 ...(options?.moveAvailability ? { moveAvailability: options.moveAvailability } : {}),
1808 ...(options?.shelfPreferencesLoading !== undefined
1809 ? { shelfPreferencesLoading: options.shelfPreferencesLoading }
1810 : {}),
1811 });
1812}
1813
1814function itemsByThreadKey(items: ReadonlyArray<ThreadListV2ListItem>) {
1815 const byKey = new Map<string, ThreadListV2ListItem>();
1816 for (const item of items) byKey.set(item.key, item);
1817 return byKey;
1818}
1819
1820describe("threadListV2ListItemsAreEqual", () => {
1821 const thread = makeThread({
1822 id: ThreadId.make("eq"),
1823 title: "eq",
1824 latestUserMessageAt: isoAt(BASE_MS - 5 * MINUTE_MS),
1825 });
1826 const layout = buildThreadListV2Items({
1827 threads: [thread],
1828 environmentId: null,
1829 searchQuery: "",
1830 now: NOW,
1831 });
1832 // One queued task object shared across builds: identity, not content, is
1833 // what the row equality compares (mirrors the store's stable references).
1834 const queued = makePendingTask("eq-queued");
1835 const build = () =>
1836 buildThreadListV2ListItems({
1837 items: layout.items,
1838 pendingTasks: [queued],
1839 snoozeLabelNow: NOW,
1840 });
1841
1842 it("treats rebuilt wrappers over identical rows as equal", () => {
1843 const first = build();
1844 const second = build();
1845 expect(first.length).toBe(second.length);
1846 for (let index = 0; index < first.length; index += 1) {
1847 expect(first[index]).not.toBe(second[index]);
1848 expect(threadListV2ListItemsAreEqual(first[index]!, second[index]!)).toBe(true);
1849 }
1850 });
1851
1852 it("notices a replaced thread shell", () => {
1853 const replacement = makeThread({ id: ThreadId.make("eq"), title: "renamed" });
1854 const rebuilt = buildThreadListV2Items({
1855 threads: [replacement],
1856 environmentId: null,
1857 searchQuery: "",
1858 now: NOW,
1859 });
1860 const next = buildThreadListV2ListItems({
1861 items: rebuilt.items,
1862 pendingTasks: [],
1863 snoozeLabelNow: NOW,
1864 });
1865 const previousThread = build().find((item) => item.type === "v2-thread")!;
1866 const nextThread = next.find((item) => item.type === "v2-thread")!;
1867 expect(threadListV2ListItemsAreEqual(previousThread, nextThread)).toBe(false);
1868 });
1869
1870 it("notices a changed wake countdown label", () => {
1871 const snoozedLayout = buildThreadListV2Items({
1872 threads: [
1873 makeThread({
1874 id: ThreadId.make("wake"),
1875 title: "wake",
1876 snoozedAt: NOW,
1877 snoozedUntil: isoAt(BASE_MS + 61 * MINUTE_MS),
1878 }),
1879 ],
1880 environmentId: null,
1881 searchQuery: "",
1882 now: NOW,
1883 snoozedShelfExpanded: true,
1884 });
1885 const earlier = buildThreadListV2ListItems({
1886 items: snoozedLayout.items,
1887 pendingTasks: [],
1888 snoozedShelfExpanded: true,
1889 snoozeLabelNow: NOW,
1890 });
1891 const later = buildThreadListV2ListItems({
1892 items: snoozedLayout.items,
1893 pendingTasks: [],
1894 snoozedShelfExpanded: true,
1895 snoozeLabelNow: isoAt(BASE_MS + MINUTE_MS),
1896 });
1897 const rows = (items: ThreadListV2ListItem[]) => {
1898 const row = items.find((item) => item.key === `v2-thread:${environmentId}:wake`);
1899 expect(row?.type).toBe("v2-thread");
1900 if (row?.type !== "v2-thread") throw new Error("missing snoozed row");
1901 return row;
1902 };
1903 expect(rows(earlier).snoozeWakeLabelText).toBe("2h");
1904 expect(rows(later).snoozeWakeLabelText).toBe("1h");
1905 expect(threadListV2ListItemsAreEqual(rows(earlier), rows(later))).toBe(false);
1906 });
1907
1908 it("notices shelf count, expansion, and loading-disabled changes", () => {
1909 const shelf = {
1910 type: "v2-settled-shelf",
1911 key: "v2-settled-shelf",
1912 count: 2,
1913 expanded: true,
1914 disabled: false,
1915 } as const;
1916 expect(threadListV2ListItemsAreEqual(shelf, { ...shelf })).toBe(true);
1917 expect(threadListV2ListItemsAreEqual(shelf, { ...shelf, count: 3 })).toBe(false);
1918 expect(threadListV2ListItemsAreEqual(shelf, { ...shelf, expanded: false })).toBe(false);
1919 // A recycled cell ignores the render closure, so the shelf header's
1920 // preference-loading disabled state has to ride on the item too.
1921 expect(threadListV2ListItemsAreEqual(shelf, { ...shelf, disabled: true })).toBe(false);
1922 });
1923
1924 it("treats different item kinds as unequal", () => {
1925 const built = build();
1926 const threadItem = built.find((item) => item.type === "v2-thread")!;
1927 const pendingItem = built.find((item) => item.type === "v2-pending")!;
1928 expect(threadListV2ListItemsAreEqual(threadItem, pendingItem)).toBe(false);
1929 });
1930
1931 it("notices a trailing-divider flip caused by a neighbour change", () => {
1932 const threadA = makeThread({ id: ThreadId.make("flip-a"), title: "flip a" });
1933 const threadB = makeThread({ id: ThreadId.make("flip-b"), title: "flip b" });
1934 const bare = buildThreadListV2ListItems({
1935 items: buildThreadListV2Items({
1936 threads: [threadA, threadB],
1937 environmentId: null,
1938 searchQuery: "",
1939 now: NOW,
1940 }).items,
1941 pendingTasks: [],
1942 snoozeLabelNow: NOW,
1943 });
1944 // The same shells with B settled: A now sits above the Settled section
1945 // rule instead of another row, so A's hairline must flip through the
1946 // recycled equality — its own shell reference never changed.
1947 const settledB = makeThread({
1948 id: ThreadId.make("flip-b"),
1949 title: "flip b",
1950 settledOverride: "settled",
1951 settledAt: NOW,
1952 });
1953 const withSettled = buildThreadListV2ListItems({
1954 items: buildThreadListV2Items({
1955 threads: [threadA, settledB],
1956 environmentId: null,
1957 searchQuery: "",
1958 now: NOW,
1959 }).items,
1960 pendingTasks: [],
1961 settledCount: 1,
1962 settledShelfHeaderIndex: 1,
1963 snoozeLabelNow: NOW,
1964 });
1965 const firstA = bare[0]!;
1966 const secondA = withSettled[0]!;
1967 expect(firstA.type === "v2-thread" && firstA.showTrailingDivider).toBe(true);
1968 expect(secondA.type === "v2-thread" && secondA.showTrailingDivider).toBe(false);
1969 expect(threadListV2ListItemsAreEqual(firstA, secondA)).toBe(false);
1970 });
1971});
1972
1973describe("isThreadListV2ListItem", () => {
1974 it("narrows the v2 kinds and rejects the legacy discriminators", () => {
1975 expect(isThreadListV2ListItem({ type: "v2-thread" })).toBe(true);
1976 expect(isThreadListV2ListItem({ type: "v2-pending" })).toBe(true);
1977 expect(isThreadListV2ListItem({ type: "v2-snoozed-shelf" })).toBe(true);
1978 expect(isThreadListV2ListItem({ type: "v2-settled-shelf" })).toBe(true);
1979 expect(isThreadListV2ListItem({ type: "thread" })).toBe(false);
1980 expect(isThreadListV2ListItem({ type: "v2-show-more" })).toBe(false);
1981 });
1982});
1983
1984describe("buildThreadListV2ListItems clock scoping", () => {
1985 const allEnvironments = new Set<EnvironmentId>([environmentId]);
1986 const tickQueued = () => [makePendingTask("tick-queued")];
1987
1988 it("carries the snooze menu clock on every row whose swipe menu offers presets", () => {
1989 const threads = buildTickThreads();
1990 const items = buildTickList(Object.values(threads), BASE_MS, tickQueued(), {
1991 snoozeEnvironmentIds: allEnvironments,
1992 });
1993 const byKey = itemsByThreadKey(items);
1994 const ready = byKey.get(`v2-thread:${environmentId}:tick-ready`)!;
1995 const approval = byKey.get(`v2-thread:${environmentId}:tick-approval`)!;
1996 const settled = byKey.get(`v2-thread:${environmentId}:tick-settled`)!;
1997 const snoozed = byKey.get(`v2-thread:${environmentId}:tick-snoozed`)!;
1998 expect(ready.type === "v2-thread" && ready.snoozePresetMinute).toBe(NOW);
1999 // The swipe-revealed snooze action exists on slim rows too (the variant
2000 // only swaps the primary action), so settled rows need the fresh clock.
2001 expect(settled.type === "v2-thread" && settled.snoozePresetMinute).toBe(NOW);
2002 // Approval rows are never snoozable; snoozed rows only offer Wake.
2003 expect(approval.type === "v2-thread" && approval.snoozePresetMinute).toBeUndefined();
2004 expect(snoozed.type === "v2-thread" && snoozed.snoozePresetMinute).toBeUndefined();
2005 });
2006
2007 it("keeps the snooze menu clock off rows on servers without the capability", () => {
2008 const threads = buildTickThreads();
2009 const items = buildTickList([threads.ready], BASE_MS, [], {
2010 snoozeEnvironmentIds: new Set<EnvironmentId>(),
2011 });
2012 const ready = items.find((item) => item.type === "v2-thread")!;
2013 expect(ready.type === "v2-thread" && ready.snoozePresetMinute).toBeUndefined();
2014 });
2015
2016 it("blanks the precomputed time for rows that render a label instead", () => {
2017 vi.useFakeTimers();
2018 try {
2019 vi.setSystemTime(BASE_MS);
2020 const working = makeThread({
2021 id: ThreadId.make("tick-working"),
2022 title: "tick working",
2023 latestUserMessageAt: isoAt(BASE_MS - 5 * MINUTE_MS),
2024 runtime: runningRuntime(),
2025 });
2026 const threads = buildTickThreads();
2027 const items = buildTickList(
2028 [threads.ready, working, threads.settled, threads.snoozed],
2029 BASE_MS,
2030 tickQueued(),
2031 );
2032 const byKey = itemsByThreadKey(items);
2033 const label = (key: string) => {
2034 const item = byKey.get(key)!;
2035 return item.type === "v2-thread" ? item.timeLabel : "<shelf>";
2036 };
2037 // Ready cards and settled/snoozed slim rows draw a time; the wake
2038 // countdown outranks the time on snoozed rows; cards with a status
2039 // label never draw one.
2040 expect(label(`v2-thread:${environmentId}:tick-ready`)).toBe("5m");
2041 expect(label(`v2-thread:${environmentId}:tick-working`)).toBe("");
2042 expect(label(`v2-thread:${environmentId}:tick-settled`)).toBe("3d");
2043 expect(label(`v2-thread:${environmentId}:tick-snoozed`)).toBe("");
2044 const snoozed = byKey.get(`v2-thread:${environmentId}:tick-snoozed`)!;
2045 expect(snoozed.type === "v2-thread" && snoozed.snoozeWakeLabelText).toBe("2h");
2046 } finally {
2047 vi.useRealTimers();
2048 }
2049 });
2050});
2051
2052describe("thread list v2 minute tick invalidation", () => {
2053 it("only invalidates rows whose clock-driven content moved", () => {
2054 vi.useFakeTimers();
2055 try {
2056 vi.setSystemTime(BASE_MS);
2057 const threads = buildTickThreads();
2058 const shellOrder = [threads.ready, threads.approval, threads.settled, threads.snoozed];
2059 // One queued-task reference shared by both builds: the store hands the
2060 // list the same pending-task objects between rebuilds.
2061 const pendingTasks = [makePendingTask("tick-queued")];
2062 const atStart = buildTickList(shellOrder, BASE_MS, pendingTasks);
2063 vi.setSystemTime(BASE_MS + MINUTE_MS);
2064 const atNextMinute = buildTickList(shellOrder, BASE_MS + MINUTE_MS, pendingTasks);
2065
2066 expect(atStart.length).toBe(atNextMinute.length);
2067 const invalidated: string[] = [];
2068 for (let index = 0; index < atStart.length; index += 1) {
2069 if (!threadListV2ListItemsAreEqual(atStart[index]!, atNextMinute[index]!)) {
2070 invalidated.push(atStart[index]!.key);
2071 }
2072 }
2073 // The ready row draws a minute-granular time and carries the snooze
2074 // menu, and the settled slim row's swipe-revealed snooze menu shows
2075 // preset times too, so both rows' menu content moved. Every other row
2076 // — the approval card (status label, never snoozable), the snoozed
2077 // shelf row ("2h" unchanged, Wake only), the shelf headers, and the
2078 // queued row — survives the tick untouched.
2079 expect(invalidated).toEqual([
2080 `v2-thread:${environmentId}:tick-ready`,
2081 `v2-thread:${environmentId}:tick-settled`,
2082 ]);
2083 } finally {
2084 vi.useRealTimers();
2085 }
2086 });
2087
2088 it("keeps unread completion labels stable across a minute tick", () => {
2089 const unread = makeThread({
2090 id: ThreadId.make("tick-unread"),
2091 title: "Unread completion",
2092 lastVisitedAt: isoAt(BASE_MS - 10 * MINUTE_MS),
2093 latestRun: {
2094 runId: RunId.make("tick-unread-run"),
2095 status: "completed",
2096 requestedAt: isoAt(BASE_MS - 6 * MINUTE_MS),
2097 startedAt: isoAt(BASE_MS - 6 * MINUTE_MS),
2098 completedAt: isoAt(BASE_MS - 5 * MINUTE_MS),
2099 assistantMessageId: null,
2100 },
2101 });
2102 const options = { snoozeEnvironmentIds: new Set<EnvironmentId>() };
2103 const first = buildTickList([unread], BASE_MS, [], options)[0]!;
2104 const next = buildTickList([unread], BASE_MS + MINUTE_MS, [], options)[0]!;
2105 expect(first.type === "v2-thread" && first.timeLabel).toBe("");
2106 expect(threadListV2ListItemsAreEqual(first, next)).toBe(true);
2107 });
2108
2109 it("keeps hour-granularity rows stable across a minute tick when they carry no snooze menu", () => {
2110 vi.useFakeTimers();
2111 try {
2112 vi.setSystemTime(BASE_MS);
2113 const staleReady = makeThread({
2114 id: ThreadId.make("tick-stale"),
2115 title: "tick stale",
2116 latestUserMessageAt: isoAt(BASE_MS - 3 * 60 * MINUTE_MS),
2117 });
2118 const atStart = buildTickList([staleReady], BASE_MS, [], {
2119 snoozeEnvironmentIds: new Set<EnvironmentId>(),
2120 });
2121 vi.setSystemTime(BASE_MS + MINUTE_MS);
2122 const atNextMinute = buildTickList([staleReady], BASE_MS + MINUTE_MS, [], {
2123 snoozeEnvironmentIds: new Set<EnvironmentId>(),
2124 });
2125 expect(threadListV2ListItemsAreEqual(atStart[0]!, atNextMinute[0]!)).toBe(true);
2126 } finally {
2127 vi.useRealTimers();
2128 }
2129 });
2130
2131 it("re-renders the snoozed countdown row when the wake label advances", () => {
2132 vi.useFakeTimers();
2133 try {
2134 const wakeAt = BASE_MS + 120 * MINUTE_MS;
2135 const snoozed = makeThread({
2136 id: ThreadId.make("tick-wake"),
2137 title: "tick wake",
2138 snoozedAt: isoAt(BASE_MS - MINUTE_MS),
2139 snoozedUntil: isoAt(wakeAt),
2140 });
2141 const wakeKey = `v2-thread:${environmentId}:tick-wake`;
2142 const wakeRow = (items: ThreadListV2ListItem[]) => itemsByThreadKey(items).get(wakeKey)!;
2143 vi.setSystemTime(BASE_MS);
2144 const atStart = buildTickList([snoozed], BASE_MS, []);
2145 vi.setSystemTime(BASE_MS + MINUTE_MS);
2146 const stillTwoHours = buildTickList([snoozed], BASE_MS + MINUTE_MS, []);
2147 expect(threadListV2ListItemsAreEqual(wakeRow(atStart), wakeRow(stillTwoHours))).toBe(true);
2148 // Minutes round up, so the countdown holds "2h" until the remaining
2149 // time drops to the hour boundary — and only that row flips when it
2150 // finally moves to minute granularity.
2151 vi.setSystemTime(BASE_MS + 61 * MINUTE_MS);
2152 const oneHour = buildTickList([snoozed], BASE_MS + 61 * MINUTE_MS, []);
2153 const oneHourRow = wakeRow(oneHour);
2154 expect(oneHourRow.type).toBe("v2-thread");
2155 expect(oneHourRow.type === "v2-thread" && oneHourRow.snoozeWakeLabelText).toBe("59m");
2156 expect(threadListV2ListItemsAreEqual(wakeRow(stillTwoHours), wakeRow(oneHour))).toBe(false);
2157 } finally {
2158 vi.useRealTimers();
2159 }
2160 });
2161});
2162
2163describe("buildThreadListV2ListItems trailing dividers", () => {
2164 it("follows the final neighbour order, not the pre-splice blocks", () => {
2165 const activeA = makeThread({ id: ThreadId.make("div-a"), title: "a" });
2166 const activeB = makeThread({ id: ThreadId.make("div-b"), title: "b" });
2167 const layout = buildThreadListV2Items({
2168 threads: [activeA, activeB],
2169 environmentId: null,
2170 searchQuery: "",
2171 now: NOW,
2172 });
2173 const items = buildThreadListV2ListItems({
2174 items: layout.items,
2175 pendingTasks: [makePendingTask("div-q1"), makePendingTask("div-q2")],
2176 snoozeLabelNow: NOW,
2177 });
2178 const dividers = items.map((item) =>
2179 item.type === "v2-thread" || item.type === "v2-pending" ? item.showTrailingDivider : "n/a",
2180 );
2181 // thread A | thread B | queued 1 | queued 2: consecutive threads keep
2182 // their hairlines, the row before the Unsent section rule loses its own,
2183 // queued rows divide each other, and the last row has nothing under it.
2184 expect(dividers).toEqual([true, false, true, false]);
2185 });
2186});
2187
2188describe("buildThreadListV2ListItems row-state stamps", () => {
2189 const readyThread = makeThread({
2190 id: ThreadId.make("stamp-ready"),
2191 title: "stamp ready",
2192 latestUserMessageAt: isoAt(BASE_MS - 5 * MINUTE_MS),
2193 });
2194 const settledThread = makeThread({
2195 id: ThreadId.make("stamp-settled"),
2196 title: "stamp settled",
2197 settledOverride: "settled",
2198 settledAt: isoAt(BASE_MS - 3 * 24 * 60 * MINUTE_MS),
2199 });
2200 const allEnvironments = new Set<EnvironmentId>([environmentId]);
2201
2202 it("stamps queued outbox messages onto the matching row and notices removal", () => {
2203 // An outbox write never touches the thread shell, so the queued icon has
2204 // to ride on the item for the recycled cell to ever update it.
2205 const queued = buildTickList([readyThread, settledThread], BASE_MS, [], {
2206 queuedThreadKeys: new Set([`${environmentId}:stamp-ready`]),
2207 snoozeEnvironmentIds: allEnvironments,
2208 });
2209 const plain = buildTickList([readyThread, settledThread], BASE_MS, [], {
2210 queuedThreadKeys: new Set<string>(),
2211 snoozeEnvironmentIds: allEnvironments,
2212 });
2213 const byKey = (items: ThreadListV2ListItem[]) => itemsByThreadKey(items);
2214 const readyQueued = byKey(queued).get(`v2-thread:${environmentId}:stamp-ready`)!;
2215 const readyPlain = byKey(plain).get(`v2-thread:${environmentId}:stamp-ready`)!;
2216 const settledQueued = byKey(queued).get(`v2-thread:${environmentId}:stamp-settled`)!;
2217 const settledPlain = byKey(plain).get(`v2-thread:${environmentId}:stamp-settled`)!;
2218 expect(readyQueued.type === "v2-thread" && readyQueued.hasQueuedMessages).toBe(true);
2219 expect(readyPlain.type === "v2-thread" && readyPlain.hasQueuedMessages).toBe(false);
2220 expect(threadListV2ListItemsAreEqual(readyQueued, readyPlain)).toBe(false);
2221 // The neighbour row is untouched by the outbox change.
2222 expect(threadListV2ListItemsAreEqual(settledQueued, settledPlain)).toBe(true);
2223 });
2224
2225 it("notices move-availability changes on card rows without a shell update", () => {
2226 const permissive = new Map([
2227 [`${environmentId}:stamp-ready`, { canMoveUp: true, canMoveDown: true }],
2228 [`${environmentId}:stamp-settled`, { canMoveUp: true, canMoveDown: true }],
2229 ]);
2230 const blocked = new Map([
2231 [`${environmentId}:stamp-settled`, { canMoveUp: true, canMoveDown: true }],
2232 ]);
2233 const open = buildTickList([readyThread, settledThread], BASE_MS, [], {
2234 moveAvailability: permissive,
2235 snoozeEnvironmentIds: allEnvironments,
2236 });
2237 const closed = buildTickList([readyThread, settledThread], BASE_MS, [], {
2238 moveAvailability: blocked,
2239 snoozeEnvironmentIds: allEnvironments,
2240 });
2241 const readyOpen = itemsByThreadKey(open).get(`v2-thread:${environmentId}:stamp-ready`)!;
2242 const readyClosed = itemsByThreadKey(closed).get(`v2-thread:${environmentId}:stamp-ready`)!;
2243 expect(readyOpen.type === "v2-thread" && readyOpen.canMoveUp).toBe(true);
2244 expect(readyClosed.type === "v2-thread" && readyClosed.canMoveUp).toBe(false);
2245 expect(threadListV2ListItemsAreEqual(readyOpen, readyClosed)).toBe(false);
2246 // Slim rows never carry the move actions, so availability is inert there.
2247 const settledOpen = itemsByThreadKey(open).get(`v2-thread:${environmentId}:stamp-settled`)!;
2248 const settledClosed = itemsByThreadKey(closed).get(`v2-thread:${environmentId}:stamp-settled`)!;
2249 expect(settledOpen.type === "v2-thread" && settledOpen.canMoveUp).toBe(false);
2250 expect(threadListV2ListItemsAreEqual(settledOpen, settledClosed)).toBe(true);
2251 });
2252
2253 it("keeps the settled slim row's swipe snooze menu fresh across a tick", () => {
2254 vi.useFakeTimers();
2255 try {
2256 vi.setSystemTime(BASE_MS);
2257 const atStart = buildTickList([settledThread], BASE_MS, [], {
2258 snoozeEnvironmentIds: allEnvironments,
2259 });
2260 vi.setSystemTime(BASE_MS + MINUTE_MS);
2261 const atNextMinute = buildTickList([settledThread], BASE_MS + MINUTE_MS, [], {
2262 snoozeEnvironmentIds: allEnvironments,
2263 });
2264 const rowAtStart = atStart.find((item) => item.type === "v2-thread")!;
2265 const rowAtNext = atNextMinute.find((item) => item.type === "v2-thread")!;
2266 // The swipe-revealed secondary action carries the snooze preset menu on
2267 // slim rows; its minute clock must move, or the displayed wake times
2268 // drift while the row is recycled-stable.
2269 expect(rowAtStart.type === "v2-thread" && rowAtStart.item.variant).toBe("slim");
2270 expect(rowAtStart.type === "v2-thread" && rowAtStart.snoozePresetMinute).toBe(isoAt(BASE_MS));
2271 expect(rowAtNext.type === "v2-thread" && rowAtNext.snoozePresetMinute).toBe(
2272 isoAt(BASE_MS + MINUTE_MS),
2273 );
2274 expect(threadListV2ListItemsAreEqual(rowAtStart, rowAtNext)).toBe(false);
2275 } finally {
2276 vi.useRealTimers();
2277 }
2278 });
2279
2280 it("stamps the shelf loading-disabled state so recycled headers refresh", () => {
2281 const loading = buildTickList([settledThread], BASE_MS, [], {
2282 shelfPreferencesLoading: true,
2283 snoozeEnvironmentIds: allEnvironments,
2284 });
2285 const loaded = buildTickList([settledThread], BASE_MS, [], {
2286 shelfPreferencesLoading: false,
2287 snoozeEnvironmentIds: allEnvironments,
2288 });
2289 const shelfLoading = loading.find((item) => item.type === "v2-settled-shelf")!;
2290 const shelfLoaded = loaded.find((item) => item.type === "v2-settled-shelf")!;
2291 expect(shelfLoading.type === "v2-settled-shelf" && shelfLoading.disabled).toBe(true);
2292 expect(shelfLoaded.type === "v2-settled-shelf" && shelfLoaded.disabled).toBe(false);
2293 expect(threadListV2ListItemsAreEqual(shelfLoading, shelfLoaded)).toBe(false);
2294 });
2295});
2296
2297describe("Working section beta", () => {
2298 const running = {
2299 status: "running" as const,
2300 activeRunId: null,
2301 providerInstanceId: ProviderInstanceId.make("codex"),
2302 providerName: "Codex",
2303 lastError: null,
2304 updatedAt: NOW,
2305 };
2306 const finishedAt = (id: string, completedAt: string) =>
2307 makeThread({
2308 id: ThreadId.make(id),
2309 title: id,
2310 createdAt: "2026-06-01T00:00:00.000Z",
2311 latestRun: {
2312 runId: RunId.make(`run-${id}`),
2313 status: "completed",
2314 requestedAt: "2026-06-01T00:00:00.000Z",
2315 startedAt: "2026-06-01T00:00:00.000Z",
2316 completedAt,
2317 assistantMessageId: null,
2318 },
2319 });
2320 const threads = [
2321 finishedAt("finished-early", "2026-06-01T01:00:00.000Z"),
2322 finishedAt("finished-late", "2026-06-01T03:00:00.000Z"),
2323 makeThread({ id: ThreadId.make("working"), title: "working", runtime: running }),
2324 makeThread({
2325 id: ThreadId.make("asks-approval"),
2326 title: "asks-approval",
2327 createdAt: "2026-06-01T02:00:00.000Z",
2328 runtime: running,
2329 hasPendingApprovals: true,
2330 }),
2331 makeThread({
2332 id: ThreadId.make("pinned-working"),
2333 title: "pinned-working",
2334 runtime: running,
2335 pinnedAt: "2026-06-01T00:00:00.000Z",
2336 }),
2337 ];
2338 const build = (input: Partial<Parameters<typeof buildThreadListV2Items>[0]> = {}) =>
2339 buildThreadListV2Items({
2340 threads,
2341 environmentId: null,
2342 searchQuery: "",
2343 now: NOW,
2344 workingShelfEnabled: true,
2345 ...input,
2346 });
2347 const ids = (layout: ReturnType<typeof buildThreadListV2Items>) =>
2348 layout.items.map((item) => item.thread.id);
2349
2350 it("folds unpinned working threads into a collapsed shelf", () => {
2351 const layout = build();
2352 expect(ids(layout)).toEqual([
2353 "pinned-working",
2354 "finished-late",
2355 "asks-approval",
2356 "finished-early",
2357 ]);
2358 expect(layout.workingCount).toBe(1);
2359 expect(layout.workingShelfHeaderIndex).toBe(4);
2360
2361 const off = build({ workingShelfEnabled: false });
2362 expect(ids(off)).toContain("working");
2363 expect(off.workingCount).toBe(0);
2364 });
2365
2366 it.each([true, false])("classifies a Limited thread with auto-resume %s", (autoResume) => {
2367 const runId = RunId.make("limited-run");
2368 const resetAt = "2026-06-02T02:00:00.000Z";
2369 const limited = presentThreadShell(
2370 environmentId,
2371 makeRawThreadShell({
2372 id: ThreadId.make("limited"),
2373 latestRunId: runId,
2374 status: "failed",
2375 latestRunCompletedAt: DateTime.makeUnsafe("2026-06-01T23:00:00.000Z"),
2376 lastError: "Plan limit reached",
2377 lastErrorClass: "usage_limit",
2378 usageLimitResetAt: resetAt,
2379 limitRecovery: { runId, resetAt, autoResume },
2380 }),
2381 );
2382 const layout = build({ threads: [limited] });
2383
2384 expect(layout.workingCount).toBe(autoResume ? 1 : 0);
2385 expect(ids(layout)).toEqual(autoResume ? [] : ["limited"]);
2386 expect(resolveThreadListV2Status(limited)).toBe("limited");
2387 expect(ids(build({ threads: [limited], workingShelfExpanded: true }))).toEqual(["limited"]);
2388 expect(ids(build({ threads: [limited], workingShelfEnabled: false }))).toEqual(["limited"]);
2389
2390 const snoozed = build({
2391 threads: [{ ...limited, snoozedUntil: resetAt, snoozedAt: NOW }],
2392 });
2393 expect(snoozed.snoozedCount).toBe(1);
2394 expect(snoozed.workingCount).toBe(0);
2395 });
2396
2397 it("shows working rows as cards when expanded, or only the selected one when collapsed", () => {
2398 expect(build({ workingShelfExpanded: true }).items.at(-1)).toMatchObject({
2399 thread: { id: "working" },
2400 variant: "card",
2401 });
2402 expect(ids(build({ selectedThreadKey: `${environmentId}:working` })).at(-1)).toBe("working");
2403 });
2404
2405 it("orders the inbox by the latest return this device observed", () => {
2406 const layout = build({
2407 inboxReturnAt: (thread) =>
2408 thread.id === "finished-early" ? Date.parse("2026-06-01T04:00:00.000Z") : undefined,
2409 });
2410 expect(ids(layout).slice(1)).toEqual(["finished-early", "finished-late", "asks-approval"]);
2411 });
2412
2413 it("places the shelf after queued tasks and before snoozed and settled threads", () => {
2414 const layout = buildThreadListV2Items({
2415 threads: [
2416 makeThread({ id: ThreadId.make("active"), title: "active" }),
2417 makeThread({ id: ThreadId.make("working"), title: "working", runtime: running }),
2418 makeThread({
2419 id: ThreadId.make("snoozed"),
2420 title: "snoozed",
2421 runtime: running,
2422 snoozedUntil: "2026-06-03T09:00:00.000Z",
2423 snoozedAt: "2026-06-01T12:00:00.000Z",
2424 }),
2425 makeThread({
2426 id: ThreadId.make("settled"),
2427 title: "settled",
2428 settledOverride: "settled",
2429 settledAt: NOW,
2430 }),
2431 ],
2432 environmentId: null,
2433 searchQuery: "",
2434 now: NOW,
2435 workingShelfEnabled: true,
2436 workingShelfExpanded: true,
2437 snoozedShelfExpanded: true,
2438 });
2439 const items = buildThreadListV2ListItems({
2440 items: layout.items,
2441 pendingTasks: [makePendingTask("queued")],
2442 workingCount: layout.workingCount,
2443 workingShelfExpanded: true,
2444 workingShelfHeaderIndex: layout.workingShelfHeaderIndex,
2445 snoozedCount: layout.snoozedCount,
2446 snoozedShelfExpanded: true,
2447 snoozedShelfHeaderIndex: layout.snoozedShelfHeaderIndex,
2448 settledCount: layout.settledCount,
2449 settledShelfHeaderIndex: layout.settledShelfHeaderIndex,
2450 });
2451 expect(
2452 items.map((item) =>
2453 item.type === "v2-thread"
2454 ? item.item.thread.id
2455 : item.type === "v2-pending"
2456 ? item.pendingTask.title
2457 : item.type,
2458 ),
2459 ).toEqual([
2460 "active",
2461 "queued",
2462 "v2-working-shelf",
2463 "working",
2464 "v2-snoozed-shelf",
2465 "snoozed",
2466 "v2-settled-shelf",
2467 "settled",
2468 ]);
2469 });
2470});