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<EnvironmentThreadShell> & Pick<EnvironmentThreadShell, "id" | "title">,): 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: "",Lines 1–500 of 1480. Load more
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<EnvironmentThreadShell> & Pick<EnvironmentThreadShell, "id" | "title">, ): 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: "",