import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId, TurnId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { buildThreadListV2Items, 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("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("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", ]); }); });