import { planPinnedMove } from "@t3tools/client-runtime/state/thread-sort"; import { createPendingThreadOrder, createThreadMovePlanner, threadOrderAfterMove, threadDropLifecycle, reconcilePendingThreadOrder, type PendingThreadOrder, } from "./threadOrder"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; import { CommandId, EnvironmentId, MessageId, ProjectId, ProviderInstanceId, ThreadId, TurnId, } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { buildThreadListV2Items, buildThreadListV2ListItems, getThreadListV2OrderedSection, resolveThreadListV2Enabled, resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozeGateExpiryMs, resolveThreadListV2Status, resolveThreadListV2SwipeActions, sortThreadsForListV2, } from "./threadListV2"; const environmentId = EnvironmentId.make("environment-1"); function makeThread( input: Partial & Pick, ): EnvironmentThreadShell { return { environmentId, projectId: ProjectId.make("project-1"), modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, runtimeMode: "full-access", interactionMode: "default", branch: null, worktreePath: null, pullRequests: [], latestTurn: null, createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", archivedAt: null, settledOverride: null, settledAt: null, session: null, latestUserMessageAt: null, hasPendingApprovals: false, hasPendingUserInput: false, hasActionableProposedPlan: false, ...input, }; } const NOW = "2026-06-02T00:00:00.000Z"; const linkedPullRequest = { projectId: ProjectId.make("project-1"), repository: "pingdotgg/t3code", number: 42, url: "https://github.com/pingdotgg/t3code/pull/42", }; describe("resolveThreadListV2SnoozeMenuSelection", () => { it("accepts a displayed evening preset while its wake time is still future", () => { const menuOpenedAt = new Date(2026, 4, 8, 16, 59, 30); const selectedAt = new Date(2026, 4, 8, 17, 0, 30); const displayedPresets = resolveSnoozePresets(menuOpenedAt); const selection = resolveThreadListV2SnoozeMenuSelection({ event: "snooze:evening", displayedPresets, now: selectedAt, }); expect(selection).toEqual({ _tag: "selected", preset: displayedPresets.find((preset) => preset.id === "evening"), }); }); it("expires a displayed preset once its wake time has passed", () => { const displayedPresets = resolveSnoozePresets(new Date(2026, 4, 8, 16, 59, 30)); expect( resolveThreadListV2SnoozeMenuSelection({ event: "snooze:evening", displayedPresets, now: new Date(2026, 4, 8, 18, 0, 1), }), ).toEqual({ _tag: "expired" }); }); it("recomputes presets that remain available instead of using old timestamps", () => { const displayedPresets = resolveSnoozePresets(new Date(2026, 4, 8, 10)); const selectedAt = new Date(2026, 4, 8, 10, 30); const selection = resolveThreadListV2SnoozeMenuSelection({ event: "snooze:hour", displayedPresets, now: selectedAt, }); expect(selection._tag).toBe("selected"); if (selection._tag === "selected") { expect(selection.preset.snoozedUntil).toBe( new Date(selectedAt.getTime() + 60 * 60 * 1_000).toISOString(), ); } }); }); describe("resolveThreadListV2Enabled", () => { it("defaults on when the device has never chosen", () => { expect( resolveThreadListV2Enabled({ legacyPreference: undefined, preferencesLoaded: true }), ).toBe(true); }); it("honors an explicit legacy opt-in", () => { expect(resolveThreadListV2Enabled({ legacyPreference: true, preferencesLoaded: true })).toBe( false, ); expect(resolveThreadListV2Enabled({ legacyPreference: false, preferencesLoaded: true })).toBe( true, ); }); it("holds the default while preferences are still loading so the list does not remount", () => { expect( resolveThreadListV2Enabled({ legacyPreference: undefined, preferencesLoaded: false }), ).toBe(true); }); }); describe("resolveThreadListV2Status", () => { it("prioritizes approval over a running session", () => { const thread = makeThread({ id: ThreadId.make("t"), title: "t", hasPendingApprovals: true, session: { threadId: ThreadId.make("t"), status: "running", providerName: "Codex", providerInstanceId: ProviderInstanceId.make("codex"), runtimeMode: "full-access", activeTurnId: null, lastError: null, updatedAt: NOW, }, }); expect(resolveThreadListV2Status(thread)).toBe("approval"); }); it("resolves ready for quiescent threads", () => { expect(resolveThreadListV2Status(makeThread({ id: ThreadId.make("t"), title: "t" }))).toBe( "ready", ); }); }); describe("queued messages keep a settled thread active", () => { const threads = [ makeThread({ id: ThreadId.make("active"), title: "Active" }), makeThread({ id: ThreadId.make("settled"), title: "Settled", settledOverride: "settled" }), makeThread({ id: ThreadId.make("settled-queued"), title: "Settled with outbox", settledOverride: "settled", }), ]; const queuedThreadKeys = new Set([`${environmentId}:settled-queued`]); it("lists the thread in the active block instead of the settled shelf", () => { const layout = buildThreadListV2Items({ threads, environmentId: null, searchQuery: "", now: NOW, queuedThreadKeys, }); expect(layout.items.map((item) => [item.thread.id, item.variant] as const)).toEqual([ ["active", "card"], ["settled-queued", "card"], ["settled", "slim"], ]); expect(layout.settledCount).toBe(1); }); it("includes it in the reorderable active section", () => { expect( getThreadListV2OrderedSection({ threads, section: "active", now: NOW, queuedThreadKeys }).map( (thread) => thread.id, ), ).toEqual(["active", "settled-queued"]); expect( getThreadListV2OrderedSection({ threads, section: "active", now: NOW }).map( (thread) => thread.id, ), ).toEqual(["active"]); }); }); describe("resolveThreadListV2SwipeActions", () => { it("offers settle and snooze for an active snoozable thread", () => { expect( resolveThreadListV2SwipeActions({ variant: "card", settlementSupported: true, snoozeSupported: true, snoozable: true, }), ).toEqual({ primary: "settle", secondary: "snooze" }); }); it("offers un-settle and snooze for settled history", () => { expect( resolveThreadListV2SwipeActions({ variant: "slim", settlementSupported: true, snoozeSupported: true, snoozable: true, }), ).toEqual({ primary: "unsettle", secondary: "snooze" }); }); it("omits snooze when the server or thread does not allow it", () => { expect( resolveThreadListV2SwipeActions({ variant: "card", settlementSupported: true, snoozeSupported: false, snoozable: true, }), ).toEqual({ primary: "settle", secondary: null }); expect( resolveThreadListV2SwipeActions({ variant: "card", settlementSupported: true, snoozeSupported: true, snoozable: false, }), ).toEqual({ primary: "settle", secondary: null }); }); it("falls back to archive only for a pre-lifecycle server", () => { expect( resolveThreadListV2SwipeActions({ variant: "card", settlementSupported: false, snoozeSupported: false, snoozable: true, }), ).toEqual({ primary: "archive", secondary: null }); }); it("offers wake and no snooze on a snoozed row", () => { expect( resolveThreadListV2SwipeActions({ variant: "slim", settlementSupported: true, snoozeSupported: true, snoozable: true, snoozed: true, }), ).toEqual({ primary: "unsnooze", secondary: null }); }); }); describe("resolveThreadListV2SnoozeGateExpiryMs", () => { it("reports when an unadopted turn's grace window lapses", () => { const thread = makeThread({ id: ThreadId.make("t"), title: "t", latestUserMessageAt: "2026-06-02T00:00:30.000Z", }); expect(resolveThreadListV2SnoozeGateExpiryMs(thread, { now: "2026-06-02T00:01:00.000Z" })).toBe( Date.parse("2026-06-02T00:02:30.000Z"), ); }); it("returns null once the thread is snoozable or when only data can unblock it", () => { expect( resolveThreadListV2SnoozeGateExpiryMs( makeThread({ id: ThreadId.make("ready"), title: "Ready" }), { now: NOW }, ), ).toBe(null); expect( resolveThreadListV2SnoozeGateExpiryMs( makeThread({ id: ThreadId.make("blocked"), title: "Blocked", hasPendingApprovals: true, latestUserMessageAt: NOW, }), { now: NOW }, ), ).toBe(null); }); }); describe("sortThreadsForListV2", () => { it("honors a saved active order and leaves new threads above it", () => { const sorted = sortThreadsForListV2([ { id: "newer-arranged", createdAt: "2026-06-01T12:00:00.000Z", activeOrderKey: "t" }, { id: "older-arranged", createdAt: "2026-06-01T08:00:00.000Z", activeOrderKey: "f" }, { id: "new", createdAt: "2026-06-01T13:00:00.000Z" }, ]); expect(sorted.map((thread) => thread.id)).toEqual(["new", "older-arranged", "newer-arranged"]); }); it("orders by creation time, newest first, ignoring activity", () => { const sorted = sortThreadsForListV2([ { id: "oldest", createdAt: "2026-06-01T08:00:00.000Z" }, { id: "newest", createdAt: "2026-06-01T12:00:00.000Z" }, { id: "middle", createdAt: "2026-06-01T10:00:00.000Z" }, ]); expect(sorted.map((thread) => thread.id)).toEqual(["newest", "middle", "oldest"]); }); it("surfaces an un-settled thread at the top via its re-entry stamp", () => { const sorted = sortThreadsForListV2([ { id: "old-unsettled", createdAt: "2026-06-01T08:00:00.000Z", unsettledAt: "2026-06-01T13:00:00.000Z", }, { id: "newest", createdAt: "2026-06-01T12:00:00.000Z" }, { id: "middle", createdAt: "2026-06-01T10:00:00.000Z" }, ]); expect(sorted.map((thread) => thread.id)).toEqual(["old-unsettled", "newest", "middle"]); }); }); describe("getThreadListV2OrderedSection", () => { it("uses each saved order and excludes settled, snoozed, and archived rows", () => { const threads = [ makeThread({ id: ThreadId.make("active-later"), title: "Later", activeOrderKey: "t" }), makeThread({ id: ThreadId.make("active-first"), title: "First", activeOrderKey: "f" }), makeThread({ id: ThreadId.make("active-new"), title: "New" }), makeThread({ id: ThreadId.make("pinned-later"), title: "Pinned later", pinnedAt: NOW, pinOrderKey: "t", activeOrderKey: "f", }), makeThread({ id: ThreadId.make("pinned-first"), title: "Pinned first", pinnedAt: NOW, pinOrderKey: "f", activeOrderKey: "t", }), makeThread({ id: ThreadId.make("settled"), title: "Settled", settledOverride: "settled" }), makeThread({ id: ThreadId.make("archived"), title: "Archived", archivedAt: NOW }), makeThread({ id: ThreadId.make("snoozed"), title: "Snoozed", snoozedUntil: "2026-06-03T10:00:00.000Z", snoozedAt: NOW, }), makeThread({ id: ThreadId.make("pinned-snoozed"), title: "Pinned snoozed", pinnedAt: NOW, snoozedUntil: "2026-06-03T10:00:00.000Z", snoozedAt: NOW, }), ]; expect( getThreadListV2OrderedSection({ threads, section: "active", now: NOW }).map( (thread) => thread.id, ), ).toEqual(["active-new", "active-first", "active-later"]); expect( getThreadListV2OrderedSection({ threads, section: "pinned", now: NOW }).map( (thread) => thread.id, ), ).toEqual(["pinned-first", "pinned-later"]); }); }); describe("buildThreadListV2Items", () => { it("places a persisted settled thread in the settled shelf", () => { const thread = makeThread({ id: ThreadId.make("linked-merged"), title: "Linked merged pull request", linkedPullRequest, settledOverride: "settled", settledAt: NOW, }); const layout = buildThreadListV2Items({ threads: [thread], environmentId: null, searchQuery: "", now: NOW, }); expect(layout.settledCount).toBe(1); expect(layout.items[0]?.variant).toBe("slim"); }); it("hides snoozed threads and counts them — visibility parity with web", () => { const layout = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("active"), title: "Active" }), makeThread({ id: ThreadId.make("snoozed"), title: "Snoozed", snoozedUntil: "2026-06-03T09:00:00.000Z", snoozedAt: "2026-06-01T12:00:00.000Z", }), makeThread({ id: ThreadId.make("woken"), title: "Woken", // Wake time already passed: back in the active list. snoozedUntil: "2026-06-01T18:00:00.000Z", snoozedAt: "2026-06-01T12:00:00.000Z", }), ], environmentId: null, searchQuery: "", now: NOW, }); // Same createdAt → static sort tiebreaks by id; the point is the woken // thread is BACK in the card block and the snoozed one is gone. expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "woken"]); expect(layout.snoozedCount).toBe(1); }); it("places settled pinned threads in the settled shelf", () => { const layout = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("active"), title: "Active" }), makeThread({ id: ThreadId.make("pinned-settled"), title: "Pinned while settled", pinnedAt: "2026-06-01T12:00:00.000Z", settledOverride: "settled", settledAt: "2026-06-01T12:00:00.000Z", }), ], environmentId: null, searchQuery: "", now: NOW, }); expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "pinned-settled"]); expect(layout.items.map((item) => item.pinned)).toEqual([false, false]); expect(layout.settledCount).toBe(1); }); it("keeps active pinned threads in the pinned block", () => { const pinned = makeThread({ id: ThreadId.make("pinned"), title: "Pinned thread", pinnedAt: "2026-06-01T12:00:00.000Z", }); const layout = buildThreadListV2Items({ threads: [pinned], environmentId: null, searchQuery: "", now: NOW, }); expect(layout.items[0]).toMatchObject({ thread: { id: "pinned" }, variant: "card", pinned: true, }); expect(layout.settledCount).toBe(0); }); it("snooze hides a pinned thread and wake restores it to the pinned block", () => { const snoozedInput = { threads: [ makeThread({ id: ThreadId.make("active"), title: "Active" }), makeThread({ id: ThreadId.make("pinned-snoozed"), title: "Pinned and snoozed", pinnedAt: "2026-06-01T12:00:00.000Z", snoozedUntil: "2026-06-03T09:00:00.000Z", snoozedAt: "2026-06-01T11:00:00.000Z", }), ], environmentId: null, searchQuery: "", }; // Before the wake time: the snooze wins; the pin holds underneath. const whileSnoozed = buildThreadListV2Items({ ...snoozedInput, now: NOW }); expect(whileSnoozed.items.map((item) => item.thread.id)).toEqual(["active"]); expect(whileSnoozed.snoozedCount).toBe(1); // After the wake time: the thread returns pinned, back on top. const afterWake = buildThreadListV2Items({ ...snoozedInput, now: "2026-06-03T10:00:00.000Z" }); expect(afterWake.items.map((item) => item.thread.id)).toEqual(["pinned-snoozed", "active"]); expect(afterWake.items[0]?.pinned).toBe(true); expect(afterWake.snoozedCount).toBe(0); }); it("classifies snooze with the second-precise clock and reports the next wake", () => { const layout = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("just-woke"), title: "Just woke", // Woke 30s ago: hidden under the minute-floored clock, visible // under the precise one. snoozedUntil: "2026-06-02T00:00:30.000Z", snoozedAt: "2026-06-01T12:00:00.000Z", }), makeThread({ id: ThreadId.make("still-snoozed"), title: "Still snoozed", snoozedUntil: "2026-06-02T09:00:00.000Z", snoozedAt: "2026-06-01T12:00:00.000Z", }), ], environmentId: null, searchQuery: "", now: "2026-06-02T00:01:07.500Z", }); expect(layout.items.map((item) => item.thread.id)).toEqual(["just-woke"]); expect(layout.snoozedCount).toBe(1); expect(layout.nextSnoozeWakeAt).toBe("2026-06-02T09:00:00.000Z"); }); it("builds snoozed rows between active and settled when the shelf is expanded", () => { const layout = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("active"), title: "Active" }), makeThread({ id: ThreadId.make("settled"), title: "Settled", settledOverride: "settled", settledAt: NOW, }), makeThread({ id: ThreadId.make("later"), title: "Wakes later", snoozedUntil: "2026-06-03T09:00:00.000Z", snoozedAt: "2026-06-01T12:00:00.000Z", }), makeThread({ id: ThreadId.make("sooner"), title: "Wakes sooner", snoozedUntil: "2026-06-02T09:00:00.000Z", snoozedAt: "2026-06-01T12:00:00.000Z", }), ], environmentId: null, searchQuery: "", now: NOW, snoozedShelfExpanded: true, }); expect(layout.items.map((item) => item.thread.id)).toEqual([ "active", "sooner", "later", "settled", ]); expect(layout.items.map((item) => item.snoozed)).toEqual([false, true, true, false]); expect(layout.snoozedShelfHeaderIndex).toBe(1); expect(layout.snoozedCount).toBe(2); }); it("collapses to a header-only shelf", () => { const layout = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("snoozed"), title: "Snoozed", snoozedUntil: "2026-06-03T09:00:00.000Z", snoozedAt: "2026-06-01T12:00:00.000Z", }), ], environmentId: null, searchQuery: "", now: NOW, }); expect(layout.items).toEqual([]); expect(layout.snoozedCount).toBe(1); expect(layout.snoozedShelfHeaderIndex).toBe(0); }); it("keeps the selected thread on a collapsed shelf", () => { const layout = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("open"), title: "Open", snoozedUntil: "2026-06-03T09:00:00.000Z", snoozedAt: "2026-06-01T12:00:00.000Z", }), makeThread({ id: ThreadId.make("other"), title: "Other", snoozedUntil: "2026-06-03T10:00:00.000Z", snoozedAt: "2026-06-01T12:00:00.000Z", }), ], environmentId: null, searchQuery: "", now: NOW, selectedThreadKey: `${environmentId}:open`, }); expect(layout.items.map((item) => item.thread.id)).toEqual(["open"]); expect(layout.items[0]?.snoozed).toBe(true); expect(layout.snoozedCount).toBe(2); }); it("keeps snoozed threads visible on environments without the snooze capability", () => { const layout = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("snoozed"), title: "Snoozed", snoozedUntil: "2026-06-03T09:00:00.000Z", snoozedAt: "2026-06-01T12:00:00.000Z", }), ], environmentId: null, searchQuery: "", snoozeEnvironmentIds: new Set(), now: NOW, }); expect(layout.items.map((item) => item.thread.id)).toEqual(["snoozed"]); expect(layout.snoozedCount).toBe(0); }); it("partitions settled threads into a slim shelf", () => { const layout = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("active"), title: "Active" }), makeThread({ id: ThreadId.make("settled"), title: "Settled", settledOverride: "settled", settledAt: NOW, }), makeThread({ id: ThreadId.make("settled-2"), title: "Settled 2", settledOverride: "settled", settledAt: NOW, }), ], environmentId: null, searchQuery: "", now: NOW, }); expect(layout.items.map((item) => [item.thread.id, item.variant])).toEqual([ ["active", "card"], ["settled", "slim"], ["settled-2", "slim"], ]); expect(layout.items.map((item) => item.isLast)).toEqual([false, false, true]); expect(layout.settledCount).toBe(2); expect(layout.settledShelfHeaderIndex).toBe(1); }); it("collapses settled threads to a counted shelf header", () => { const layout = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("active"), title: "Active" }), makeThread({ id: ThreadId.make("settled"), title: "Settled", settledOverride: "settled", settledAt: NOW, }), ], environmentId: null, searchQuery: "", now: NOW, settledShelfExpanded: false, }); expect(layout.items.map((item) => item.thread.id)).toEqual(["active"]); expect(layout.settledCount).toBe(1); expect(layout.settledShelfHeaderIndex).toBe(1); }); it("keeps the selected settled thread visible when its shelf is collapsed", () => { const layout = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("selected"), title: "Selected", settledOverride: "settled", settledAt: NOW, }), makeThread({ id: ThreadId.make("other"), title: "Other", settledOverride: "settled", settledAt: NOW, }), ], environmentId: null, searchQuery: "", now: NOW, settledShelfExpanded: false, selectedThreadKey: `${environmentId}:selected`, }); expect(layout.items.map((item) => item.thread.id)).toEqual(["selected"]); expect(layout.settledCount).toBe(2); expect(layout.settledShelfHeaderIndex).toBe(0); }); it("keeps cards in creation order while settled sorts by recency", () => { const { items } = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("older-created"), title: "Older", createdAt: "2026-06-01T08:00:00.000Z", updatedAt: NOW, // recent activity must NOT promote it }), makeThread({ id: ThreadId.make("newer-created"), title: "Newer", createdAt: "2026-06-01T12:00:00.000Z", }), ], environmentId: null, searchQuery: "", now: NOW, }); expect(items.map((item) => item.thread.id)).toEqual(["newer-created", "older-created"]); }); it("sorts settled threads by their persisted settlement timestamp", () => { const { items } = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("settled-newer"), title: "Settled newer", settledOverride: "settled", settledAt: "2026-06-01T12:00:00.000Z", latestUserMessageAt: "2026-06-01T08:00:00.000Z", }), makeThread({ id: ThreadId.make("settled-older"), title: "Settled older", settledOverride: "settled", settledAt: "2026-06-01T10:00:00.000Z", latestUserMessageAt: "2026-06-01T09:00:00.000Z", }), ], environmentId: null, searchQuery: "", now: NOW, }); expect(items.map((item) => item.thread.id)).toEqual(["settled-newer", "settled-older"]); }); it("keeps settled threads in the tail and filters by search query", () => { const { items } = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("match"), title: "Fix login bug" }), makeThread({ id: ThreadId.make("miss"), title: "Greeting" }), makeThread({ id: ThreadId.make("settled"), title: "Fix login again", settledOverride: "settled", settledAt: NOW, }), ], environmentId: null, searchQuery: "login", now: NOW, }); expect(items.map((item) => [item.thread.id, item.variant])).toEqual([ ["match", "card"], ["settled", "slim"], ]); }); it("includes a thread matched by message content", () => { const thread = makeThread({ id: ThreadId.make("content-match"), title: "Unrelated title", }); const { items } = buildThreadListV2Items({ threads: [thread], environmentId: null, searchQuery: "relay reconnect", matchedThreadKeys: new Set([ threadSearchMatchKey({ environmentId, threadId: thread.id, }), ]), now: NOW, }); expect(items.map((item) => item.thread.id)).toEqual(["content-match"]); }); it("scopes the flat list to one project", () => { const otherProjectId = ProjectId.make("project-2"); const { items } = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("included"), title: "Included" }), makeThread({ id: ThreadId.make("excluded"), projectId: otherProjectId, title: "Excluded", }), ], environmentId: null, projectRefs: [{ environmentId, projectId: ProjectId.make("project-1") }], searchQuery: "", now: NOW, }); expect(items.map((item) => item.thread.id)).toEqual(["included"]); }); it("scopes the flat list to every environment member of a logical project", () => { const remoteEnvironmentId = EnvironmentId.make("environment-remote"); const { items } = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("local"), title: "Local" }), makeThread({ environmentId: remoteEnvironmentId, id: ThreadId.make("remote"), title: "Remote", }), ], environmentId: null, projectRefs: [ { environmentId, projectId: ProjectId.make("project-1") }, { environmentId: remoteEnvironmentId, projectId: ProjectId.make("project-1") }, ], searchQuery: "", now: NOW, }); expect(items.map((item) => item.thread.id)).toEqual(["local", "remote"]); }); }); describe("buildThreadListV2Items settled paging", () => { it("caps the settled tail at settledLimit and reports the hidden count", () => { const threads = [ makeThread({ id: ThreadId.make("active"), title: "Active" }), ...Array.from({ length: 4 }, (_, index) => makeThread({ id: ThreadId.make(`settled-${index}`), title: `Settled ${index}`, settledOverride: "settled", settledAt: `2026-06-01T0${index}:10:00.000Z`, latestUserMessageAt: `2026-06-01T0${index}:00:00.000Z`, // A turn adopted the message (same requestedAt): without it the // thread reads as a queued turn start, which never settles. latestTurn: { turnId: TurnId.make(`turn-${index}`), state: "completed", requestedAt: `2026-06-01T0${index}:00:00.000Z`, startedAt: `2026-06-01T0${index}:00:00.000Z`, completedAt: `2026-06-01T0${index}:10:00.000Z`, assistantMessageId: null, }, }), ), ]; const layout = buildThreadListV2Items({ threads, environmentId: null, searchQuery: "", settledLimit: 2, now: NOW, }); expect(layout.hiddenSettledCount).toBe(2); expect(layout.items.filter((item) => item.variant === "slim")).toHaveLength(2); // Most recent settled first — the hidden ones are the oldest. expect(layout.items.map((item) => item.thread.id)).toEqual([ "active", "settled-3", "settled-2", ]); }); }); function makePendingTask(id: string): PendingNewTask { const creation = { projectId: ProjectId.make("project-1"), workspaceMode: "worktree" as const, branch: null, worktreePath: null, }; return { kind: "pending", key: `pending-task:${id}`, environmentId, projectId: creation.projectId, projectTitle: undefined, projectCwd: undefined, branch: null, title: id, createdAt: NOW, message: { environmentId, threadId: ThreadId.make(`thread-${id}`), messageId: MessageId.make(id), commandId: CommandId.make(`command-${id}`), text: id, attachments: [], createdAt: NOW, creation, }, creation, }; } describe("buildThreadListV2ListItems", () => { const layout = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("active"), title: "active" }), makeThread({ id: ThreadId.make("settled"), title: "settled", settledOverride: "settled", settledAt: NOW, }), ], environmentId: null, searchQuery: "", now: NOW, }); it("splices queued tasks between the active block and the settled tail", () => { const items = buildThreadListV2ListItems({ items: layout.items, pendingTasks: [makePendingTask("queued-1"), makePendingTask("queued-2")], settledCount: layout.settledCount, settledShelfHeaderIndex: layout.settledShelfHeaderIndex, }); expect( items.map((item) => item.type === "v2-pending" ? item.pendingTask.title : item.type === "v2-thread" ? item.item.thread.id : item.type === "v2-snoozed-shelf" ? "snoozed-shelf" : "settled-shelf", ), ).toEqual(["active", "queued-1", "queued-2", "settled-shelf", "settled"]); // Only the leading queued row labels the section, exactly like Settled. expect( items.filter((item) => item.type === "v2-pending" && item.showPendingDivider), ).toHaveLength(1); }); it("ends the list with queued tasks when nothing has settled yet", () => { const activeOnly = buildThreadListV2Items({ threads: [makeThread({ id: ThreadId.make("active"), title: "active" })], environmentId: null, searchQuery: "", now: NOW, }); const items = buildThreadListV2ListItems({ items: activeOnly.items, pendingTasks: [makePendingTask("queued-1")], }); expect(items.map((item) => item.type)).toEqual(["v2-thread", "v2-pending"]); }); it("keeps the settled shelf between active and settled rows when nothing is queued", () => { const items = buildThreadListV2ListItems({ items: layout.items, pendingTasks: [], settledCount: layout.settledCount, settledShelfHeaderIndex: layout.settledShelfHeaderIndex, }); expect(items.map((item) => item.key)).toEqual([ `v2-thread:${environmentId}:active`, "v2-settled-shelf", `v2-thread:${environmentId}:settled`, ]); }); it("places queued tasks before a collapsed snoozed shelf", () => { const snoozedLayout = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("active"), title: "active" }), makeThread({ id: ThreadId.make("snoozed"), title: "snoozed", snoozedUntil: "2026-06-03T09:00:00.000Z", snoozedAt: "2026-06-01T12:00:00.000Z", }), makeThread({ id: ThreadId.make("settled"), title: "settled", settledOverride: "settled", settledAt: NOW, }), ], environmentId: null, searchQuery: "", now: NOW, }); const items = buildThreadListV2ListItems({ items: snoozedLayout.items, pendingTasks: [makePendingTask("queued")], snoozedCount: snoozedLayout.snoozedCount, snoozedShelfExpanded: false, snoozedShelfHeaderIndex: snoozedLayout.snoozedShelfHeaderIndex, settledCount: snoozedLayout.settledCount, settledShelfHeaderIndex: snoozedLayout.settledShelfHeaderIndex, }); expect(items.map((item) => item.type)).toEqual([ "v2-thread", "v2-pending", "v2-snoozed-shelf", "v2-settled-shelf", "v2-thread", ]); }); }); describe("pending mobile thread moves", () => { function fixture(section: "active" | "pinned" = "active") { const rows = ["a", "b", "c"].map((id, index) => makeThread({ id: ThreadId.make(id), title: id === "a" ? "hidden" : "match", createdAt: `2026-06-01T0${3 - index}:00:00.000Z`, pinnedAt: section === "pinned" ? `2026-06-01T0${3 - index}:00:00.000Z` : null, }), ); const ordered = getThreadListV2OrderedSection({ threads: rows, section, now: NOW }); const orderedIds = ordered.map((row) => `${row.environmentId}:${row.id}`); const movedId = orderedIds[2]!; const assignments = planPinnedMove({ orderedIds, keysById: new Map(orderedIds.map((id) => [id, null])), movedId, direction: "up", })!; const pending = createPendingThreadOrder({ section, ordered, movedId, direction: "up", assignments, }); const update = (current: EnvironmentThreadShell[], assignment: (typeof assignments)[number]) => current.map((row) => `${row.environmentId}:${row.id}` === assignment.id ? { ...row, [section === "pinned" ? "pinOrderKey" : "activeOrderKey"]: assignment.orderKey, } : row, ); return { rows, assignments, pending, update }; } function layout( rows: EnvironmentThreadShell[], pendingOrder: PendingThreadOrder | null, searchQuery = "", ) { return buildThreadListV2Items({ threads: rows, pendingOrder, environmentId: null, searchQuery, now: NOW, }).items.map((item) => item.thread.id); } it.each(["active", "pinned"] as const)( "holds %s order through every intermediate key upsert", (section) => { const { rows, assignments, pending, update } = fixture(section); let current = rows; let hold: PendingThreadOrder | null = pending; const desired = pending.orderedIds.map((id) => id.split(":")[1]); expect(layout(current, hold)).toEqual(desired); for (const assignment of assignments) { current = update(current, assignment); hold = reconcilePendingThreadOrder( hold!, getThreadListV2OrderedSection({ threads: current, section, now: NOW }), ); expect(hold).not.toBeNull(); expect(layout(current, hold)).toEqual(desired); } expect(reconcilePendingThreadOrder({ ...hold!, commandsComplete: true }, current)).toBeNull(); expect(layout(current, null)).toEqual(desired); }, ); it("keeps the action guard pending when receipts precede canonical shells", () => { const { rows, assignments, pending, update } = fixture(); let hold: PendingThreadOrder | null = { ...pending, commandsComplete: true }; let current = rows; expect(reconcilePendingThreadOrder(hold, current)).toBe(hold); for (const [index, assignment] of assignments.entries()) { current = update(current, assignment); hold = reconcilePendingThreadOrder(hold!, current); expect(hold === null).toBe(index === assignments.length - 1); expect(layout(current, hold)).toEqual(["a", "c", "b"]); } }); it("keeps search results in the full pending section order", () => { const { rows, assignments, pending, update } = fixture(); const current = update(update(rows, assignments[0]!), assignments[1]!); expect(layout(current, pending, "match")).toEqual(["c", "b"]); }); it("releases for real section membership and foreign key changes", () => { const { rows, pending } = fixture(); expect(reconcilePendingThreadOrder(pending, rows.slice(1))).toBeNull(); const newRow = makeThread({ id: ThreadId.make("new"), title: "new" }); expect(reconcilePendingThreadOrder(pending, [...rows, newRow])).toBeNull(); expect( reconcilePendingThreadOrder( pending, rows.map((row, index) => (index === 0 ? { ...row, activeOrderKey: "zz" } : row)), ), ).toBeNull(); const settled = rows.map((row, index) => index === 0 ? { ...row, settledOverride: "settled" as const } : row, ); expect(layout(settled, pending)).toEqual(layout(settled, null)); }); it("does not hide a concurrent return to a previously confirmed key", () => { const { rows, assignments, pending, update } = fixture(); const confirmed = reconcilePendingThreadOrder(pending, update(rows, assignments[0]!))!; expect(reconcilePendingThreadOrder(confirmed, rows)).toBeNull(); }); it("preserves the hold for activity but releases for a reopened sort anchor", () => { const { rows, pending } = fixture(); expect( reconcilePendingThreadOrder( pending, rows.map((row) => ({ ...row, updatedAt: NOW })), ), ).toBe(pending); expect( reconcilePendingThreadOrder( pending, rows.map((row, index) => (index === 0 ? { ...row, unsettledAt: NOW } : row)), ), ).toBeNull(); }); }); describe("mobile move availability", () => { const oldEnvironment = EnvironmentId.make("older-server"); function rows(section: "active" | "pinned", keys: readonly (string | null)[]) { return keys.map((key, index) => makeThread({ id: ThreadId.make(`move-${index}`), title: `Move ${index}`, environmentId: index === 1 ? oldEnvironment : environmentId, activeOrderKey: section === "active" ? key : null, pinOrderKey: section === "pinned" ? key : null, pinnedAt: section === "pinned" ? NOW : null, }), ); } it.each(["active", "pinned"] as const)( "keeps unsupported keyed %s neighbors as usable anchors", (section) => { const ordered = rows(section, ["bb", "dd", "ff"]); const plan = createThreadMovePlanner({ ordered, section, reorderableEnvironmentIds: new Set([environmentId]), }); const assignments = plan(`${environmentId}:move-0`, "down"); expect(assignments).toHaveLength(1); expect(assignments![0]!.id).toBe(`${environmentId}:move-0`); expect(assignments![0]!.orderKey > "dd").toBe(true); expect(assignments![0]!.orderKey < "ff").toBe(true); expect(plan(`${oldEnvironment}:move-1`, "up")).toBeNull(); expect(plan(`${environmentId}:move-0`, "up")).toBeNull(); }, ); it.each(["active", "pinned"] as const)( "disables %s moves requiring unsupported keyless materialization", (section) => { const ordered = rows(section, [null, null, null]); const plan = createThreadMovePlanner({ ordered, section, reorderableEnvironmentIds: new Set([environmentId]), }); expect(plan(`${environmentId}:move-0`, "down")).toBeNull(); expect(plan(`${environmentId}:move-2`, "up")).toBeNull(); const supported = createThreadMovePlanner({ ordered, section, reorderableEnvironmentIds: new Set([environmentId, oldEnvironment]), }); expect(supported(`${environmentId}:move-0`, "down")).toHaveLength(3); }, ); it.each(["active", "pinned"] as const)( "reserves snoozed %s keys when moving visible rows", (section) => { const ordered = rows(section, ["bb", "dd", "ff"]); const input = { ordered, section, reorderableEnvironmentIds: new Set([environmentId]) }; const collision = createThreadMovePlanner(input)(`${environmentId}:move-0`, "down")![0]! .orderKey; const hidden = { ...ordered[0]!, id: ThreadId.make("snoozed"), snoozedAt: NOW, snoozedUntil: "2099-01-01T00:00:00.000Z", pinOrderKey: section === "pinned" ? collision : null, activeOrderKey: section === "active" ? collision : null, }; const assignments = createThreadMovePlanner({ ...input, allThreads: [...ordered, hidden] })( `${environmentId}:move-0`, "down", ); expect(assignments).toHaveLength(1); expect(assignments![0]!.orderKey).not.toBe(collision); expect(assignments![0]!.orderKey > "dd" && assignments![0]!.orderKey < "ff").toBe(true); }, ); it("allows an independent keyed move despite an unsupported keyless row elsewhere", () => { const ordered = rows("active", [null, null, "bb", "dd", "ff"]); const plan = createThreadMovePlanner({ ordered, section: "active", reorderableEnvironmentIds: new Set([environmentId]), }); const assignments = plan(`${environmentId}:move-4`, "up"); expect(assignments).toHaveLength(1); expect(assignments![0]!.id).toBe(`${environmentId}:move-4`); expect(assignments![0]!.orderKey > "bb").toBe(true); expect(assignments![0]!.orderKey < "dd").toBe(true); }); }); describe("thread drag destinations", () => { it("moves across multiple rows while keeping hidden anchors in place", () => { expect( threadOrderAfterMove(["a", "hidden", "b", "c"], "c", { targetId: "a", placement: "before", }), ).toEqual(["c", "a", "hidden", "b"]); expect( threadOrderAfterMove(["a", "hidden", "b", "c"], "a", { targetId: "b", placement: "after", }), ).toEqual(["hidden", "b", "a", "c"]); }); it("rejects missing, self, and unchanged destinations", () => { for (const targetId of ["missing", "a", "b"]) { expect( threadOrderAfterMove(["a", "b", "c"], "a", { targetId, placement: "before", }), ).toBeNull(); } expect(threadOrderAfterMove(["a", "b"], "missing", "down")).toBeNull(); }); it.each(["active", "pinned"] as const)( "persists a dropped %s row and holds its order until confirmed", (section) => { const ordered = ["a", "b", "c", "d"].map((id) => makeThread({ id: ThreadId.make(id), title: id, pinnedAt: section === "pinned" ? NOW : null, }), ); const ids = ordered.map((row) => `${row.environmentId}:${row.id}`); const direction = { targetId: ids[0]!, placement: "before" as const }; const assignments = createThreadMovePlanner({ ordered, section, reorderableEnvironmentIds: new Set([environmentId]), })(ids[3]!, direction)!; const pending = createPendingThreadOrder({ section, ordered, movedId: ids[3]!, direction, assignments, }); expect(pending.orderedIds).toEqual([ids[3], ids[0], ids[1], ids[2]]); const confirmed = ordered.map((row) => ({ ...row, [section === "pinned" ? "pinOrderKey" : "activeOrderKey"]: assignments.find( (a) => a.id === `${row.environmentId}:${row.id}`, )!.orderKey, })); expect( getThreadListV2OrderedSection({ threads: confirmed, section, now: NOW }).map( (row) => `${row.environmentId}:${row.id}`, ), ).toEqual(pending.orderedIds); expect( reconcilePendingThreadOrder({ ...pending, commandsComplete: true }, confirmed), ).toBeNull(); }, ); it("refuses a drop that would need to rewrite an old server's keyless row", () => { const old = EnvironmentId.make("old-server"); const ordered = [environmentId, old, environmentId].map((env, index) => makeThread({ id: ThreadId.make(String(index)), title: String(index), environmentId: env, }), ); expect( createThreadMovePlanner({ ordered, section: "active", reorderableEnvironmentIds: new Set([environmentId]), })(`${environmentId}:2`, { targetId: `${environmentId}:0`, placement: "before" }), ).toBeNull(); }); }); it("allows a long drop past an old server even when both adjacent moves fail", () => { const old = EnvironmentId.make("old-server"); const ordered = [ makeThread({ id: ThreadId.make("a"), title: "a" }), makeThread({ id: ThreadId.make("b"), title: "b", environmentId: old }), makeThread({ id: ThreadId.make("c"), title: "c", activeOrderKey: "h" }), makeThread({ id: ThreadId.make("d"), title: "d", activeOrderKey: "p" }), ]; const planner = createThreadMovePlanner({ ordered, section: "active", reorderableEnvironmentIds: new Set([environmentId]), }); const movedId = `${environmentId}:a`; expect(planner(movedId, "up")).toBeNull(); expect(planner(movedId, "down")).toBeNull(); const assignments = planner(movedId, { targetId: `${environmentId}:d`, placement: "after" }); expect(assignments).toHaveLength(1); expect(assignments![0]!.id).toBe(movedId); expect(assignments![0]!.orderKey > "p").toBe(true); }); describe("cross-section thread drops", () => { it.each(["pinned", "active"] as const)("inserts into an empty %s section", (section) => { const thread = makeThread({ id: ThreadId.make("source"), title: "source" }); const id = `${thread.environmentId}:${thread.id}`; const destination = { section, targetId: null, placement: "before" as const }; expect(threadOrderAfterMove([], id, destination)).toEqual([id]); const plan = createThreadMovePlanner({ ordered: [], allThreads: [thread], section, reorderableEnvironmentIds: new Set([environmentId]), })(id, destination); expect(plan).toHaveLength(1); expect(plan![0]!.id).toBe(id); }); it("places an incoming row between existing anchors without rewriting them", () => { const a = makeThread({ id: ThreadId.make("a"), title: "a", pinOrderKey: "h" }); const b = makeThread({ id: ThreadId.make("b"), title: "b", pinOrderKey: "z" }); const source = makeThread({ id: ThreadId.make("source"), title: "source" }); const id = `${environmentId}:source`; const destination = { section: "pinned" as const, targetId: `${environmentId}:b`, placement: "before" as const, }; const plan = createThreadMovePlanner({ ordered: [a, b], allThreads: [a, b, source], section: "pinned", reorderableEnvironmentIds: new Set([environmentId]), })(id, destination); expect(plan).toHaveLength(1); expect(plan![0]!.orderKey > "h" && plan![0]!.orderKey < "z").toBe(true); expect( threadOrderAfterMove([`${environmentId}:a`, `${environmentId}:b`], id, destination), ).toEqual([`${environmentId}:a`, id, `${environmentId}:b`]); }); it("rejects removed targets and unsupported incoming sources", () => { expect( threadOrderAfterMove(["a"], "source", { section: "active", targetId: "gone", placement: "before", }), ).toBeNull(); const source = makeThread({ id: ThreadId.make("source"), title: "source" }); expect( createThreadMovePlanner({ ordered: [], allThreads: [source], section: "active", reorderableEnvironmentIds: new Set(), })(`${environmentId}:source`, { section: "active", targetId: null, placement: "before" }), ).toBeNull(); }); it("clears pinning, settlement and snooze when returning a parked thread to Active", () => { const thread = makeThread({ id: ThreadId.make("parked"), title: "parked", pinnedAt: NOW, settledOverride: "settled", snoozedAt: NOW, snoozedUntil: "2099-01-01T00:00:00.000Z", }); expect(threadDropLifecycle(thread, "active", NOW)).toEqual({ pin: false, unpin: true, unsettle: true, unsnooze: true, }); expect(threadDropLifecycle(thread, "pinned", NOW)).toEqual({ pin: true, unpin: false, unsettle: false, unsnooze: false, }); }); it("does not send lifecycle commands for an ordinary Active reorder", () => { expect( threadDropLifecycle( makeThread({ id: ThreadId.make("active"), title: "active" }), "active", NOW, ), ).toEqual({ pin: false, unpin: false, unsettle: false, unsnooze: false }); }); });