1480 lines · 48.2 KB · TypeScript
RawDownload
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.