import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable";import { animatePinnedLayoutChanges, archiveSelectedThreadEntries, buildBulkTitleRegenerationContextMenuItem, buildBulkUnpinContextMenuItem, buildMultiSelectThreadContextMenuItems, createThreadJumpHintVisibilityController, filterSidebarProjectScopeItems, getSidebarThreadIdsToPrewarm, getVisibleSidebarThreadIds, resolveAdjacentThreadId, reduceSidebarProjectScopeMenuState, getFallbackThreadIdAfterDelete, getVisibleThreadsForProject, getProjectSortTimestamp, hasUnseenCompletion, isContextMenuPointerDown, isSidebarNestedLinkClick, isTrailingDoubleClick, orderItemsByPreferredIds, resolveProjectStatusIndicator, resolveThreadRowClassName, resolveSidebarThreadStatus, resolveThreadStatusPill, resolveWorkingStartedAt, searchSidebarThreadsByTitle, formatWorkingDurationLabel, shouldNavigateAfterProjectRemoval, shouldClearThreadSelectionOnMouseDown, shouldRecedeSidebarThread, sortLogicalProjectsForSidebar, sortSettledThreadsForSidebar, pinOrderKeyBetween, planPinnedReorder, sortPinnedThreadsForSidebar, sortThreadsForSidebar, sortProjectsForSidebar, sortScopedProjectsForSidebar, shouldCreateNewThreadInCurrentProject, THREAD_JUMP_HINT_SHOW_DELAY_MS,} from "./Sidebar.logic";import { EnvironmentId, OrchestrationLatestTurn, ProjectId, ProviderInstanceId, ThreadId,} from "@t3tools/contracts"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Project, type Thread,} from "../types"; const localEnvironmentId = EnvironmentId.make("environment-local"); describe("animatePinnedLayoutChanges", () => { 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(animatePinnedLayoutChanges(baseArgs)).toBe(false); }); it("keeps layout movement while the user is sorting", () => { expect(animatePinnedLayoutChanges({ ...baseArgs, isSorting: true })).toBe(true); });}); describe("shouldNavigateAfterProjectRemoval", () => { const projectThreads = [{ environmentId: "environment-local", id: "thread-1" }]; it("navigates away from a draft route owned by the removed project", () => { expect( shouldNavigateAfterProjectRemoval({ routeTarget: { kind: "draft", draftId: "draft-1" as never }, projectThreads, projectDraftId: "draft-1", }), ).toBe(true); }); it("does not navigate away from a different draft route", () => { expect( shouldNavigateAfterProjectRemoval({ routeTarget: { kind: "draft", draftId: "draft-2" as never }, projectThreads, projectDraftId: "draft-1", }), ).toBe(false); }); it("navigates away from a server thread owned by the removed project", () => { expect( shouldNavigateAfterProjectRemoval({ routeTarget: { kind: "server", threadRef: { environmentId: EnvironmentId.make("environment-local"), threadId: ThreadId.make("thread-1"), }, }, projectThreads, projectDraftId: null, }), ).toBe(true); }); it("does not navigate from an unrelated route", () => { expect( shouldNavigateAfterProjectRemoval({ routeTarget: null, projectThreads, projectDraftId: null, }), ).toBe(false); });}); 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); });}); describe("isTrailingDoubleClick", () => { it("treats a single click as a normal activation", () => { expect(isTrailingDoubleClick(1)).toBe(false); }); it("treats synthetic/keyboard activations (detail 0) as a normal activation", () => { expect(isTrailingDoubleClick(0)).toBe(false); }); it("ignores the second click of a double-click so it does not navigate", () => { expect(isTrailingDoubleClick(2)).toBe(true); }); it("ignores further clicks of a triple-click", () => { expect(isTrailingDoubleClick(3)).toBe(true); });}); describe("isSidebarNestedLinkClick", () => { const linkTarget = { closest: (selector: string) => (selector === "a[href]" ? ({} as Element) : null), } as unknown as EventTarget; it("ignores row clicks that originated on a nested link", () => { expect(isSidebarNestedLinkClick(linkTarget)).toBe(true); }); it("walks up from a text node to the enclosing link", () => { expect(isSidebarNestedLinkClick({ parentElement: linkTarget } as unknown as EventTarget)).toBe( true, ); }); it("leaves ordinary row clicks alone", () => { expect(isSidebarNestedLinkClick({ closest: () => null } as unknown as EventTarget)).toBe(false); expect(isSidebarNestedLinkClick(null)).toBe(false); });}); describe("shouldCreateNewThreadInCurrentProject", () => { it("creates directly on shift+click in a multi-project setup", () => { expect(shouldCreateNewThreadInCurrentProject(true, 2)).toBe(true); }); it("opens the picker on a plain click in a multi-project setup", () => { expect(shouldCreateNewThreadInCurrentProject(false, 2)).toBe(false); }); it("creates directly on any click with a single project", () => { expect(shouldCreateNewThreadInCurrentProject(false, 1)).toBe(true); expect(shouldCreateNewThreadInCurrentProject(true, 1)).toBe(true); });});Lines 1–500 of 1798. Load more
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable"; import { animatePinnedLayoutChanges, archiveSelectedThreadEntries, buildBulkTitleRegenerationContextMenuItem, buildBulkUnpinContextMenuItem, buildMultiSelectThreadContextMenuItems, createThreadJumpHintVisibilityController, filterSidebarProjectScopeItems, getSidebarThreadIdsToPrewarm, getVisibleSidebarThreadIds, resolveAdjacentThreadId, reduceSidebarProjectScopeMenuState, getFallbackThreadIdAfterDelete, getVisibleThreadsForProject, getProjectSortTimestamp, hasUnseenCompletion, isContextMenuPointerDown, isSidebarNestedLinkClick, isTrailingDoubleClick, orderItemsByPreferredIds, resolveProjectStatusIndicator, resolveThreadRowClassName, resolveSidebarThreadStatus, resolveThreadStatusPill, resolveWorkingStartedAt, searchSidebarThreadsByTitle, formatWorkingDurationLabel, shouldNavigateAfterProjectRemoval, shouldClearThreadSelectionOnMouseDown, shouldRecedeSidebarThread, sortLogicalProjectsForSidebar, sortSettledThreadsForSidebar, pinOrderKeyBetween, planPinnedReorder, sortPinnedThreadsForSidebar, sortThreadsForSidebar, sortProjectsForSidebar, sortScopedProjectsForSidebar, shouldCreateNewThreadInCurrentProject, THREAD_JUMP_HINT_SHOW_DELAY_MS, } from "./Sidebar.logic"; import { EnvironmentId, OrchestrationLatestTurn, ProjectId, ProviderInstanceId, ThreadId, } from "@t3tools/contracts"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Project, type Thread, } from "../types"; const localEnvironmentId = EnvironmentId.make("environment-local"); describe("animatePinnedLayoutChanges", () => { 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(animatePinnedLayoutChanges(baseArgs)).toBe(false); }); it("keeps layout movement while the user is sorting", () => { expect(animatePinnedLayoutChanges({ ...baseArgs, isSorting: true })).toBe(true); }); }); describe("shouldNavigateAfterProjectRemoval", () => { const projectThreads = [{ environmentId: "environment-local", id: "thread-1" }]; it("navigates away from a draft route owned by the removed project", () => { expect( shouldNavigateAfterProjectRemoval({ routeTarget: { kind: "draft", draftId: "draft-1" as never }, projectThreads, projectDraftId: "draft-1", }), ).toBe(true); }); it("does not navigate away from a different draft route", () => { expect( shouldNavigateAfterProjectRemoval({ routeTarget: { kind: "draft", draftId: "draft-2" as never }, projectThreads, projectDraftId: "draft-1", }), ).toBe(false); }); it("navigates away from a server thread owned by the removed project", () => { expect( shouldNavigateAfterProjectRemoval({ routeTarget: { kind: "server", threadRef: { environmentId: EnvironmentId.make("environment-local"), threadId: ThreadId.make("thread-1"), }, }, projectThreads, projectDraftId: null, }), ).toBe(true); }); it("does not navigate from an unrelated route", () => { expect( shouldNavigateAfterProjectRemoval({ routeTarget: null, projectThreads, projectDraftId: null, }), ).toBe(false); }); }); 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); }); }); describe("isTrailingDoubleClick", () => { it("treats a single click as a normal activation", () => { expect(isTrailingDoubleClick(1)).toBe(false); }); it("treats synthetic/keyboard activations (detail 0) as a normal activation", () => { expect(isTrailingDoubleClick(0)).toBe(false); }); it("ignores the second click of a double-click so it does not navigate", () => { expect(isTrailingDoubleClick(2)).toBe(true); }); it("ignores further clicks of a triple-click", () => { expect(isTrailingDoubleClick(3)).toBe(true); }); }); describe("isSidebarNestedLinkClick", () => { const linkTarget = { closest: (selector: string) => (selector === "a[href]" ? ({} as Element) : null), } as unknown as EventTarget; it("ignores row clicks that originated on a nested link", () => { expect(isSidebarNestedLinkClick(linkTarget)).toBe(true); }); it("walks up from a text node to the enclosing link", () => { expect(isSidebarNestedLinkClick({ parentElement: linkTarget } as unknown as EventTarget)).toBe( true, ); }); it("leaves ordinary row clicks alone", () => { expect(isSidebarNestedLinkClick({ closest: () => null } as unknown as EventTarget)).toBe(false); expect(isSidebarNestedLinkClick(null)).toBe(false); }); }); describe("shouldCreateNewThreadInCurrentProject", () => { it("creates directly on shift+click in a multi-project setup", () => { expect(shouldCreateNewThreadInCurrentProject(true, 2)).toBe(true); }); it("opens the picker on a plain click in a multi-project setup", () => { expect(shouldCreateNewThreadInCurrentProject(false, 2)).toBe(false); }); it("creates directly on any click with a single project", () => { expect(shouldCreateNewThreadInCurrentProject(false, 1)).toBe(true); expect(shouldCreateNewThreadInCurrentProject(true, 1)).toBe(true); }); });