1import { presentThreadShell } from "@t3tools/client-runtime/state/models";
2import * as DateTime from "effect/DateTime";
3import { deriveActiveWorkStartedAt } from "../session-logic.ts";
4import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
5import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable";
6import * as Cause from "effect/Cause";
7import { AsyncResult } from "effect/reactivity";
8import {
9 animateSidebarLayoutChanges,
10 archiveSelectedThreadEntries,
11 buildBulkTitleRegenerationContextMenuItem,
12 buildBulkUnpinContextMenuItem,
13 buildMultiSelectThreadContextMenuItems,
14 createThreadJumpHintVisibilityController,
15 deleteSelectedThreadEntries,
16 filterSidebarProjectScopeItems,
17 filterSidebarV2VisibleThreads,
18 formatWorkingDurationLabel,
19 getFallbackThreadIdAfterDelete,
20 getProjectSortTimestamp,
21 getSidebarForkParentThreadId,
22 getSidebarThreadIdsToPrewarm,
23 hasUnseenCompletion,
24 isContextMenuPointerDown,
25 isSidebarSubagentThread,
26 isSidebarThreadWorking,
27 isTrailingDoubleClick,
28 orderItemsByPreferredIds,
29 pinOrderKeyBetween,
30 reduceSidebarProjectScopeMenuState,
31 resolveAdjacentThreadId,
32 resolveProjectStatusIndicator,
33 resolveSidebarSweepKeys,
34 resolveSidebarStageBadgeLabel,
35 resolveSidebarThreadSection,
36 resolveSidebarRowAccessibility,
37 resolveSidebarThreadStatus,
38 resolveSidebarV2TopStatus,
39 resolveThreadLastVisitedAt,
40 resolveThreadStatusPill,
41 resolveWorkingStartedAt,
42 pullRequestMatchesSearchQuery,
43 searchSidebarThreads,
44 shouldClearThreadSelectionOnMouseDown,
45 shouldShowSidebarV2Duration,
46 shouldRecedeSidebarThread,
47 sortLogicalProjectsForSidebar,
48 sortInboxThreadsByReturn,
49 resolveSidebarDropTarget,
50 planSidebarThreadDrop,
51 sortPinnedThreadsForSidebar,
52 sortProjectsForSidebar,
53 sortScopedProjectsForSidebar,
54 sortSidebarV2ProjectGroups,
55 shouldNavigateAfterThreadPark,
56 THREAD_JUMP_HINT_SHOW_DELAY_MS,
57 type SidebarListItem,
58 type SidebarListMarker,
59 type SidebarSection,
60 resolveSidebarDropVerb,
61} from "./Sidebar.logic";
62import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
63import {
64 CommandId,
65 EnvironmentId,
66 OrchestrationV2DispatchCommandError,
67 ProjectId,
68 ProviderInstanceId,
69 RunId,
70 ThreadId,
71} from "@t3tools/contracts";
72
73import {
74 DEFAULT_INTERACTION_MODE,
75 DEFAULT_RUNTIME_MODE,
76 type Project,
77 type Thread,
78} from "../types";
79import { makeThreadFixture, type ThreadFixtureOverrides } from "../test-fixtures";
80
81const localEnvironmentId = EnvironmentId.make("environment-local");
82
83describe("resolveSidebarRowAccessibility", () => {
84 it.each([
85 {
86 title: "Can you audit the UI?",
87 statusLabel: "Working",
88 projectDisplayName: "T3 Code",
89 isActive: true,
90 expected: { label: "Can you audit the UI?, Working, T3 Code", current: "page" },
91 },
92 {
93 title: "The audit is done",
94 statusLabel: null,
95 projectDisplayName: "T3 Code",
96 isActive: false,
97 expected: { label: "The audit is done, T3 Code", current: undefined },
98 },
99 {
100 title: "Untitled task",
101 statusLabel: null,
102 projectDisplayName: null,
103 isActive: false,
104 expected: { label: "Untitled task", current: undefined },
105 },
106 ])("leads with the title without folding row actions into its name: %j", (input) => {
107 const { expected, ...state } = input;
108 expect(resolveSidebarRowAccessibility(state)).toEqual(expected);
109 });
110});
111
112describe("animateSidebarLayoutChanges", () => {
113 const baseArgs: Parameters<AnimateLayoutChanges>[0] = {
114 active: null,
115 containerId: "pinned-threads",
116 isDragging: false,
117 isSorting: false,
118 id: "thread-a",
119 index: 1,
120 items: ["thread-b", "thread-a"],
121 newIndex: 0,
122 previousItems: ["thread-a", "thread-b"],
123 previousContainerId: "pinned-threads",
124 transition: { duration: 200, easing: "ease" },
125 wasDragging: true,
126 };
127
128 it("does not replay layout movement after the pointer is released", () => {
129 expect(defaultAnimateLayoutChanges(baseArgs)).toBe(true);
130 expect(animateSidebarLayoutChanges(baseArgs)).toBe(false);
131 });
132
133 it("keeps layout movement while the user is sorting", () => {
134 expect(animateSidebarLayoutChanges({ ...baseArgs, isSorting: true })).toBe(true);
135 });
136});
137
138describe("resolveSidebarThreadSection", () => {
139 it("keeps a pinned thread in the pinned shelf", () => {
140 expect(resolveSidebarThreadSection({ snoozed: false, settled: false, pinned: true })).toBe(
141 "pinned",
142 );
143 });
144
145 it("keeps lifecycle shelves authoritative over a stale pin", () => {
146 expect(resolveSidebarThreadSection({ snoozed: true, settled: true, pinned: true })).toBe(
147 "snoozed",
148 );
149 expect(resolveSidebarThreadSection({ snoozed: false, settled: true, pinned: true })).toBe(
150 "settled",
151 );
152 });
153});
154
155describe("deleteSelectedThreadEntries", () => {
156 const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const;
157 const success = AsyncResult.success(undefined);
158 const failure = AsyncResult.failure(Cause.fail(new Error("Delete failed")));
159 const interrupted = AsyncResult.failure(Cause.interrupt());
160
161 it("waits for each delete and excludes only earlier successes from worktree checks", async () => {
162 let resolveDelete!: (result: typeof success) => void;
163 const pendingDelete = new Promise<typeof success>((resolve) => {
164 resolveDelete = resolve;
165 });
166 const worktreeChecks: { threadKey: string; deletedThreadKeys: string[] }[] = [];
167 const deletion = deleteSelectedThreadEntries({
168 entries,
169 delete: async ({ threadKey }, deletedThreadKeys) => {
170 worktreeChecks.push({ threadKey, deletedThreadKeys: [...deletedThreadKeys] });
171 return threadKey === "one" ? pendingDelete : success;
172 },
173 });
174
175 expect(worktreeChecks).toEqual([{ threadKey: "one", deletedThreadKeys: [] }]);
176 resolveDelete(success);
177 const outcome = await deletion;
178
179 expect(worktreeChecks).toEqual([
180 { threadKey: "one", deletedThreadKeys: [] },
181 { threadKey: "two", deletedThreadKeys: ["one"] },
182 { threadKey: "three", deletedThreadKeys: ["one", "two"] },
183 ]);
184 expect(outcome).toEqual({
185 deletedThreadKeys: new Set(["one", "two", "three"]),
186 firstFailure: null,
187 });
188 });
189
190 it("continues after ordinary failures and keeps the first failure", async () => {
191 const laterFailure = AsyncResult.failure(Cause.fail(new Error("Later failure")));
192 const deletedKeysAtLastEntry: string[][] = [];
193 const outcome = await deleteSelectedThreadEntries({
194 entries: [...entries, { threadKey: "four" }],
195 delete: async ({ threadKey }, deletedThreadKeys) => {
196 if (threadKey === "one") return failure;
197 if (threadKey === "three") return laterFailure;
198 if (threadKey === "four") deletedKeysAtLastEntry.push([...deletedThreadKeys]);
199 return success;
200 },
201 });
202
203 expect(deletedKeysAtLastEntry).toEqual([["two"]]);
204 expect(outcome).toEqual({
205 deletedThreadKeys: new Set(["two", "four"]),
206 firstFailure: failure,
207 });
208 });
209
210 it.each([
211 { firstResult: success, deletedThreadKeys: new Set(["one"]), firstFailure: null },
212 { firstResult: failure, deletedThreadKeys: new Set<string>(), firstFailure: failure },
213 ])("stops on interruption and preserves earlier results %#", async (testCase) => {
214 const attemptedThreadKeys: string[] = [];
215 const outcome = await deleteSelectedThreadEntries({
216 entries,
217 delete: async ({ threadKey }) => {
218 attemptedThreadKeys.push(threadKey);
219 return threadKey === "one" ? testCase.firstResult : interrupted;
220 },
221 });
222
223 expect(attemptedThreadKeys).toEqual(["one", "two"]);
224 expect(outcome).toEqual({
225 deletedThreadKeys: testCase.deletedThreadKeys,
226 firstFailure: testCase.firstFailure,
227 });
228 });
229
230 it("does not count a skipped entry as deleted", async () => {
231 const visibleEntries = new Set(entries.map(({ threadKey }) => threadKey));
232 const worktreeChecks: string[][] = [];
233 const outcome = await deleteSelectedThreadEntries({
234 entries,
235 delete: async ({ threadKey }, deletedThreadKeys) => {
236 if (!visibleEntries.has(threadKey)) return null;
237 worktreeChecks.push([...deletedThreadKeys]);
238 visibleEntries.delete("two");
239 return success;
240 },
241 });
242
243 expect(worktreeChecks).toEqual([[], ["one"]]);
244 expect(outcome).toEqual({
245 deletedThreadKeys: new Set(["one", "three"]),
246 firstFailure: null,
247 });
248 });
249});
250
251describe("archiveSelectedThreadEntries", () => {
252 const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const;
253 const success = AsyncResult.success(undefined);
254 const failure = AsyncResult.failure(Cause.fail(new Error("Archive failed")));
255
256 it("records every entry after full success", async () => {
257 const outcome = await archiveSelectedThreadEntries({
258 entries,
259 archive: async (_entry, onArchived) => {
260 onArchived();
261 return success;
262 },
263 });
264
265 expect(outcome).toEqual({
266 archivedThreadKeys: ["one", "two", "three"],
267 mutationFailure: null,
268 followupFailures: [],
269 });
270 });
271
272 it("stops at a mutation failure and retains prior successes", async () => {
273 const archive = vi.fn(async (entry: (typeof entries)[number], onArchived: () => void) => {
274 if (entry.threadKey === "two") return failure;
275 onArchived();
276 return success;
277 });
278 const outcome = await archiveSelectedThreadEntries({ entries, archive });
279
280 expect(archive).toHaveBeenCalledTimes(2);
281 expect(outcome).toEqual({
282 archivedThreadKeys: ["one"],
283 mutationFailure: failure,
284 followupFailures: [],
285 });
286 });
287
288 it("continues after a post-archive failure", async () => {
289 const archive = vi.fn(async (entry: (typeof entries)[number], onArchived: () => void) => {
290 onArchived();
291 return entry.threadKey === "two" ? failure : success;
292 });
293 const outcome = await archiveSelectedThreadEntries({ entries, archive });
294
295 expect(archive).toHaveBeenCalledTimes(3);
296 expect(outcome).toEqual({
297 archivedThreadKeys: ["one", "two", "three"],
298 mutationFailure: null,
299 followupFailures: [failure],
300 });
301 });
302
303 it("archives a full shelf beyond the visible tail and omits a revived entry", async () => {
304 const entries = Array.from({ length: 137 }, (_, index) => ({ threadKey: `settled-${index}` }));
305 const archive = vi.fn(async (entry: (typeof entries)[number], onArchived: () => void) => {
306 if (entry.threadKey === "settled-92") return null;
307 onArchived();
308 return success;
309 });
310 const outcome = await archiveSelectedThreadEntries({ entries, archive });
311
312 expect(archive).toHaveBeenCalledTimes(137);
313 expect(outcome.archivedThreadKeys).toHaveLength(136);
314 expect(outcome.archivedThreadKeys).toContain("settled-136");
315 expect(outcome.archivedThreadKeys).not.toContain("settled-92");
316 expect(outcome.mutationFailure).toBeNull();
317 expect(outcome.followupFailures).toEqual([]);
318 });
319 it("skips a typed condition rejection in the middle and archives later candidates", async () => {
320 const changed = AsyncResult.failure(
321 Cause.fail(
322 new OrchestrationV2DispatchCommandError({
323 commandId: CommandId.make("archive-two"),
324 commandType: "thread.archive",
325 reason: "archive-condition-changed",
326 message: "The thread changed before automatic archival.",
327 }),
328 ),
329 );
330 const archive = vi.fn(async (entry: (typeof entries)[number], onArchived: () => void) => {
331 if (entry.threadKey === "two") return changed;
332 onArchived();
333 return success;
334 });
335 const outcome = await archiveSelectedThreadEntries({ entries, archive });
336
337 expect(archive).toHaveBeenCalledTimes(3);
338 expect(outcome).toEqual({
339 archivedThreadKeys: ["one", "three"],
340 mutationFailure: null,
341 followupFailures: [],
342 });
343 });
344
345 it("does not skip a genuine failure with the condition error's text", async () => {
346 const rejected = AsyncResult.failure(
347 Cause.fail(
348 new OrchestrationV2DispatchCommandError({
349 commandId: CommandId.make("archive-two"),
350 commandType: "thread.archive",
351 message: "The thread changed before automatic archival.",
352 }),
353 ),
354 );
355 const archive = vi.fn(async (entry: (typeof entries)[number], onArchived: () => void) => {
356 if (entry.threadKey === "two") return rejected;
357 onArchived();
358 return success;
359 });
360 const outcome = await archiveSelectedThreadEntries({ entries, archive });
361
362 expect(archive).toHaveBeenCalledTimes(2);
363 expect(outcome).toEqual({
364 archivedThreadKeys: ["one"],
365 mutationFailure: rejected,
366 followupFailures: [],
367 });
368 });
369});
370
371describe("buildBulkUnpinContextMenuItem", () => {
372 it("counts only the pinned rows of a mixed selection", () => {
373 expect(buildBulkUnpinContextMenuItem({ pinnedCount: 2 })).toEqual({
374 id: "unpin",
375 label: "Unpin (2)",
376 });
377 });
378
379 it("omits the action when nothing selected is pinned", () => {
380 expect(buildBulkUnpinContextMenuItem({ pinnedCount: 0 })).toBeNull();
381 });
382});
383
384describe("buildBulkTitleRegenerationContextMenuItem", () => {
385 it("counts only threads that can start a new regeneration", () => {
386 expect(
387 buildBulkTitleRegenerationContextMenuItem({
388 supportedCount: 4,
389 actionableCount: 3,
390 }),
391 ).toEqual({
392 id: "regenerate-title",
393 label: "Regenerate titles (3)",
394 });
395 });
396
397 it("shows a disabled progress item when every supported thread is pending", () => {
398 expect(
399 buildBulkTitleRegenerationContextMenuItem({
400 supportedCount: 2,
401 actionableCount: 0,
402 }),
403 ).toEqual({
404 id: "regenerate-title",
405 label: "Regenerating… (2)",
406 disabled: true,
407 });
408 });
409
410 it("omits the action when no selected environment supports it", () => {
411 expect(
412 buildBulkTitleRegenerationContextMenuItem({
413 supportedCount: 0,
414 actionableCount: 0,
415 }),
416 ).toBeNull();
417 });
418});
419
420describe("buildMultiSelectThreadContextMenuItems", () => {
421 it("offers bulk archive with the selected count", () => {
422 expect(
423 buildMultiSelectThreadContextMenuItems({ count: 3, hasRunningThread: false }),
424 ).toContainEqual({ id: "archive", label: "Archive (3)", disabled: false });
425 });
426
427 it("disables bulk archive when a selected thread is running", () => {
428 expect(
429 buildMultiSelectThreadContextMenuItems({ count: 2, hasRunningThread: true }),
430 ).toContainEqual({ id: "archive", label: "Archive (2)", disabled: true });
431 });
432});
433
434describe("resolveSidebarStageBadgeLabel", () => {
435 it("returns Nightly for nightly primary server versions", () => {
436 expect(
437 resolveSidebarStageBadgeLabel({
438 primaryServerVersion: "0.0.28-nightly.20260616.12",
439 fallbackStageLabel: "Alpha",
440 }),
441 ).toBe("Nightly");
442 });
443
444 it("returns the fallback label for stable primary server versions", () => {
445 expect(
446 resolveSidebarStageBadgeLabel({
447 primaryServerVersion: "0.0.27",
448 fallbackStageLabel: "Alpha",
449 }),
450 ).toBe("Alpha");
451 });
452
453 it("returns the fallback label when the primary server version is missing", () => {
454 expect(
455 resolveSidebarStageBadgeLabel({
456 primaryServerVersion: null,
457 fallbackStageLabel: "Dev",
458 }),
459 ).toBe("Dev");
460 });
461
462 it("returns the fallback label for malformed nightly prerelease versions", () => {
463 expect(
464 resolveSidebarStageBadgeLabel({
465 primaryServerVersion: "0.0.28-nightly.20260616",
466 fallbackStageLabel: "Alpha",
467 }),
468 ).toBe("Alpha");
469 });
470});
471
472describe("sidebar thread lineage helpers", () => {
473 it("keeps only top-level, unarchived threads in the Sidebar V2 project scope", () => {
474 const parentId = ThreadId.make("thread-parent");
475 const projectId = ProjectId.make("project-visible");
476 const environmentId = EnvironmentId.make("environment-visible");
477 const root = makeThreadFixture({
478 id: parentId,
479 environmentId,
480 projectId,
481 });
482 const subagent = makeThreadFixture({
483 id: ThreadId.make("thread-subagent"),
484 environmentId,
485 projectId,
486 lineage: {
487 rootThreadId: parentId,
488 parentThreadId: parentId,
489 relationshipToParent: "subagent",
490 },
491 });
492 const fork = makeThreadFixture({
493 id: ThreadId.make("thread-fork"),
494 environmentId,
495 projectId,
496 lineage: {
497 rootThreadId: parentId,
498 parentThreadId: parentId,
499 relationshipToParent: "fork",
500 },
501 });
502 const archived = makeThreadFixture({
503 id: ThreadId.make("thread-archived"),
504 environmentId,
505 projectId,
506 archivedAt: "2026-01-02T00:00:00.000Z",
507 });
508 const otherProject = makeThreadFixture({
509 id: ThreadId.make("thread-other-project"),
510 environmentId,
511 projectId: ProjectId.make("project-other"),
512 });
513
514 expect(
515 filterSidebarV2VisibleThreads(
516 [root, subagent, fork, archived, otherProject],
517 new Set([`${environmentId}:${projectId}`]),
518 ).map((thread) => thread.id),
519 ).toEqual([parentId, fork.id]);
520 });
521
522 it("identifies subagent threads so the sidebar can hide them", () => {
523 const parentId = ThreadId.make("thread-parent");
524 const subagent = makeThreadFixture({
525 lineage: {
526 rootThreadId: parentId,
527 parentThreadId: parentId,
528 relationshipToParent: "subagent",
529 },
530 });
531
532 expect(isSidebarSubagentThread(subagent)).toBe(true);
533 expect(isSidebarSubagentThread(makeThreadFixture())).toBe(false);
534 });
535
536 it("resolves the parent thread for fork sidebar affordances", () => {
537 const parentId = ThreadId.make("thread-parent");
538 const fallbackParentId = ThreadId.make("thread-fallback-parent");
539 const runFork = makeThreadFixture({
540 forkedFrom: { type: "run", threadId: parentId, runId: "run-1" as never },
541 lineage: {
542 rootThreadId: parentId,
543 parentThreadId: fallbackParentId,
544 relationshipToParent: "fork",
545 },
546 });
547 const lineageFork = makeThreadFixture({
548 lineage: {
549 rootThreadId: parentId,
550 parentThreadId: fallbackParentId,
551 relationshipToParent: "fork",
552 },
553 });
554
555 expect(getSidebarForkParentThreadId(runFork)).toBe(parentId);
556 expect(getSidebarForkParentThreadId(lineageFork)).toBe(fallbackParentId);
557 expect(getSidebarForkParentThreadId(makeThreadFixture())).toBeNull();
558 });
559});
560
561function makeLatestRun(overrides?: {
562 completedAt?: string | null;
563 startedAt?: string | null;
564}): NonNullable<Thread["latestRun"]> {
565 return {
566 runId: "turn-1" as never,
567 status: "completed",
568 assistantMessageId: null,
569 requestedAt: "2026-03-09T10:00:00.000Z",
570 startedAt:
571 overrides?.startedAt !== undefined ? overrides.startedAt : "2026-03-09T10:00:00.000Z",
572 completedAt:
573 overrides?.completedAt !== undefined ? overrides.completedAt : "2026-03-09T10:05:00.000Z",
574 };
575}
576
577describe("hasUnseenCompletion", () => {
578 it("returns true when a thread completed after its last visit", () => {
579 expect(
580 hasUnseenCompletion({
581 hasActionableProposedPlan: false,
582 hasPendingApprovals: false,
583 hasPendingUserInput: false,
584 interactionMode: "default",
585 latestRun: makeLatestRun(),
586 lastVisitedAt: "2026-03-09T10:04:00.000Z",
587 runtime: null,
588 }),
589 ).toBe(true);
590 });
591
592 it("treats a missing client visit marker as read", () => {
593 expect(
594 hasUnseenCompletion({
595 hasActionableProposedPlan: false,
596 hasPendingApprovals: false,
597 hasPendingUserInput: false,
598 interactionMode: "default",
599 latestRun: makeLatestRun(),
600 lastVisitedAt: undefined,
601 runtime: null,
602 }),
603 ).toBe(false);
604 });
605});
606
607describe("shouldRecedeSidebarThread", () => {
608 it.each(["working", "waiting"] as const)(
609 "recedes an inactive %s thread even when it is unread and woke",
610 (status) => {
611 expect(
612 shouldRecedeSidebarThread({
613 status,
614 isUnread: true,
615 isWoke: true,
616 isActive: false,
617 isSelected: false,
618 }),
619 ).toBe(true);
620 },
621 );
622
623 it.each(["ready", "approval", "input"] as const)(
624 "keeps an unread %s thread prominent",
625 (status) => {
626 expect(
627 shouldRecedeSidebarThread({
628 status,
629 isUnread: true,
630 isWoke: false,
631 isActive: false,
632 isSelected: false,
633 }),
634 ).toBe(false);
635 },
636 );
637
638 it("keeps active and selected working threads prominent", () => {
639 const input = {
640 status: "working" as const,
641 isUnread: true,
642 isWoke: true,
643 isActive: false,
644 isSelected: false,
645 };
646
647 expect(shouldRecedeSidebarThread({ ...input, isActive: true })).toBe(false);
648 expect(shouldRecedeSidebarThread({ ...input, isSelected: true })).toBe(false);
649 });
650
651 it.each([false, true])("keeps input-required threads prominent with unread=%s", (isUnread) => {
652 expect(
653 shouldRecedeSidebarThread({
654 status: "input",
655 isUnread,
656 isWoke: false,
657 isActive: false,
658 isSelected: false,
659 }),
660 ).toBe(false);
661 });
662});
663
664describe("createThreadJumpHintVisibilityController", () => {
665 beforeEach(() => {
666 vi.useFakeTimers();
667 });
668
669 afterEach(() => {
670 vi.useRealTimers();
671 });
672
673 it("delays showing jump hints until the configured delay elapses", () => {
674 const visibilityChanges: boolean[] = [];
675 const controller = createThreadJumpHintVisibilityController({
676 delayMs: THREAD_JUMP_HINT_SHOW_DELAY_MS,
677 onVisibilityChange: (visible) => {
678 visibilityChanges.push(visible);
679 },
680 });
681
682 controller.sync(true);
683 vi.advanceTimersByTime(THREAD_JUMP_HINT_SHOW_DELAY_MS - 1);
684
685 expect(visibilityChanges).toEqual([]);
686
687 vi.advanceTimersByTime(1);
688
689 expect(visibilityChanges).toEqual([true]);
690 });
691
692 it("hides immediately when the modifiers are released", () => {
693 const visibilityChanges: boolean[] = [];
694 const controller = createThreadJumpHintVisibilityController({
695 delayMs: THREAD_JUMP_HINT_SHOW_DELAY_MS,
696 onVisibilityChange: (visible) => {
697 visibilityChanges.push(visible);
698 },
699 });
700
701 controller.sync(true);
702 vi.advanceTimersByTime(THREAD_JUMP_HINT_SHOW_DELAY_MS);
703 controller.sync(false);
704
705 expect(visibilityChanges).toEqual([true, false]);
706 });
707
708 it("cancels a pending reveal when the modifier is released early", () => {
709 const visibilityChanges: boolean[] = [];
710 const controller = createThreadJumpHintVisibilityController({
711 delayMs: THREAD_JUMP_HINT_SHOW_DELAY_MS,
712 onVisibilityChange: (visible) => {
713 visibilityChanges.push(visible);
714 },
715 });
716
717 controller.sync(true);
718 vi.advanceTimersByTime(Math.floor(THREAD_JUMP_HINT_SHOW_DELAY_MS / 2));
719 controller.sync(false);
720 vi.advanceTimersByTime(THREAD_JUMP_HINT_SHOW_DELAY_MS);
721
722 expect(visibilityChanges).toEqual([]);
723 });
724});
725
726describe("getSidebarThreadIdsToPrewarm", () => {
727 it("returns only the first visible thread ids up to the prewarm limit", () => {
728 expect(getSidebarThreadIdsToPrewarm(["t1", "t2", "t3"], 2)).toEqual(["t1", "t2"]);
729 });
730
731 it("returns all visible thread ids when they fit within the limit", () => {
732 expect(getSidebarThreadIdsToPrewarm(["t1", "t2"], 10)).toEqual(["t1", "t2"]);
733 });
734
735 it("returns no thread ids when the limit is zero", () => {
736 expect(getSidebarThreadIdsToPrewarm(["t1", "t2"], 0)).toEqual([]);
737 });
738});
739
740describe("shouldClearThreadSelectionOnMouseDown", () => {
741 it("preserves selection for thread items", () => {
742 const child = {
743 closest: (selector: string) =>
744 selector.includes("[data-thread-item]") ? ({} as Element) : null,
745 } as unknown as HTMLElement;
746
747 expect(shouldClearThreadSelectionOnMouseDown(child)).toBe(false);
748 });
749
750 it("preserves selection for thread list toggle controls", () => {
751 const selectionSafe = {
752 closest: (selector: string) =>
753 selector.includes("[data-thread-selection-safe]") ? ({} as Element) : null,
754 } as unknown as HTMLElement;
755
756 expect(shouldClearThreadSelectionOnMouseDown(selectionSafe)).toBe(false);
757 });
758
759 it("clears selection for unrelated sidebar clicks", () => {
760 const unrelated = {
761 closest: () => null,
762 } as unknown as HTMLElement;
763
764 expect(shouldClearThreadSelectionOnMouseDown(unrelated)).toBe(true);
765 });
766});
767
768describe("isTrailingDoubleClick", () => {
769 it("treats a single click as a normal activation", () => {
770 expect(isTrailingDoubleClick(1)).toBe(false);
771 });
772
773 it("treats synthetic/keyboard activations (detail 0) as a normal activation", () => {
774 expect(isTrailingDoubleClick(0)).toBe(false);
775 });
776
777 it("ignores the second click of a double-click so it does not navigate", () => {
778 expect(isTrailingDoubleClick(2)).toBe(true);
779 });
780
781 it("ignores further clicks of a triple-click", () => {
782 expect(isTrailingDoubleClick(3)).toBe(true);
783 });
784});
785
786describe("orderItemsByPreferredIds", () => {
787 it("keeps preferred ids first, skips stale ids, and preserves the relative order of remaining items", () => {
788 const ordered = orderItemsByPreferredIds({
789 items: [
790 { id: ProjectId.make("project-1"), name: "One" },
791 { id: ProjectId.make("project-2"), name: "Two" },
792 { id: ProjectId.make("project-3"), name: "Three" },
793 ],
794 preferredIds: [
795 ProjectId.make("project-3"),
796 ProjectId.make("project-missing"),
797 ProjectId.make("project-1"),
798 ],
799 getId: (project) => project.id,
800 });
801
802 expect(ordered.map((project) => project.id)).toEqual([
803 ProjectId.make("project-3"),
804 ProjectId.make("project-1"),
805 ProjectId.make("project-2"),
806 ]);
807 });
808
809 it("does not duplicate items when preferred ids repeat", () => {
810 const ordered = orderItemsByPreferredIds({
811 items: [
812 { id: ProjectId.make("project-1"), name: "One" },
813 { id: ProjectId.make("project-2"), name: "Two" },
814 ],
815 preferredIds: [
816 ProjectId.make("project-2"),
817 ProjectId.make("project-1"),
818 ProjectId.make("project-2"),
819 ],
820 getId: (project) => project.id,
821 });
822
823 expect(ordered.map((project) => project.id)).toEqual([
824 ProjectId.make("project-2"),
825 ProjectId.make("project-1"),
826 ]);
827 });
828
829 it("honors projectOrder physical keys via getProjectOrderKey", async () => {
830 // Regression guard for #1904 / the regression introduced by #2055:
831 // `projectOrder` is populated with physical keys (envId + cwd-derived)
832 // by the store and by drag-end handlers. Readers must identify projects
833 // with the same key format, or manual sort silently snaps back.
834 const { getProjectOrderKey } = await import("../logicalProject");
835 const projects = [
836 {
837 environmentId: EnvironmentId.make("environment-local"),
838 id: ProjectId.make("id-alpha"),
839 workspaceRoot: "/work/alpha",
840 },
841 {
842 environmentId: EnvironmentId.make("environment-local"),
843 id: ProjectId.make("id-beta"),
844 workspaceRoot: "/work/beta",
845 },
846 {
847 environmentId: EnvironmentId.make("environment-local"),
848 id: ProjectId.make("id-gamma"),
849 workspaceRoot: "/work/gamma",
850 },
851 ];
852 const ordered = orderItemsByPreferredIds({
853 items: projects,
854 preferredIds: [getProjectOrderKey(projects[2]!), getProjectOrderKey(projects[0]!)],
855 getId: getProjectOrderKey,
856 });
857
858 expect(ordered.map((project) => project.workspaceRoot)).toEqual([
859 "/work/gamma",
860 "/work/alpha",
861 "/work/beta",
862 ]);
863 });
864
865 it("resolves legacy preference aliases without materializing project state", () => {
866 const ordered = orderItemsByPreferredIds({
867 items: [
868 { id: "physical-a", cwd: "/work/a" },
869 { id: "physical-b", cwd: "/work/b" },
870 { id: "physical-c", cwd: "/work/c" },
871 ],
872 preferredIds: ["legacy:/work/c", "legacy:/work/a"],
873 getId: (project) => project.id,
874 getPreferenceIds: (project) => [project.id, `legacy:${project.cwd}`],
875 });
876
877 expect(ordered.map((project) => project.id)).toEqual([
878 "physical-c",
879 "physical-a",
880 "physical-b",
881 ]);
882 });
883});
884
885describe("resolveAdjacentThreadId", () => {
886 it("resolves adjacent thread ids in ordered sidebar traversal", () => {
887 const threads = [
888 ThreadId.make("thread-1"),
889 ThreadId.make("thread-2"),
890 ThreadId.make("thread-3"),
891 ];
892
893 expect(
894 resolveAdjacentThreadId({
895 threadIds: threads,
896 currentThreadId: threads[1] ?? null,
897 direction: "previous",
898 }),
899 ).toBe(threads[0]);
900 expect(
901 resolveAdjacentThreadId({
902 threadIds: threads,
903 currentThreadId: threads[1] ?? null,
904 direction: "next",
905 }),
906 ).toBe(threads[2]);
907 expect(
908 resolveAdjacentThreadId({
909 threadIds: threads,
910 currentThreadId: null,
911 direction: "next",
912 }),
913 ).toBe(threads[0]);
914 expect(
915 resolveAdjacentThreadId({
916 threadIds: threads,
917 currentThreadId: null,
918 direction: "previous",
919 }),
920 ).toBe(threads[2]);
921 expect(
922 resolveAdjacentThreadId({
923 threadIds: threads,
924 currentThreadId: threads[0] ?? null,
925 direction: "previous",
926 }),
927 ).toBeNull();
928 });
929});
930
931describe("isContextMenuPointerDown", () => {
932 it("treats secondary-button presses as context menu gestures on all platforms", () => {
933 expect(
934 isContextMenuPointerDown({
935 button: 2,
936 ctrlKey: false,
937 isMac: false,
938 }),
939 ).toBe(true);
940 });
941
942 it("treats ctrl+primary-click as a context menu gesture on macOS", () => {
943 expect(
944 isContextMenuPointerDown({
945 button: 0,
946 ctrlKey: true,
947 isMac: true,
948 }),
949 ).toBe(true);
950 });
951
952 it("does not treat ctrl+primary-click as a context menu gesture off macOS", () => {
953 expect(
954 isContextMenuPointerDown({
955 button: 0,
956 ctrlKey: true,
957 isMac: false,
958 }),
959 ).toBe(false);
960 });
961});
962
963describe("resolveSidebarThreadStatus", () => {
964 const runtime = {
965 status: "running" as const,
966 activeRunId: null,
967 providerInstanceId: ProviderInstanceId.make("codex"),
968 providerName: "Codex",
969 lastError: null,
970 updatedAt: "2026-03-09T10:00:00.000Z",
971 };
972
973 const idle = { hasPendingApprovals: false, hasPendingUserInput: false, runtime: null };
974
975 it("prioritizes approval over a running runtime", () => {
976 expect(resolveSidebarThreadStatus({ ...idle, hasPendingApprovals: true, runtime })).toBe(
977 "approval",
978 );
979 });
980
981 it("prioritizes awaiting input over a running runtime, below approval", () => {
982 expect(resolveSidebarThreadStatus({ ...idle, hasPendingUserInput: true, runtime })).toBe(
983 "input",
984 );
985 expect(
986 resolveSidebarThreadStatus({
987 ...idle,
988 hasPendingApprovals: true,
989 hasPendingUserInput: true,
990 runtime,
991 }),
992 ).toBe("approval");
993 });
994
995 it("reports working for running and starting runtimes", () => {
996 expect(resolveSidebarThreadStatus({ ...idle, runtime })).toBe("working");
997 expect(
998 resolveSidebarThreadStatus({
999 ...idle,
1000 runtime: { ...runtime, status: "starting" as const },
1001 }),
1002 ).toBe("working");
1003 });
1004
1005 it("keeps usage-limit stops Limited and visible until the thread recovers", () => {
1006 const limited = {
1007 ...runtime,
1008 status: "failed" as const,
1009 lastError: "Plan limit reached",
1010 lastErrorClass: "usage_limit" as const,
1011 };
1012 expect(resolveSidebarThreadStatus({ ...idle, runtime: limited })).toBe("limited");
1013 expect(
1014 resolveSidebarThreadStatus({ ...idle, runtime: { ...limited, status: "running" } }),
1015 ).toBe("working");
1016 expect(
1017 resolveSidebarThreadStatus({ ...idle, runtime: { ...limited, status: "completed" } }),
1018 ).toBe("ready");
1019 expect(resolveSidebarV2TopStatus({ status: "limited", isUnread: false, isWoke: false })).toBe(
1020 "limited",
1021 );
1022 expect(
1023 shouldRecedeSidebarThread({
1024 status: "limited",
1025 isUnread: false,
1026 isWoke: false,
1027 isActive: false,
1028 isSelected: false,
1029 }),
1030 ).toBe(false);
1031 });
1032
1033 it("reports failed only while the latest run failed", () => {
1034 expect(
1035 resolveSidebarThreadStatus({
1036 ...idle,
1037 runtime: { ...runtime, status: "failed" as const, lastError: "boom" },
1038 }),
1039 ).toBe("failed");
1040 expect(
1041 resolveSidebarThreadStatus({
1042 ...idle,
1043 runtime: { ...runtime, status: "completed" as const, lastError: "persisted" },
1044 }),
1045 ).toBe("ready");
1046 expect(
1047 resolveSidebarThreadStatus({
1048 ...idle,
1049 runtime: { ...runtime, status: "idle" as const, lastError: "persisted" },
1050 }),
1051 ).toBe("waiting");
1052 });
1053
1054 it("defaults to ready with no runtime", () => {
1055 expect(resolveSidebarThreadStatus(idle)).toBe("ready");
1056 });
1057
1058 it("keeps a waiting runtime visible ahead of unread and woke presentation", () => {
1059 expect(resolveSidebarV2TopStatus({ status: "waiting", isUnread: true, isWoke: true })).toBe(
1060 "waiting",
1061 );
1062 });
1063
1064 it("keeps Waiting static while Working shows elapsed duration", () => {
1065 expect(shouldShowSidebarV2Duration("waiting")).toBe(false);
1066 expect(shouldShowSidebarV2Duration("working")).toBe(true);
1067 });
1068});
1069
1070describe("pullRequestMatchesSearchQuery", () => {
1071 const branchPullRequest = {
1072 projectId: ProjectId.make("project"),
1073 repository: "pingdotgg/t3code",
1074 number: 123,
1075 url: "https://github.com/pingdotgg/t3code/pull/123",
1076 };
1077
1078 it("matches when every query token appears in the PR's terms", () => {
1079 expect(pullRequestMatchesSearchQuery({ branchPullRequest }, "123")).toBe(true);
1080 expect(pullRequestMatchesSearchQuery({ branchPullRequest }, "pingdotgg/t3code #123")).toBe(
1081 true,
1082 );
1083 });
1084
1085 it("rejects queries with a token the PR does not carry, and blank queries", () => {
1086 expect(pullRequestMatchesSearchQuery({ branchPullRequest }, "rebase 123")).toBe(false);
1087 expect(pullRequestMatchesSearchQuery({ branchPullRequest }, " ")).toBe(false);
1088 });
1089});
1090
1091describe("searchSidebarThreads", () => {
1092 const searchThread = (id: string, title: string, project: string) => ({
1093 environmentId: localEnvironmentId,
1094 id: ThreadId.make(id),
1095 title,
1096 project,
1097 });
1098 const threads = [
1099 searchThread("thread-1", "Fix workspace search", "Alpha"),
1100 searchThread("thread-2", "Review providers", "Workspace"),
1101 searchThread("thread-3", "WORKTREE cleanup", "Beta"),
1102 ];
1103 const contentKeys = (...ids: ReadonlyArray<string>) =>
1104 new Set(
1105 ids.map((id) =>
1106 threadSearchMatchKey({ environmentId: localEnvironmentId, threadId: ThreadId.make(id) }),
1107 ),
1108 );
1109
1110 it("matches thread titles case-insensitively and preserves their order", () => {
1111 expect(searchSidebarThreads(threads, "work")).toEqual([threads[0], threads[2]]);
1112 });
1113
1114 it("does not match project metadata", () => {
1115 expect(searchSidebarThreads(threads, "workspace")).toEqual([threads[0]]);
1116 });
1117
1118 it("returns no results for an empty query", () => {
1119 expect(searchSidebarThreads(threads, " ")).toEqual([]);
1120 });
1121
1122 it("appends content-only matches after every title match", () => {
1123 expect(searchSidebarThreads(threads, "work", contentKeys("thread-2"))).toEqual([
1124 threads[0],
1125 threads[2],
1126 threads[1],
1127 ]);
1128 });
1129
1130 it("lists a thread matching both title and content once", () => {
1131 expect(searchSidebarThreads(threads, "work", contentKeys("thread-1"))).toEqual([
1132 threads[0],
1133 threads[2],
1134 ]);
1135 });
1136
1137 it("ignores content matches for threads outside the sidebar collection", () => {
1138 expect(searchSidebarThreads(threads, "work", contentKeys("thread-missing"))).toEqual([
1139 threads[0],
1140 threads[2],
1141 ]);
1142 });
1143});
1144
1145describe("filterSidebarProjectScopeItems", () => {
1146 const items = [
1147 { value: "all", label: "All projects" },
1148 { value: "alpha", label: "Alpha workspace" },
1149 { value: "beta", label: "Beta tools" },
1150 ] as const;
1151 const filter = (query: string) =>
1152 filterSidebarProjectScopeItems({
1153 items,
1154 query,
1155 matches: (item, candidate) =>
1156 item.label.toLocaleLowerCase().includes(candidate.toLocaleLowerCase()),
1157 });
1158
1159 it("shows the default row first while the query is empty", () => {
1160 expect(filter("")).toEqual(items);
1161 expect(filter(" ")).toEqual(items);
1162 });
1163
1164 it("hides the default row while filtering", () => {
1165 expect(filter("all")).toEqual([]);
1166 });
1167
1168 it("returns matching projects in source order and supports no-match results", () => {
1169 expect(filter("WORK")).toEqual([items[1]]);
1170 expect(filter("missing")).toEqual([]);
1171 });
1172});
1173
1174describe("reduceSidebarProjectScopeMenuState", () => {
1175 const queriedOpenState = { open: true, query: "alpha" };
1176
1177 it("clears the query when the combobox closes through onOpenChange", () => {
1178 expect(
1179 reduceSidebarProjectScopeMenuState(queriedOpenState, {
1180 type: "open-changed",
1181 open: false,
1182 }),
1183 ).toEqual({ open: false, query: "" });
1184 });
1185
1186 it("clears the query when project settings closes the combobox", () => {
1187 expect(
1188 reduceSidebarProjectScopeMenuState(queriedOpenState, {
1189 type: "project-settings-opened",
1190 }),
1191 ).toEqual({ open: false, query: "" });
1192 });
1193
1194 it("keeps the popup open while the query changes", () => {
1195 expect(
1196 reduceSidebarProjectScopeMenuState(
1197 { open: true, query: "" },
1198 { type: "query-changed", query: "beta" },
1199 ),
1200 ).toEqual({ open: true, query: "beta" });
1201 });
1202});
1203
1204describe("resolveWorkingStartedAt", () => {
1205 const runtime = {
1206 status: "running" as const,
1207 providerName: "Codex",
1208 providerInstanceId: ProviderInstanceId.make("codex"),
1209 activeRunId: RunId.make("turn-1"),
1210 lastError: null,
1211 updatedAt: "2026-03-09T10:02:00.000Z",
1212 };
1213
1214 it("uses the running run's start time", () => {
1215 expect(
1216 resolveWorkingStartedAt({
1217 latestRun: makeLatestRun({ completedAt: null }),
1218 runtime,
1219 }),
1220 ).toBe("2026-03-09T10:00:00.000Z");
1221 });
1222
1223 it("uses the request time while a run awaits adoption", () => {
1224 expect(
1225 resolveWorkingStartedAt({
1226 latestRun: makeLatestRun({ startedAt: null, completedAt: null }),
1227 runtime,
1228 }),
1229 ).toBe("2026-03-09T10:00:00.000Z");
1230 });
1231
1232 it("does not invent a start from activity updates when the newest run completed", () => {
1233 expect(
1234 resolveWorkingStartedAt({
1235 latestRun: makeLatestRun(),
1236 runtime,
1237 }),
1238 ).toBeNull();
1239 });
1240
1241 it("skips a malformed startedAt instead of returning it", () => {
1242 expect(
1243 resolveWorkingStartedAt({
1244 latestRun: makeLatestRun({ startedAt: "not-a-date", completedAt: null }),
1245 runtime,
1246 }),
1247 ).toBe("2026-03-09T10:00:00.000Z");
1248 });
1249
1250 it.each(["queued", "cancelled"] as const)(
1251 "shares the detail timer when a newer run is %s",
1252 (status) => {
1253 const activityStartedAt = "2026-03-09T10:00:00.000Z";
1254 const latestRun = {
1255 ...makeLatestRun(),
1256 runId: RunId.make("newer-run"),
1257 status,
1258 startedAt: null,
1259 completedAt: status === "queued" ? null : "2026-03-09T10:05:00.000Z",
1260 };
1261 for (const updatedAt of ["2026-03-09T10:30:00.000Z", "2026-03-09T10:50:00.000Z"]) {
1262 const activeRuntime = { ...runtime, updatedAt, activityStartedAt };
1263 expect(resolveWorkingStartedAt({ latestRun, runtime: activeRuntime })).toBe(
1264 activityStartedAt,
1265 );
1266 expect(deriveActiveWorkStartedAt(latestRun, activeRuntime, updatedAt)).toBe(
1267 activityStartedAt,
1268 );
1269 }
1270 // A server-owned run without a valid start must not borrow a local dispatch clock.
1271 expect(
1272 deriveActiveWorkStartedAt(
1273 latestRun,
1274 { ...runtime, activityStartedAt: null },
1275 "2026-03-09T10:50:00.000Z",
1276 ),
1277 ).toBeNull();
1278 },
1279 );
1280
1281 it("returns null with neither a running run nor a runtime", () => {
1282 expect(resolveWorkingStartedAt({ latestRun: null, runtime: null })).toBeNull();
1283 });
1284});
1285
1286describe("formatWorkingDurationLabel", () => {
1287 it("formats seconds, minutes, and hours", () => {
1288 expect(formatWorkingDurationLabel(0)).toBe("0s");
1289 expect(formatWorkingDurationLabel(42_000)).toBe("42s");
1290 expect(formatWorkingDurationLabel(5 * 60_000)).toBe("5m");
1291 expect(formatWorkingDurationLabel(90 * 60_000)).toBe("1h 30m");
1292 });
1293
1294 it("clamps negative and non-finite elapsed values to zero", () => {
1295 expect(formatWorkingDurationLabel(-5_000)).toBe("0s");
1296 expect(formatWorkingDurationLabel(Number.NaN)).toBe("0s");
1297 });
1298});
1299
1300describe("resolveThreadStatusPill", () => {
1301 const baseThread = {
1302 hasActionableProposedPlan: false,
1303 hasPendingApprovals: false,
1304 hasPendingUserInput: false,
1305 interactionMode: "plan" as const,
1306 latestRun: null,
1307 lastVisitedAt: undefined,
1308 runtime: {
1309 status: "running" as const,
1310 providerName: "Codex",
1311 providerInstanceId: ProviderInstanceId.make("codex"),
1312 activeRunId: "turn-1" as never,
1313 lastError: null,
1314 updatedAt: "2026-03-09T10:00:00.000Z",
1315 },
1316 };
1317
1318 it("shows pending approval before all other statuses", () => {
1319 expect(
1320 resolveThreadStatusPill({
1321 thread: {
1322 ...baseThread,
1323 hasPendingApprovals: true,
1324 hasPendingUserInput: true,
1325 },
1326 }),
1327 ).toMatchObject({ label: "Pending Approval", pulse: false });
1328 });
1329
1330 it("shows awaiting input when plan mode is blocked on user answers", () => {
1331 expect(
1332 resolveThreadStatusPill({
1333 thread: {
1334 ...baseThread,
1335 hasPendingUserInput: true,
1336 },
1337 }),
1338 ).toMatchObject({ label: "Awaiting Input", pulse: false });
1339 });
1340
1341 it("falls back to working when the thread is actively running without blockers", () => {
1342 expect(
1343 resolveThreadStatusPill({
1344 thread: baseThread,
1345 }),
1346 ).toMatchObject({ label: "Working", pulse: true });
1347 });
1348
1349 it("shows waiting for an idle thread with pending background tasks", () => {
1350 expect(
1351 resolveThreadStatusPill({
1352 thread: {
1353 ...baseThread,
1354 pendingBackgroundTasks: [{ taskId: "bg-1", description: "Watch build", kind: "monitor" }],
1355 runtime: {
1356 ...baseThread.runtime,
1357 status: "idle",
1358 activeRunId: null,
1359 },
1360 },
1361 }),
1362 ).toMatchObject({
1363 label: "Waiting",
1364 colorClass: "text-sidebar-muted-foreground",
1365 dotClass: "bg-sidebar-muted-foreground",
1366 pulse: false,
1367 });
1368 });
1369
1370 it("keeps an active turn working when background tasks are also present", () => {
1371 expect(
1372 resolveThreadStatusPill({
1373 thread: {
1374 ...baseThread,
1375 pendingBackgroundTasks: [{ taskId: "bg-1", description: "sleep 20", kind: "command" }],
1376 },
1377 }),
1378 ).toMatchObject({ label: "Working", pulse: true });
1379 });
1380
1381 it("does not show waiting after the background task roster clears", () => {
1382 expect(
1383 resolveThreadStatusPill({
1384 thread: {
1385 ...baseThread,
1386 pendingBackgroundTasks: [],
1387 runtime: {
1388 ...baseThread.runtime,
1389 status: "idle",
1390 activeRunId: null,
1391 },
1392 },
1393 }),
1394 ).toBeNull();
1395 });
1396
1397 it("shows plan ready when a settled plan turn has a proposed plan ready for follow-up", () => {
1398 expect(
1399 resolveThreadStatusPill({
1400 thread: {
1401 ...baseThread,
1402 hasActionableProposedPlan: true,
1403 latestRun: makeLatestRun(),
1404 runtime: {
1405 ...baseThread.runtime,
1406 status: "completed",
1407 activeRunId: null,
1408 },
1409 },
1410 }),
1411 ).toMatchObject({ label: "Plan Ready", pulse: false });
1412 });
1413
1414 it("does not manufacture completed state without a client visit marker", () => {
1415 expect(
1416 resolveThreadStatusPill({
1417 thread: {
1418 ...baseThread,
1419 latestRun: makeLatestRun(),
1420 runtime: {
1421 ...baseThread.runtime,
1422 status: "completed",
1423 activeRunId: null,
1424 },
1425 },
1426 }),
1427 ).toBeNull();
1428 });
1429
1430 it("shows completed when there is an unseen completion and no active blocker", () => {
1431 expect(
1432 resolveThreadStatusPill({
1433 thread: {
1434 ...baseThread,
1435 interactionMode: "default",
1436 latestRun: makeLatestRun(),
1437 lastVisitedAt: "2026-03-09T10:04:00.000Z",
1438 runtime: {
1439 ...baseThread.runtime,
1440 status: "completed",
1441 activeRunId: null,
1442 },
1443 },
1444 }),
1445 ).toMatchObject({ label: "Completed", pulse: false });
1446 });
1447});
1448
1449describe("resolveProjectStatusIndicator", () => {
1450 it("returns null when no threads have a notable status", () => {
1451 expect(resolveProjectStatusIndicator([null, null])).toBeNull();
1452 });
1453
1454 it("surfaces the highest-priority actionable state across project threads", () => {
1455 expect(
1456 resolveProjectStatusIndicator([
1457 {
1458 label: "Completed",
1459 colorClass: "text-emerald-600",
1460 dotClass: "bg-emerald-500",
1461 pulse: false,
1462 },
1463 {
1464 label: "Pending Approval",
1465 colorClass: "text-amber-600",
1466 dotClass: "bg-amber-500",
1467 pulse: false,
1468 },
1469 {
1470 label: "Working",
1471 colorClass: "text-sky-600",
1472 dotClass: "bg-sky-500",
1473 pulse: true,
1474 },
1475 ]),
1476 ).toMatchObject({ label: "Pending Approval", dotClass: "bg-amber-500" });
1477 });
1478
1479 it("prefers plan-ready over completed when no stronger action is needed", () => {
1480 expect(
1481 resolveProjectStatusIndicator([
1482 {
1483 label: "Completed",
1484 colorClass: "text-emerald-600",
1485 dotClass: "bg-emerald-500",
1486 pulse: false,
1487 },
1488 {
1489 label: "Plan Ready",
1490 colorClass: "text-violet-600",
1491 dotClass: "bg-violet-500",
1492 pulse: false,
1493 },
1494 ]),
1495 ).toMatchObject({ label: "Plan Ready", dotClass: "bg-violet-500" });
1496 });
1497
1498 it("ranks waiting below active work and above plan-ready", () => {
1499 const waiting = {
1500 label: "Waiting" as const,
1501 colorClass: "text-sidebar-muted-foreground",
1502 dotClass: "bg-sidebar-muted-foreground",
1503 pulse: false,
1504 };
1505
1506 expect(
1507 resolveProjectStatusIndicator([
1508 waiting,
1509 {
1510 label: "Working",
1511 colorClass: "text-sky-600",
1512 dotClass: "bg-sky-500",
1513 pulse: true,
1514 },
1515 ]),
1516 ).toMatchObject({ label: "Working" });
1517 expect(
1518 resolveProjectStatusIndicator([
1519 {
1520 label: "Plan Ready",
1521 colorClass: "text-violet-600",
1522 dotClass: "bg-violet-500",
1523 pulse: false,
1524 },
1525 waiting,
1526 ]),
1527 ).toMatchObject({ label: "Waiting" });
1528 });
1529});
1530
1531function makeProject(overrides: Partial<Project> = {}): Project {
1532 const { defaultModelSelection, ...rest } = overrides;
1533 return {
1534 id: ProjectId.make("project-1"),
1535 environmentId: localEnvironmentId,
1536 title: "Project",
1537 workspaceRoot: "/tmp/project",
1538 repositoryIdentity: null,
1539 defaultModelSelection: {
1540 instanceId: ProviderInstanceId.make("codex"),
1541 model: "gpt-5.4",
1542 ...defaultModelSelection,
1543 },
1544 createdAt: "2026-03-09T10:00:00.000Z",
1545 updatedAt: "2026-03-09T10:00:00.000Z",
1546 scripts: [],
1547 ...rest,
1548 };
1549}
1550
1551function makeThread(overrides: ThreadFixtureOverrides = {}): Thread {
1552 return makeThreadFixture({
1553 id: ThreadId.make("thread-1"),
1554 environmentId: localEnvironmentId,
1555 projectId: ProjectId.make("project-1"),
1556 title: "Thread",
1557 modelSelection: {
1558 instanceId: ProviderInstanceId.make("codex"),
1559 model: "gpt-5.4",
1560 ...overrides?.modelSelection,
1561 },
1562 runtimeMode: DEFAULT_RUNTIME_MODE,
1563 interactionMode: DEFAULT_INTERACTION_MODE,
1564 runtime: null,
1565 messages: [],
1566 proposedPlans: [],
1567 createdAt: "2026-03-09T10:00:00.000Z",
1568 archivedAt: null,
1569 settledOverride: null,
1570 settledAt: null,
1571 deletedAt: null,
1572 updatedAt: "2026-03-09T10:00:00.000Z",
1573 latestRun: null,
1574 branch: null,
1575 worktreePath: null,
1576 ...overrides,
1577 });
1578}
1579
1580describe("getFallbackThreadIdAfterDelete", () => {
1581 it("returns the top remaining thread in the deleted thread's project sidebar order", () => {
1582 const fallbackThreadId = getFallbackThreadIdAfterDelete({
1583 threads: [
1584 makeThread({
1585 id: ThreadId.make("thread-oldest"),
1586 projectId: ProjectId.make("project-1"),
1587 createdAt: "2026-03-09T10:00:00.000Z",
1588 messages: [],
1589 }),
1590 makeThread({
1591 id: ThreadId.make("thread-active"),
1592 projectId: ProjectId.make("project-1"),
1593 createdAt: "2026-03-09T10:05:00.000Z",
1594 messages: [],
1595 }),
1596 makeThread({
1597 id: ThreadId.make("thread-newest"),
1598 projectId: ProjectId.make("project-1"),
1599 createdAt: "2026-03-09T10:10:00.000Z",
1600 messages: [],
1601 }),
1602 makeThread({
1603 id: ThreadId.make("thread-other-project"),
1604 projectId: ProjectId.make("project-2"),
1605 createdAt: "2026-03-09T10:20:00.000Z",
1606 messages: [],
1607 }),
1608 ],
1609 deletedThreadId: ThreadId.make("thread-active"),
1610 sortOrder: "created_at",
1611 });
1612
1613 expect(fallbackThreadId).toBe(ThreadId.make("thread-newest"));
1614 });
1615
1616 it("skips other threads being deleted in the same action", () => {
1617 const fallbackThreadId = getFallbackThreadIdAfterDelete({
1618 threads: [
1619 makeThread({
1620 id: ThreadId.make("thread-active"),
1621 projectId: ProjectId.make("project-1"),
1622 createdAt: "2026-03-09T10:05:00.000Z",
1623 messages: [],
1624 }),
1625 makeThread({
1626 id: ThreadId.make("thread-newest"),
1627 projectId: ProjectId.make("project-1"),
1628 createdAt: "2026-03-09T10:10:00.000Z",
1629 messages: [],
1630 }),
1631 makeThread({
1632 id: ThreadId.make("thread-next"),
1633 projectId: ProjectId.make("project-1"),
1634 createdAt: "2026-03-09T10:07:00.000Z",
1635 messages: [],
1636 }),
1637 ],
1638 deletedThreadId: ThreadId.make("thread-active"),
1639 deletedThreadIds: new Set([ThreadId.make("thread-active"), ThreadId.make("thread-newest")]),
1640 sortOrder: "created_at",
1641 });
1642
1643 expect(fallbackThreadId).toBe(ThreadId.make("thread-next"));
1644 });
1645});
1646describe("sortProjectsForSidebar", () => {
1647 it("sorts projects by the most recent user message across their threads", () => {
1648 const projects = [
1649 makeProject({ id: ProjectId.make("project-1"), title: "Older project" }),
1650 makeProject({ id: ProjectId.make("project-2"), title: "Newer project" }),
1651 ];
1652 const threads = [
1653 makeThread({
1654 projectId: ProjectId.make("project-1"),
1655 updatedAt: "2026-03-09T10:20:00.000Z",
1656 messages: [
1657 {
1658 id: "message-1" as never,
1659 role: "user",
1660 text: "older project user message",
1661 runId: null,
1662 createdAt: "2026-03-09T10:01:00.000Z",
1663 updatedAt: "2026-03-09T10:01:00.000Z",
1664 streaming: false,
1665 },
1666 ],
1667 }),
1668 makeThread({
1669 id: ThreadId.make("thread-2"),
1670 projectId: ProjectId.make("project-2"),
1671 updatedAt: "2026-03-09T10:05:00.000Z",
1672 messages: [
1673 {
1674 id: "message-2" as never,
1675 role: "user",
1676 text: "newer project user message",
1677 runId: null,
1678 createdAt: "2026-03-09T10:05:00.000Z",
1679 updatedAt: "2026-03-09T10:05:00.000Z",
1680 streaming: false,
1681 },
1682 ],
1683 }),
1684 ];
1685
1686 const sorted = sortProjectsForSidebar(projects, threads, "updated_at");
1687
1688 expect(sorted.map((project) => project.id)).toEqual([
1689 ProjectId.make("project-2"),
1690 ProjectId.make("project-1"),
1691 ]);
1692 });
1693
1694 it("falls back to project timestamps when a project has no threads", () => {
1695 const sorted = sortProjectsForSidebar(
1696 [
1697 makeProject({
1698 id: ProjectId.make("project-1"),
1699 title: "Older project",
1700 updatedAt: "2026-03-09T10:01:00.000Z",
1701 }),
1702 makeProject({
1703 id: ProjectId.make("project-2"),
1704 title: "Newer project",
1705 updatedAt: "2026-03-09T10:05:00.000Z",
1706 }),
1707 ],
1708 [],
1709 "updated_at",
1710 );
1711
1712 expect(sorted.map((project) => project.id)).toEqual([
1713 ProjectId.make("project-2"),
1714 ProjectId.make("project-1"),
1715 ]);
1716 });
1717
1718 it("falls back to name and id ordering when projects have no sortable timestamps", () => {
1719 const sorted = sortProjectsForSidebar(
1720 [
1721 makeProject({
1722 id: ProjectId.make("project-2"),
1723 title: "Beta",
1724 createdAt: "invalid-created-at" as never,
1725 updatedAt: "invalid-updated-at" as never,
1726 }),
1727 makeProject({
1728 id: ProjectId.make("project-1"),
1729 title: "Alpha",
1730 createdAt: "invalid-created-at" as never,
1731 updatedAt: "invalid-updated-at" as never,
1732 }),
1733 ],
1734 [],
1735 "updated_at",
1736 );
1737
1738 expect(sorted.map((project) => project.id)).toEqual([
1739 ProjectId.make("project-1"),
1740 ProjectId.make("project-2"),
1741 ]);
1742 });
1743
1744 it("preserves manual project ordering", () => {
1745 const projects = [
1746 makeProject({ id: ProjectId.make("project-2"), title: "Second" }),
1747 makeProject({ id: ProjectId.make("project-1"), title: "First" }),
1748 ];
1749
1750 const sorted = sortProjectsForSidebar(projects, [], "manual");
1751
1752 expect(sorted.map((project) => project.id)).toEqual([
1753 ProjectId.make("project-2"),
1754 ProjectId.make("project-1"),
1755 ]);
1756 });
1757
1758 it("ignores archived threads when sorting projects", () => {
1759 const sorted = sortProjectsForSidebar(
1760 [
1761 makeProject({
1762 id: ProjectId.make("project-1"),
1763 title: "Visible project",
1764 updatedAt: "2026-03-09T10:01:00.000Z",
1765 }),
1766 makeProject({
1767 id: ProjectId.make("project-2"),
1768 title: "Archived-only project",
1769 updatedAt: "2026-03-09T10:00:00.000Z",
1770 }),
1771 ],
1772 [
1773 makeThread({
1774 id: ThreadId.make("thread-visible"),
1775 projectId: ProjectId.make("project-1"),
1776 updatedAt: "2026-03-09T10:02:00.000Z",
1777 archivedAt: null,
1778 }),
1779 makeThread({
1780 id: ThreadId.make("thread-archived"),
1781 projectId: ProjectId.make("project-2"),
1782 updatedAt: "2026-03-09T10:10:00.000Z",
1783 archivedAt: "2026-03-09T10:11:00.000Z",
1784 }),
1785 ].filter((thread) => thread.archivedAt === null),
1786 "updated_at",
1787 );
1788
1789 expect(sorted.map((project) => project.id)).toEqual([
1790 ProjectId.make("project-1"),
1791 ProjectId.make("project-2"),
1792 ]);
1793 });
1794
1795 it.each(["updated_at", "created_at"] as const)(
1796 "matches the per-comparison %s order on a shuffled list with ties",
1797 (sortOrder) => {
1798 const minute = (value: number) => `2026-03-09T10:0${value}:00.000Z`;
1799 // (index * 7) % 24 scrambles the input order. Titles repeat, and
1800 // projects 16-23 have no threads, so they use their own stamps.
1801 const projects = Array.from({ length: 24 }, (_, index) => {
1802 const n = (index * 7) % 24;
1803 return makeProject({
1804 id: ProjectId.make(`project-${n}`),
1805 title: n % 2 === 0 ? "Alpha" : "Beta",
1806 createdAt: minute(n % 3),
1807 updatedAt: n % 5 === 0 ? "invalid" : minute(n % 2),
1808 });
1809 });
1810 const threads = Array.from({ length: 48 }, (_, n) => ({
1811 projectId: ProjectId.make(`project-${n % 16}`),
1812 createdAt: minute(n % 6),
1813 updatedAt: minute(n % 3),
1814 latestUserMessageAt: n % 4 === 0 ? null : minute(n % 5),
1815 }));
1816 // The comparator this sort replaced: it walked each project's threads
1817 // on every call.
1818 const timestamp = (project: Project) =>
1819 getProjectSortTimestamp(
1820 project,
1821 threads.filter((thread) => thread.projectId === project.id),
1822 sortOrder,
1823 );
1824 const expected = projects.toSorted((left, right) => {
1825 const rightTimestamp = timestamp(right);
1826 const leftTimestamp = timestamp(left);
1827 const byTimestamp =
1828 rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1;
1829 return (
1830 byTimestamp || left.title.localeCompare(right.title) || left.id.localeCompare(right.id)
1831 );
1832 });
1833
1834 expect(sortProjectsForSidebar(projects, threads, sortOrder)).toEqual(expected);
1835 },
1836 );
1837
1838 it("returns the project timestamp when no threads are present", () => {
1839 const timestamp = getProjectSortTimestamp(
1840 makeProject({ updatedAt: "2026-03-09T10:10:00.000Z" }),
1841 [],
1842 "updated_at",
1843 );
1844
1845 expect(timestamp).toBe(Date.parse("2026-03-09T10:10:00.000Z"));
1846 });
1847});
1848
1849describe("sortScopedProjectsForSidebar", () => {
1850 it("keeps identical project ids in different environments separate", () => {
1851 const remoteEnvironmentId = EnvironmentId.make("environment-remote");
1852 const sharedProjectId = ProjectId.make("shared-project");
1853 const projects = [
1854 makeProject({
1855 environmentId: localEnvironmentId,
1856 id: sharedProjectId,
1857 title: "Local project",
1858 }),
1859 makeProject({
1860 environmentId: remoteEnvironmentId,
1861 id: sharedProjectId,
1862 title: "Remote project",
1863 }),
1864 ];
1865 const threads = [
1866 makeThread({
1867 environmentId: localEnvironmentId,
1868 projectId: sharedProjectId,
1869 updatedAt: "2026-03-09T10:02:00.000Z",
1870 }),
1871 makeThread({
1872 environmentId: remoteEnvironmentId,
1873 projectId: sharedProjectId,
1874 updatedAt: "2026-03-09T10:10:00.000Z",
1875 }),
1876 ];
1877
1878 const sorted = sortScopedProjectsForSidebar(projects, threads, "updated_at");
1879
1880 expect(sorted.map((project) => project.title)).toEqual(["Remote project", "Local project"]);
1881 });
1882
1883 it("does not use archived threads as project activity", () => {
1884 const projects = [
1885 makeProject({
1886 id: ProjectId.make("project-visible"),
1887 title: "Visible project",
1888 updatedAt: "2026-03-09T10:01:00.000Z",
1889 }),
1890 makeProject({
1891 id: ProjectId.make("project-archived"),
1892 title: "Archived-only project",
1893 updatedAt: "2026-03-09T10:00:00.000Z",
1894 }),
1895 ];
1896 const threads = [
1897 makeThread({
1898 id: ThreadId.make("thread-visible"),
1899 projectId: ProjectId.make("project-visible"),
1900 updatedAt: "2026-03-09T10:02:00.000Z",
1901 }),
1902 makeThread({
1903 id: ThreadId.make("thread-archived"),
1904 projectId: ProjectId.make("project-archived"),
1905 updatedAt: "2026-03-09T10:10:00.000Z",
1906 archivedAt: "2026-03-09T10:11:00.000Z",
1907 }),
1908 ];
1909
1910 const sorted = sortScopedProjectsForSidebar(projects, threads, "updated_at");
1911
1912 expect(sorted.map((project) => project.title)).toEqual([
1913 "Visible project",
1914 "Archived-only project",
1915 ]);
1916 });
1917});
1918
1919describe("sortLogicalProjectsForSidebar", () => {
1920 it("uses saved order only in manual mode and activity order otherwise", () => {
1921 const olderProjectId = ProjectId.make("project-older");
1922 const newerProjectId = ProjectId.make("project-newer");
1923 const projects = [
1924 {
1925 ...makeProject({ id: olderProjectId, title: "Older project" }),
1926 projectKey: "logical-older",
1927 memberProjectRefs: [{ environmentId: localEnvironmentId, projectId: olderProjectId }],
1928 },
1929 {
1930 ...makeProject({ id: newerProjectId, title: "Newer project" }),
1931 projectKey: "logical-newer",
1932 memberProjectRefs: [{ environmentId: localEnvironmentId, projectId: newerProjectId }],
1933 },
1934 ];
1935 const threads = [
1936 makeThread({
1937 projectId: olderProjectId,
1938 updatedAt: "2026-03-09T10:01:00.000Z",
1939 }),
1940 makeThread({
1941 id: ThreadId.make("thread-newer"),
1942 projectId: newerProjectId,
1943 updatedAt: "2026-03-09T10:05:00.000Z",
1944 }),
1945 ];
1946
1947 expect(sortLogicalProjectsForSidebar(projects, threads, "manual")).toEqual(projects);
1948 expect(
1949 sortLogicalProjectsForSidebar(projects, threads, "updated_at").map(
1950 (project) => project.projectKey,
1951 ),
1952 ).toEqual(["logical-newer", "logical-older"]);
1953 });
1954});
1955
1956describe("sortSidebarV2ProjectGroups", () => {
1957 it("does not let a hidden subagent thread reorder projects", () => {
1958 const olderProjectId = ProjectId.make("project-older");
1959 const newerProjectId = ProjectId.make("project-newer");
1960 const olderRootThreadId = ThreadId.make("thread-older-root");
1961 const projects = [
1962 {
1963 ...makeProject({ id: olderProjectId, title: "A older project" }),
1964 projectKey: "logical-older",
1965 memberProjectRefs: [{ environmentId: localEnvironmentId, projectId: olderProjectId }],
1966 },
1967 {
1968 ...makeProject({ id: newerProjectId, title: "Z newer project" }),
1969 projectKey: "logical-newer",
1970 memberProjectRefs: [{ environmentId: localEnvironmentId, projectId: newerProjectId }],
1971 },
1972 ];
1973 const threads = [
1974 makeThread({
1975 id: olderRootThreadId,
1976 projectId: olderProjectId,
1977 updatedAt: "2026-03-09T10:01:00.000Z",
1978 }),
1979 makeThread({
1980 id: ThreadId.make("thread-newer-root"),
1981 projectId: newerProjectId,
1982 updatedAt: "2026-03-09T10:05:00.000Z",
1983 }),
1984 makeThread({
1985 id: ThreadId.make("thread-hidden-subagent"),
1986 projectId: olderProjectId,
1987 updatedAt: "2026-03-09T10:10:00.000Z",
1988 lineage: {
1989 rootThreadId: olderRootThreadId,
1990 parentThreadId: olderRootThreadId,
1991 relationshipToParent: "subagent",
1992 },
1993 }),
1994 ];
1995
1996 expect(
1997 sortSidebarV2ProjectGroups(projects, threads, "updated_at").map(
1998 (project) => project.projectKey,
1999 ),
2000 ).toEqual(["logical-newer", "logical-older"]);
2001 });
2002});
2003
2004describe("resolveThreadLastVisitedAt", () => {
2005 it("uses the local watermark when the server does not track visits", () => {
2006 expect(resolveThreadLastVisitedAt(undefined, "2026-07-30T10:00:00.000Z")).toBe(
2007 "2026-07-30T10:00:00.000Z",
2008 );
2009 expect(resolveThreadLastVisitedAt(undefined, undefined)).toBeUndefined();
2010 });
2011
2012 it("keeps the server watermark authoritative when visited tracking exists", () => {
2013 // A rewound server value (mark-unread) must win even over a newer local
2014 // watermark left behind by earlier viewing on this device.
2015 expect(resolveThreadLastVisitedAt("2026-07-30T10:00:00.000Z", "2026-07-30T10:00:05.000Z")).toBe(
2016 "2026-07-30T10:00:00.000Z",
2017 );
2018 expect(resolveThreadLastVisitedAt("2026-07-30T10:00:00.000Z", undefined)).toBe(
2019 "2026-07-30T10:00:00.000Z",
2020 );
2021 });
2022
2023 it("treats an explicit server-side null as never visited", () => {
2024 expect(resolveThreadLastVisitedAt(null, "2026-07-30T10:00:00.000Z")).toBeUndefined();
2025 });
2026});
2027
2028describe("pinOrderKeyBetween", () => {
2029 it("produces keys that sort between their bounds", () => {
2030 const middle = pinOrderKeyBetween(null, null)!;
2031 const top = pinOrderKeyBetween(null, middle)!;
2032 const bottom = pinOrderKeyBetween(middle, null)!;
2033 expect(top < middle).toBe(true);
2034 expect(middle < bottom).toBe(true);
2035
2036 const between = pinOrderKeyBetween(top, middle)!;
2037 expect(top < between && between < middle).toBe(true);
2038 });
2039
2040 it("extends into new digits when bounds are adjacent", () => {
2041 const key = pinOrderKeyBetween("g", "h")!;
2042 expect("g" < key && key < "h").toBe(true);
2043 });
2044
2045 it("stays strictly ordered under repeated top insertion", () => {
2046 // Every new pin lands at the head of the arranged run; keys must keep
2047 // sorting before the previous head without ever bottoming out.
2048 let head: string | null = null;
2049 const keys: string[] = [];
2050 for (let i = 0; i < 100; i += 1) {
2051 const key: string = pinOrderKeyBetween(null, head)!;
2052 expect(key).not.toBeNull();
2053 if (head !== null) expect(key < head).toBe(true);
2054 keys.push(key);
2055 head = key;
2056 }
2057 expect(new Set(keys).size).toBe(100);
2058 });
2059
2060 it("stays strictly ordered under repeated middle insertion", () => {
2061 let low = pinOrderKeyBetween(null, null)!;
2062 let high = pinOrderKeyBetween(low, null)!;
2063 for (let i = 0; i < 100; i += 1) {
2064 const key: string = pinOrderKeyBetween(low, high)!;
2065 expect(low < key && key < high).toBe(true);
2066 if (i % 2 === 0) low = key;
2067 else high = key;
2068 }
2069 });
2070
2071 it("returns null for corrupt or out-of-order bounds instead of throwing", () => {
2072 expect(pinOrderKeyBetween("z", "a")).toBeNull();
2073 expect(pinOrderKeyBetween("A!", null)).toBeNull();
2074 expect(pinOrderKeyBetween(null, "ma")).toBeNull();
2075 expect(pinOrderKeyBetween("m", "m")).toBeNull();
2076 });
2077});
2078
2079describe("sortPinnedThreadsForSidebar", () => {
2080 const pinnable = (input: { id: string; createdAt: string; pinOrderKey?: string | null }) => ({
2081 id: input.id,
2082 createdAt: input.createdAt,
2083 pinOrderKey: input.pinOrderKey ?? null,
2084 });
2085
2086 it("sorts keyed threads by key ahead of keyless threads in creation order", () => {
2087 const sorted = sortPinnedThreadsForSidebar([
2088 pinnable({ id: "keyless-old", createdAt: "2026-03-09T08:00:00.000Z" }),
2089 pinnable({ id: "second", createdAt: "2026-03-09T09:00:00.000Z", pinOrderKey: "t" }),
2090 pinnable({ id: "keyless-new", createdAt: "2026-03-09T12:00:00.000Z" }),
2091 pinnable({ id: "first", createdAt: "2026-03-09T07:00:00.000Z", pinOrderKey: "g" }),
2092 ]);
2093
2094 expect(sorted.map((thread) => thread.id)).toEqual([
2095 "first",
2096 "second",
2097 "keyless-new",
2098 "keyless-old",
2099 ]);
2100 });
2101
2102 it("breaks equal keys by id so raced writes render identically everywhere", () => {
2103 const sorted = sortPinnedThreadsForSidebar([
2104 pinnable({ id: "b", createdAt: "2026-03-09T10:00:00.000Z", pinOrderKey: "m" }),
2105 pinnable({ id: "a", createdAt: "2026-03-09T11:00:00.000Z", pinOrderKey: "m" }),
2106 ]);
2107
2108 expect(sorted.map((thread) => thread.id)).toEqual(["a", "b"]);
2109 });
2110});
2111
2112describe("resolveSidebarSweepKeys", () => {
2113 const ordered = ["a", "b", "c", "d", "blocked"];
2114 const canSettle = (key: string) => key !== "blocked";
2115
2116 it("covers every row between the pressed row and the pointer, in either direction", () => {
2117 expect(resolveSidebarSweepKeys(ordered, "b", "b", canSettle)).toEqual(["b"]);
2118 expect(resolveSidebarSweepKeys(ordered, "b", "d", canSettle)).toEqual(["b", "c", "d"]);
2119 expect(resolveSidebarSweepKeys(ordered, "d", "a", canSettle)).toEqual(["a", "b", "c", "d"]);
2120 });
2121
2122 it("leaves out rows that cannot settle and rows that left the list", () => {
2123 expect(resolveSidebarSweepKeys(ordered, "c", "blocked", canSettle)).toEqual(["c", "d"]);
2124 expect(resolveSidebarSweepKeys(ordered, "gone", "a", canSettle)).toEqual([]);
2125 });
2126});
2127
2128describe("navigation after parking a thread", () => {
2129 it.each([
2130 ["settle", "settled", null, "thread", true],
2131 ["settle", "active", null, "thread", false],
2132 ["settle", "settled", null, "other-thread", false],
2133 ["snooze", null, "2099-01-01T00:00:00.000Z", "thread", true],
2134 ["snooze", null, null, "thread", false],
2135 ["snooze", null, "2026-09-12T09:00:00.000Z", "thread", false],
2136 ["snooze", null, "2099-01-01T00:00:00.000Z", "thread", false, true],
2137 ["snooze", null, "2099-01-01T00:00:00.000Z", "other-thread", false],
2138 ] as const)(
2139 "%s with state %s / %s on %s navigates: %s",
2140 (
2141 action,
2142 settledOverride,
2143 snoozedUntil,
2144 currentThreadKey,
2145 expected,
2146 hasPendingApprovals: boolean = false,
2147 ) => {
2148 expect(
2149 shouldNavigateAfterThreadPark({
2150 threadKey: "thread",
2151 currentThreadKey,
2152 action,
2153 now: "2026-09-12T10:00:00.000Z",
2154 thread: {
2155 settledOverride,
2156 snoozedUntil,
2157 snoozedAt: null,
2158 session: null,
2159 latestTurn: null,
2160 hasPendingApprovals,
2161 hasPendingUserInput: false,
2162 },
2163 }),
2164 ).toBe(expected);
2165 },
2166 );
2167});
2168
2169describe("unseen completion with background work", () => {
2170 it.each([
2171 { kind: "command", status: "ready", topStatus: "done", receded: false, pill: "Completed" },
2172 { kind: "monitor", status: "waiting", topStatus: "waiting", receded: true, pill: "Waiting" },
2173 ] as const)("presents a completed thread with a $kind roster", (expected) => {
2174 const thread = presentThreadShell(localEnvironmentId, {
2175 ...makeThreadFixture().source,
2176 latestRunId: RunId.make("run-background-completion"),
2177 status: "completed",
2178 latestRunCompletedAt: DateTime.makeUnsafe("2026-06-20T01:00:00.000Z"),
2179 lastVisitedAt: DateTime.makeUnsafe("2026-06-20T00:59:00.000Z"),
2180 pendingBackgroundTasks: [{ taskId: "background-work", kind: expected.kind }],
2181 });
2182 const status = resolveSidebarThreadStatus(thread);
2183 const isUnread = hasUnseenCompletion(thread);
2184
2185 expect(isUnread).toBe(true);
2186 expect(status).toBe(expected.status);
2187 expect(resolveSidebarV2TopStatus({ status, isUnread, isWoke: false })).toBe(expected.topStatus);
2188 expect(
2189 shouldRecedeSidebarThread({
2190 status,
2191 isUnread,
2192 isWoke: false,
2193 isActive: false,
2194 isSelected: false,
2195 }),
2196 ).toBe(expected.receded);
2197 expect(isSidebarThreadWorking(thread)).toBe(expected.receded);
2198 expect(resolveThreadStatusPill({ thread })).toMatchObject({ label: expected.pill });
2199 });
2200});
2201
2202describe("Working shelf (beta)", () => {
2203 const runtime = {
2204 status: "running" as const,
2205 activeRunId: null,
2206 providerInstanceId: ProviderInstanceId.make("codex"),
2207 providerName: "Codex",
2208 lastError: null,
2209 updatedAt: "2026-03-09T10:00:00.000Z",
2210 };
2211 const backgroundTask = { taskId: "bg-1", description: "Watch build", kind: "monitor" as const };
2212 const idle = {
2213 hasActionableProposedPlan: false,
2214 hasPendingApprovals: false,
2215 hasPendingUserInput: false,
2216 interactionMode: "default" as const,
2217 latestRun: makeLatestRun(),
2218 runtime: null,
2219 };
2220 // Stopped with background tasks still open: V2's "waiting" sidebar status.
2221 const waiting = {
2222 ...idle,
2223 runtime: { ...runtime, status: "idle" as const },
2224 pendingBackgroundTasks: [backgroundTask],
2225 };
2226
2227 it("folds away running threads and threads waiting on background work only", () => {
2228 expect(isSidebarThreadWorking({ ...idle, runtime })).toBe(true);
2229 expect(isSidebarThreadWorking(waiting)).toBe(true);
2230 expect(isSidebarThreadWorking(idle)).toBe(false);
2231 expect(isSidebarThreadWorking({ ...idle, runtime, hasPendingApprovals: true })).toBe(false);
2232 expect(isSidebarThreadWorking({ ...idle, runtime, hasPendingUserInput: true })).toBe(false);
2233 expect(
2234 isSidebarThreadWorking({
2235 ...waiting,
2236 runtime: { ...runtime, status: "failed" as const, lastError: "boom" },
2237 }),
2238 ).toBe(false);
2239 });
2240
2241 it("keeps a ready plan in the inbox while background work runs", () => {
2242 expect(
2243 isSidebarThreadWorking({
2244 ...waiting,
2245 interactionMode: "plan",
2246 hasActionableProposedPlan: true,
2247 }),
2248 ).toBe(false);
2249 });
2250
2251 it.each([true, false])("classifies a Limited shell with auto-resume %s", (autoResume) => {
2252 const runId = RunId.make("limited-run");
2253 const resetAt = "2026-06-20T03:00:00.000Z";
2254 const thread = presentThreadShell(localEnvironmentId, {
2255 ...makeThreadFixture().source,
2256 latestRunId: runId,
2257 status: "failed",
2258 latestRunCompletedAt: DateTime.makeUnsafe("2026-06-20T01:00:00.000Z"),
2259 lastError: "Plan limit reached",
2260 lastErrorClass: "usage_limit",
2261 usageLimitResetAt: resetAt,
2262 limitRecovery: { runId, resetAt, autoResume },
2263 });
2264
2265 expect(isSidebarThreadWorking(thread)).toBe(autoResume);
2266 expect(resolveSidebarThreadStatus(thread)).toBe("limited");
2267 });
2268
2269 describe("sortInboxThreadsByReturn", () => {
2270 const thread = (
2271 id: string,
2272 input: { createdAt: string; completedAt?: string | null; unsettledAt?: string },
2273 ) => ({
2274 id: ThreadId.make(id),
2275 environmentId: localEnvironmentId,
2276 createdAt: input.createdAt,
2277 unsettledAt: input.unsettledAt ?? null,
2278 latestRun:
2279 input.completedAt === undefined
2280 ? null
2281 : { ...makeLatestRun({ completedAt: input.completedAt }), requestedAt: input.createdAt },
2282 });
2283
2284 it("puts the thread that finished last on top, whatever its age", () => {
2285 const sorted = sortInboxThreadsByReturn([
2286 thread("new", { createdAt: "2026-03-09T11:00:00.000Z" }),
2287 thread("old-finished-now", {
2288 createdAt: "2026-03-01T09:00:00.000Z",
2289 completedAt: "2026-03-09T12:00:00.000Z",
2290 }),
2291 thread("reopened", {
2292 createdAt: "2026-03-02T09:00:00.000Z",
2293 unsettledAt: "2026-03-09T11:30:00.000Z",
2294 }),
2295 ]);
2296 expect(sorted.map((entry) => entry.id)).toEqual(["old-finished-now", "reopened", "new"]);
2297 });
2298
2299 it("counts a return the server does not stamp, like an approval request", () => {
2300 const waiting = thread("asks-approval", {
2301 createdAt: "2026-03-09T09:00:00.000Z",
2302 completedAt: null,
2303 });
2304 const finished = thread("finished", {
2305 createdAt: "2026-03-09T09:30:00.000Z",
2306 completedAt: "2026-03-09T11:00:00.000Z",
2307 });
2308 expect(sortInboxThreadsByReturn([finished, waiting]).map((entry) => entry.id)).toEqual([
2309 "finished",
2310 "asks-approval",
2311 ]);
2312 expect(
2313 sortInboxThreadsByReturn([finished, waiting], (entry) =>
2314 entry === waiting ? Date.parse("2026-03-09T11:05:00.000Z") : undefined,
2315 ).map((entry) => entry.id),
2316 ).toEqual(["asks-approval", "finished"]);
2317 });
2318 });
2319
2320 describe("dragging", () => {
2321 const marker = (name: SidebarListMarker): SidebarListItem => ({ kind: "marker", marker: name });
2322 const row = (key: string, section: SidebarSection): SidebarListItem => ({
2323 kind: "thread",
2324 key,
2325 section,
2326 });
2327 // Pinned p1 | Active a1 a2 | Working w1 | Settled s1
2328 const items: readonly SidebarListItem[] = [
2329 marker("pinned-header"),
2330 row("p1", "pinned"),
2331 marker("pinned-divider"),
2332 row("a1", "active"),
2333 row("a2", "active"),
2334 marker("working-header"),
2335 row("w1", "working"),
2336 marker("settled-header"),
2337 row("s1", "settled"),
2338 ];
2339
2340 it("never drops into the Working shelf, and keeps it out of the inbox order", () => {
2341 expect(resolveSidebarDropTarget(items, "a1", "w1")).toBeNull();
2342 expect(resolveSidebarDropTarget(items, "p1", "a2")).toEqual({
2343 section: "active",
2344 pinnedOrder: [],
2345 activeOrder: ["a1", "a2", "p1"],
2346 });
2347 expect(resolveSidebarDropVerb("active", "working")).toBeNull();
2348 });
2349
2350 it("arranges the rows it can write when another row's server cannot store an order", () => {
2351 // None of the rows has a key yet, so the drop needs keys for its
2352 // neighbors too. "offline" sits on a server that cannot take them.
2353 const plan = planSidebarThreadDrop({
2354 activeKey: "a2",
2355 activeSection: "active",
2356 target: {
2357 section: "active",
2358 pinnedOrder: [],
2359 activeOrder: ["offline", "a2", "a1"],
2360 },
2361 pinnedOrder: [],
2362 pinnedKeysById: new Map(),
2363 activeOrder: ["offline", "a1", "a2"],
2364 activeKeysById: new Map([
2365 ["offline", null],
2366 ["a1", null],
2367 ["a2", null],
2368 ]),
2369 activeReorderableKeys: new Set(["a1", "a2"]),
2370 });
2371 expect(plan.kind).toBe("move-active");
2372 if (plan.kind !== "move-active") return;
2373 expect(plan.assignments.map(({ id }) => id)).toEqual(["a2", "a1"]);
2374 const [a2, a1] = plan.assignments.map(({ orderKey }) => orderKey);
2375 expect(a2! < a1!).toBe(true);
2376
2377 // A keyed row on that server still sorts by its key, so it stays a bound.
2378 const above = planSidebarThreadDrop({
2379 activeKey: "a2",
2380 activeSection: "active",
2381 target: { section: "active", pinnedOrder: [], activeOrder: ["a2", "offline"] },
2382 pinnedOrder: [],
2383 pinnedKeysById: new Map(),
2384 activeOrder: ["offline", "a2"],
2385 activeKeysById: new Map([
2386 ["offline", "m"],
2387 ["a2", "t"],
2388 ]),
2389 activeReorderableKeys: new Set(["a2"]),
2390 });
2391 expect(above.kind === "move-active" && above.assignments[0]!.orderKey < "m").toBe(true);
2392 });
2393
2394 it("only changes lifecycle when the inbox is time-ordered", () => {
2395 const base = {
2396 pinnedOrder: ["p1"],
2397 pinnedKeysById: new Map([["p1", "m"]]),
2398 activeOrder: ["a1", "a2"],
2399 activeKeysById: new Map([
2400 ["a1", "f"],
2401 ["a2", "t"],
2402 ]),
2403 activeTimeOrdered: true,
2404 };
2405 expect(
2406 planSidebarThreadDrop({
2407 ...base,
2408 activeKey: "a1",
2409 activeSection: "active",
2410 target: { section: "active", pinnedOrder: ["p1"], activeOrder: ["a2", "a1"] },
2411 }),
2412 ).toEqual({ kind: "none" });
2413 expect(
2414 planSidebarThreadDrop({
2415 ...base,
2416 activeKey: "p1",
2417 activeSection: "pinned",
2418 target: { section: "active", pinnedOrder: [], activeOrder: ["a1", "p1", "a2"] },
2419 }),
2420 ).toEqual({
2421 kind: "move-active",
2422 order: null,
2423 assignments: [],
2424 unpin: true,
2425 unsettle: false,
2426 unsnooze: false,
2427 });
2428 });
2429 });
2430});