import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; 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, resolveThreadListV2Enabled, resolveThreadListV2Status, 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, 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"; describe("resolveThreadListV2Enabled", () => { it("defaults on when the device has never chosen", () => { expect(resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: true })).toBe( true, ); }); it("honors an explicit device opt-out", () => { expect(resolveThreadListV2Enabled({ preference: false, preferencesLoaded: true })).toBe(false); expect(resolveThreadListV2Enabled({ preference: true, preferencesLoaded: true })).toBe(true); }); it("holds the default while preferences are still loading so the list does not remount", () => { expect(resolveThreadListV2Enabled({ preference: 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("sortThreadsForListV2", () => { 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"]); }); }); describe("buildThreadListV2Items", () => { 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("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: "", // Minute-floored partition clock vs precise snooze clock. now: "2026-06-02T00:01:00.000Z", snoozeNow: "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("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 tail with one divider", () => { const { items } = 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(items.map((item) => [item.thread.id, item.variant])).toEqual([ ["active", "card"], ["settled", "slim"], ["settled-2", "slim"], ]); expect(items.map((item) => item.showSettledDivider)).toEqual([false, true, false]); expect(items.map((item) => item.isLast)).toEqual([false, false, true]); }); 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("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: NOW, 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 { return { message: { environmentId, threadId: ThreadId.make(`thread-${id}`), messageId: MessageId.make(id), commandId: CommandId.make(`command-${id}`), text: id, attachments: [], createdAt: NOW, creation: { projectId: ProjectId.make("project-1"), workspaceMode: "worktree", branch: null, worktreePath: null, }, }, creation: { projectId: ProjectId.make("project-1"), workspaceMode: "worktree", branch: null, worktreePath: null, }, title: id, }; } 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")], }); expect( items.map((item) => item.type === "v2-pending" ? item.pendingTask.title : item.item.thread.id, ), ).toEqual(["active", "queued-1", "queued-2", "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("leaves the thread order untouched when nothing is queued", () => { const items = buildThreadListV2ListItems({ items: layout.items, pendingTasks: [] }); expect(items.map((item) => item.key)).toEqual([ `v2-thread:${environmentId}:active`, `v2-thread:${environmentId}:settled`, ]); }); });