2494 lines · 80.3 KB · TypeScript
RawDownload
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable";import * as Cause from "effect/Cause";import { AsyncResult } from "effect/unstable/reactivity";import {  animateSidebarLayoutChanges,  applySidebarThreadDrop,  archiveSelectedThreadEntries,  buildBulkTitleRegenerationContextMenuItem,  buildBulkUnpinContextMenuItem,  buildMultiSelectThreadContextMenuItems,  createThreadJumpHintVisibilityController,  deleteSelectedThreadEntries,  filterSidebarProjectScopeItems,  getSidebarThreadIdsToPrewarm,  resolveAdjacentThreadId,  reduceSidebarProjectScopeMenuState,  getFallbackThreadIdAfterDelete,  getProjectSortTimestamp,  hasUnseenCompletion,  isContextMenuPointerDown,  isSidebarNestedLinkClick,  isTrailingDoubleClick,  orderItemsByPreferredIds,  resolveProjectStatusIndicator,  resolveThreadRowClassName,  resolveSidebarThreadStatus,  resolveThreadStatusPill,  resolveWorkingStartedAt,  searchSidebarThreads,  formatWorkingDurationLabel,  shouldClearThreadSelectionOnMouseDown,  shouldRecedeSidebarThread,  sortLogicalProjectsForSidebar,  sortSettledThreadsForSidebar,  resolveSidebarDropTarget,  pinOrderKeyBetween,  planPinnedReorder,  planSidebarThreadDrop,  sidebarMarkerId,  sidebarListItemId,  sortPinnedThreadsForSidebar,  sortThreadsForSidebar,  sortProjectsForSidebar,  sortScopedProjectsForSidebar,  shouldCreateNewThreadInCurrentProject,  THREAD_JUMP_HINT_SHOW_DELAY_MS,  type SidebarListItem,  type SidebarListMarker,  type SidebarSection,  resolveSidebarDropVerb,} from "./Sidebar.logic";import {  EnvironmentId,  OrchestrationLatestTurn,  ProjectId,  ProviderInstanceId,  ThreadId,} from "@t3tools/contracts"; import {  DEFAULT_INTERACTION_MODE,  DEFAULT_RUNTIME_MODE,  type Project,  type SidebarThreadSummary,  type Thread,} from "../types"; const localEnvironmentId = EnvironmentId.make("environment-local"); describe("animateSidebarLayoutChanges", () => {  const baseArgs: Parameters<AnimateLayoutChanges>[0] = {    active: null,    containerId: "pinned-threads",    isDragging: false,    isSorting: false,    id: "thread-a",    index: 1,    items: ["thread-b", "thread-a"],    newIndex: 0,    previousItems: ["thread-a", "thread-b"],    previousContainerId: "pinned-threads",    transition: { duration: 200, easing: "ease" },    wasDragging: true,  };   it("does not replay layout movement after the pointer is released", () => {    expect(defaultAnimateLayoutChanges(baseArgs)).toBe(true);    expect(animateSidebarLayoutChanges(baseArgs)).toBe(false);  });   it("keeps layout movement while the user is sorting", () => {    expect(animateSidebarLayoutChanges({ ...baseArgs, isSorting: true })).toBe(true);  });}); describe("deleteSelectedThreadEntries", () => {  const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const;  const success = AsyncResult.success(undefined);  const failure = AsyncResult.failure(Cause.fail(new Error("Delete failed")));  const interrupted = AsyncResult.failure(Cause.interrupt());   it("waits for each delete and excludes only earlier successes from worktree checks", async () => {    let resolveDelete!: (result: typeof success) => void;    const pendingDelete = new Promise<typeof success>((resolve) => {      resolveDelete = resolve;    });    const worktreeChecks: { threadKey: string; deletedThreadKeys: string[] }[] = [];    const deletion = deleteSelectedThreadEntries({      entries,      delete: async ({ threadKey }, deletedThreadKeys) => {        worktreeChecks.push({ threadKey, deletedThreadKeys: [...deletedThreadKeys] });        return threadKey === "one" ? pendingDelete : success;      },    });     expect(worktreeChecks).toEqual([{ threadKey: "one", deletedThreadKeys: [] }]);    resolveDelete(success);    const outcome = await deletion;     expect(worktreeChecks).toEqual([      { threadKey: "one", deletedThreadKeys: [] },      { threadKey: "two", deletedThreadKeys: ["one"] },      { threadKey: "three", deletedThreadKeys: ["one", "two"] },    ]);    expect(outcome).toEqual({      deletedThreadKeys: new Set(["one", "two", "three"]),      firstFailure: null,    });  });   it("continues after ordinary failures and keeps the first failure", async () => {    const laterFailure = AsyncResult.failure(Cause.fail(new Error("Later failure")));    const deletedKeysAtLastEntry: string[][] = [];    const outcome = await deleteSelectedThreadEntries({      entries: [...entries, { threadKey: "four" }],      delete: async ({ threadKey }, deletedThreadKeys) => {        if (threadKey === "one") return failure;        if (threadKey === "three") return laterFailure;        if (threadKey === "four") deletedKeysAtLastEntry.push([...deletedThreadKeys]);        return success;      },    });     expect(deletedKeysAtLastEntry).toEqual([["two"]]);    expect(outcome).toEqual({      deletedThreadKeys: new Set(["two", "four"]),      firstFailure: failure,    });  });   it.each([    { firstResult: success, deletedThreadKeys: new Set(["one"]), firstFailure: null },    { firstResult: failure, deletedThreadKeys: new Set<string>(), firstFailure: failure },  ])("stops on interruption and preserves earlier results %#", async (testCase) => {    const attemptedThreadKeys: string[] = [];    const outcome = await deleteSelectedThreadEntries({      entries,      delete: async ({ threadKey }) => {        attemptedThreadKeys.push(threadKey);        return threadKey === "one" ? testCase.firstResult : interrupted;      },    });     expect(attemptedThreadKeys).toEqual(["one", "two"]);    expect(outcome).toEqual({      deletedThreadKeys: testCase.deletedThreadKeys,      firstFailure: testCase.firstFailure,    });  });   it("does not count a skipped entry as deleted", async () => {    const visibleEntries = new Set(entries.map(({ threadKey }) => threadKey));    const worktreeChecks: string[][] = [];    const outcome = await deleteSelectedThreadEntries({      entries,      delete: async ({ threadKey }, deletedThreadKeys) => {        if (!visibleEntries.has(threadKey)) return null;        worktreeChecks.push([...deletedThreadKeys]);        visibleEntries.delete("two");        return success;      },    });     expect(worktreeChecks).toEqual([[], ["one"]]);    expect(outcome).toEqual({      deletedThreadKeys: new Set(["one", "three"]),      firstFailure: null,    });  });}); describe("archiveSelectedThreadEntries", () => {  const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const;  const success = { _tag: "Success" } as const;  const failure = { _tag: "Failure" } as const;   it("records every entry after full success", async () => {    const outcome = await archiveSelectedThreadEntries({      entries,      archive: async (_entry, onArchived) => {        onArchived();        return success;      },    });     expect(outcome).toEqual({      archivedThreadKeys: ["one", "two", "three"],      mutationFailure: null,      followupFailures: [],    });  });   it("stops at a mutation failure and retains prior successes", async () => {    const archive = vi.fn(async (entry: (typeof entries)[number], onArchived: () => void) => {      if (entry.threadKey === "two") return failure;      onArchived();      return success;    });    const outcome = await archiveSelectedThreadEntries({ entries, archive });     expect(archive).toHaveBeenCalledTimes(2);    expect(outcome).toEqual({      archivedThreadKeys: ["one"],      mutationFailure: failure,      followupFailures: [],    });  });   it("continues after a post-archive failure", async () => {    const archive = vi.fn(async (entry: (typeof entries)[number], onArchived: () => void) => {      onArchived();      return entry.threadKey === "two" ? failure : success;    });    const outcome = await archiveSelectedThreadEntries({ entries, archive });     expect(archive).toHaveBeenCalledTimes(3);    expect(outcome).toEqual({      archivedThreadKeys: ["one", "two", "three"],      mutationFailure: null,      followupFailures: [failure],    });  });}); describe("buildBulkUnpinContextMenuItem", () => {  it("counts only the pinned rows of a mixed selection", () => {    expect(buildBulkUnpinContextMenuItem({ pinnedCount: 2 })).toEqual({      id: "unpin",      label: "Unpin (2)",    });  });   it("omits the action when nothing selected is pinned", () => {    expect(buildBulkUnpinContextMenuItem({ pinnedCount: 0 })).toBeNull();  });}); describe("buildBulkTitleRegenerationContextMenuItem", () => {  it("counts only threads that can start a new regeneration", () => {    expect(      buildBulkTitleRegenerationContextMenuItem({        supportedCount: 4,        actionableCount: 3,      }),    ).toEqual({      id: "regenerate-title",      label: "Regenerate titles (3)",    });  });   it("shows a disabled progress item when every supported thread is pending", () => {    expect(      buildBulkTitleRegenerationContextMenuItem({        supportedCount: 2,        actionableCount: 0,      }),    ).toEqual({      id: "regenerate-title",      label: "Regenerating… (2)",      disabled: true,    });  });   it("omits the action when no selected environment supports it", () => {    expect(      buildBulkTitleRegenerationContextMenuItem({        supportedCount: 0,        actionableCount: 0,      }),    ).toBeNull();  });}); describe("buildMultiSelectThreadContextMenuItems", () => {  it("offers bulk archive with the selected count", () => {    expect(      buildMultiSelectThreadContextMenuItems({ count: 3, hasRunningThread: false }),    ).toContainEqual({ id: "archive", label: "Archive (3)", disabled: false });  });   it("disables bulk archive when a selected thread is running", () => {    expect(      buildMultiSelectThreadContextMenuItems({ count: 2, hasRunningThread: true }),    ).toContainEqual({ id: "archive", label: "Archive (2)", disabled: true });  });}); function makeLatestTurn(overrides?: {  completedAt?: string | null;  startedAt?: string | null;}): OrchestrationLatestTurn {  return {    turnId: "turn-1" as never,    state: "completed",    assistantMessageId: null,    requestedAt: "2026-03-09T10:00:00.000Z",    startedAt:      overrides?.startedAt !== undefined ? overrides.startedAt : "2026-03-09T10:00:00.000Z",    completedAt:      overrides?.completedAt !== undefined ? overrides.completedAt : "2026-03-09T10:05:00.000Z",  };} describe("hasUnseenCompletion", () => {  it("returns true when a thread completed after its last visit", () => {    expect(      hasUnseenCompletion({        hasActionableProposedPlan: false,        hasPendingApprovals: false,        hasPendingUserInput: false,        interactionMode: "default",        latestTurn: makeLatestTurn(),        lastVisitedAt: "2026-03-09T10:04:00.000Z",        session: null,      }),    ).toBe(true);  });   it("treats a missing client visit marker as read", () => {    expect(      hasUnseenCompletion({        hasActionableProposedPlan: false,        hasPendingApprovals: false,        hasPendingUserInput: false,        interactionMode: "default",        latestTurn: makeLatestTurn(),        lastVisitedAt: undefined,        session: null,      }),    ).toBe(false);  });}); describe("shouldRecedeSidebarThread", () => {  it.each(["working", "monitoring"] as const)(    "recedes an inactive %s thread even when it is unread and woke",    (status) => {      expect(        shouldRecedeSidebarThread({          status,          isUnread: true,          isWoke: true,          isActive: false,          isSelected: false,        }),      ).toBe(true);    },  );   it.each(["ready", "approval", "input"] as const)(    "keeps an unread %s thread prominent",    (status) => {      expect(        shouldRecedeSidebarThread({          status,          isUnread: true,          isWoke: false,          isActive: false,          isSelected: false,        }),      ).toBe(false);    },  );   it("keeps active and selected working threads prominent", () => {    const input = {      status: "working" as const,      isUnread: true,      isWoke: true,      isActive: false,      isSelected: false,    };     expect(shouldRecedeSidebarThread({ ...input, isActive: true })).toBe(false);    expect(shouldRecedeSidebarThread({ ...input, isSelected: true })).toBe(false);  });}); describe("createThreadJumpHintVisibilityController", () => {  beforeEach(() => {    vi.useFakeTimers();  });   afterEach(() => {    vi.useRealTimers();  });   it("delays showing jump hints until the configured delay elapses", () => {    const visibilityChanges: boolean[] = [];    const controller = createThreadJumpHintVisibilityController({      delayMs: THREAD_JUMP_HINT_SHOW_DELAY_MS,      onVisibilityChange: (visible) => {        visibilityChanges.push(visible);      },    });     controller.sync(true);    vi.advanceTimersByTime(THREAD_JUMP_HINT_SHOW_DELAY_MS - 1);     expect(visibilityChanges).toEqual([]);     vi.advanceTimersByTime(1);     expect(visibilityChanges).toEqual([true]);  });   it("hides immediately when the modifiers are released", () => {    const visibilityChanges: boolean[] = [];    const controller = createThreadJumpHintVisibilityController({      delayMs: THREAD_JUMP_HINT_SHOW_DELAY_MS,      onVisibilityChange: (visible) => {        visibilityChanges.push(visible);      },    });     controller.sync(true);    vi.advanceTimersByTime(THREAD_JUMP_HINT_SHOW_DELAY_MS);    controller.sync(false);     expect(visibilityChanges).toEqual([true, false]);  });   it("cancels a pending reveal when the modifier is released early", () => {    const visibilityChanges: boolean[] = [];    const controller = createThreadJumpHintVisibilityController({      delayMs: THREAD_JUMP_HINT_SHOW_DELAY_MS,      onVisibilityChange: (visible) => {        visibilityChanges.push(visible);      },    });     controller.sync(true);    vi.advanceTimersByTime(Math.floor(THREAD_JUMP_HINT_SHOW_DELAY_MS / 2));    controller.sync(false);    vi.advanceTimersByTime(THREAD_JUMP_HINT_SHOW_DELAY_MS);     expect(visibilityChanges).toEqual([]);  });}); describe("getSidebarThreadIdsToPrewarm", () => {  it("returns only the first visible thread ids up to the prewarm limit", () => {    expect(getSidebarThreadIdsToPrewarm(["t1", "t2", "t3"], 2)).toEqual(["t1", "t2"]);  });   it("returns all visible thread ids when they fit within the limit", () => {    expect(getSidebarThreadIdsToPrewarm(["t1", "t2"], 10)).toEqual(["t1", "t2"]);  });   it("returns no thread ids when the limit is zero", () => {    expect(getSidebarThreadIdsToPrewarm(["t1", "t2"], 0)).toEqual([]);  });}); describe("shouldClearThreadSelectionOnMouseDown", () => {  it("preserves selection for thread items", () => {    const child = {      closest: (selector: string) =>        selector.includes("[data-thread-item]") ? ({} as Element) : null,    } as unknown as HTMLElement;     expect(shouldClearThreadSelectionOnMouseDown(child)).toBe(false);  });   it("preserves selection for thread list toggle controls", () => {    const selectionSafe = {      closest: (selector: string) =>        selector.includes("[data-thread-selection-safe]") ? ({} as Element) : null,    } as unknown as HTMLElement;     expect(shouldClearThreadSelectionOnMouseDown(selectionSafe)).toBe(false);  });   it("clears selection for unrelated sidebar clicks", () => {    const unrelated = {      closest: () => null,    } as unknown as HTMLElement;     expect(shouldClearThreadSelectionOnMouseDown(unrelated)).toBe(true);

Lines 1–500 of 2494.