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. Load more
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);