459 lines · 14.2 KB · TypeScript
RawDownload
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<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,    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`,    ]);  });});