import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable";import { THREAD_JUMP_HINT_SHOW_DELAY_MS, animatePinnedLayoutChanges, archiveSelectedThreadEntries, buildBulkTitleRegenerationContextMenuItem, buildBulkUnpinContextMenuItem, buildMultiSelectThreadContextMenuItems, createThreadJumpHintVisibilityController, filterSidebarProjectScopeItems, filterSidebarV2VisibleThreads, formatWorkingDurationLabel, getFallbackThreadIdAfterDelete, getProjectSortTimestamp, getSidebarForkParentThreadId, getSidebarThreadIdsToPrewarm, getVisibleSidebarThreadIds, getVisibleThreadsForProject, hasUnseenCompletion, isContextMenuPointerDown, isSidebarSubagentThread, isTrailingDoubleClick, orderItemsByPreferredIds, pinOrderKeyBetween, planPinnedReorder, reduceSidebarProjectScopeMenuState, resolveAdjacentThreadId, resolveProjectStatusIndicator, resolveSidebarStageBadgeLabel, resolveSidebarThreadStatus, resolveSidebarV2TopStatus, resolveThreadLastVisitedAt, resolveThreadRowClassName, resolveThreadStatusPill, resolveWorkingStartedAt, searchSidebarThreadsByTitle, shouldClearThreadSelectionOnMouseDown, shouldNavigateAfterProjectRemoval, shouldShowSidebarV2Duration, shouldRecedeSidebarThread, sortLogicalProjectsForSidebar, sortPinnedThreadsForSidebar, sortProjectsForSidebar, sortScopedProjectsForSidebar, sortSettledThreadsForSidebar, sortSidebarV2ProjectGroups, sortThreadsForSidebar,} from "./Sidebar.logic";import { EnvironmentId, ProjectId, ProviderInstanceId, RunId, ThreadId } from "@t3tools/contracts";import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Project, type Thread,} from "../types";import { makeThreadFixture, type ThreadFixtureOverrides } from "../test-fixtures"; 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 }); });}); describe("resolveSidebarStageBadgeLabel", () => { it("returns Nightly for nightly primary server versions", () => { expect( resolveSidebarStageBadgeLabel({ primaryServerVersion: "0.0.28-nightly.20260616.12", fallbackStageLabel: "Alpha", }), ).toBe("Nightly"); }); it("returns the fallback label for stable primary server versions", () => { expect( resolveSidebarStageBadgeLabel({ primaryServerVersion: "0.0.27", fallbackStageLabel: "Alpha", }), ).toBe("Alpha"); }); it("returns the fallback label when the primary server version is missing", () => { expect( resolveSidebarStageBadgeLabel({ primaryServerVersion: null, fallbackStageLabel: "Dev", }), ).toBe("Dev"); }); it("returns the fallback label for malformed nightly prerelease versions", () => { expect( resolveSidebarStageBadgeLabel({ primaryServerVersion: "0.0.28-nightly.20260616", fallbackStageLabel: "Alpha", }), ).toBe("Alpha"); });}); describe("sidebar thread lineage helpers", () => { it("keeps only top-level, unarchived threads in the Sidebar V2 project scope", () => { const parentId = ThreadId.make("thread-parent"); const projectId = ProjectId.make("project-visible"); const environmentId = EnvironmentId.make("environment-visible"); const root = makeThreadFixture({ id: parentId, environmentId, projectId, }); const subagent = makeThreadFixture({ id: ThreadId.make("thread-subagent"), environmentId, projectId, lineage: { rootThreadId: parentId, parentThreadId: parentId, relationshipToParent: "subagent", }, }); const fork = makeThreadFixture({ id: ThreadId.make("thread-fork"), environmentId, projectId, lineage: { rootThreadId: parentId, parentThreadId: parentId, relationshipToParent: "fork", }, }); const archived = makeThreadFixture({ id: ThreadId.make("thread-archived"), environmentId, projectId, archivedAt: "2026-01-02T00:00:00.000Z", }); const otherProject = makeThreadFixture({ id: ThreadId.make("thread-other-project"), environmentId, projectId: ProjectId.make("project-other"), }); expect( filterSidebarV2VisibleThreads( [root, subagent, fork, archived, otherProject], new Set([`${environmentId}:${projectId}`]), ).map((thread) => thread.id), ).toEqual([parentId, fork.id]); }); it("identifies subagent threads so the sidebar can hide them", () => { const parentId = ThreadId.make("thread-parent"); const subagent = makeThreadFixture({ lineage: { rootThreadId: parentId, parentThreadId: parentId, relationshipToParent: "subagent", }, }); expect(isSidebarSubagentThread(subagent)).toBe(true); expect(isSidebarSubagentThread(makeThreadFixture())).toBe(false); }); it("resolves the parent thread for fork sidebar affordances", () => { const parentId = ThreadId.make("thread-parent"); const fallbackParentId = ThreadId.make("thread-fallback-parent"); const runFork = makeThreadFixture({ forkedFrom: { type: "run", threadId: parentId, runId: "run-1" as never }, lineage: { rootThreadId: parentId, parentThreadId: fallbackParentId, relationshipToParent: "fork", }, }); const lineageFork = makeThreadFixture({ lineage: { rootThreadId: parentId, parentThreadId: fallbackParentId, relationshipToParent: "fork", }, }); expect(getSidebarForkParentThreadId(runFork)).toBe(parentId); expect(getSidebarForkParentThreadId(lineageFork)).toBe(fallbackParentId); expect(getSidebarForkParentThreadId(makeThreadFixture())).toBeNull(); });}); function makeLatestRun(overrides?: { completedAt?: string | null; startedAt?: string | null;}): NonNullable<Thread["latestRun"]> { return { runId: "turn-1" as never, status: "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", latestRun: makeLatestRun(), lastVisitedAt: "2026-03-09T10:04:00.000Z", runtime: null, }), ).toBe(true); }); it("treats a missing client visit marker as read", () => { expect( hasUnseenCompletion({ hasActionableProposedPlan: false, hasPendingApprovals: false, hasPendingUserInput: false, interactionMode: "default", latestRun: makeLatestRun(), lastVisitedAt: undefined, runtime: null, }), ).toBe(false); });}); describe("shouldRecedeSidebarThread", () => { it.each(["working", "waiting"] 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[] = [];Lines 1–500 of 2044. Load more
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable"; import { THREAD_JUMP_HINT_SHOW_DELAY_MS, animatePinnedLayoutChanges, archiveSelectedThreadEntries, buildBulkTitleRegenerationContextMenuItem, buildBulkUnpinContextMenuItem, buildMultiSelectThreadContextMenuItems, createThreadJumpHintVisibilityController, filterSidebarProjectScopeItems, filterSidebarV2VisibleThreads, formatWorkingDurationLabel, getFallbackThreadIdAfterDelete, getProjectSortTimestamp, getSidebarForkParentThreadId, getSidebarThreadIdsToPrewarm, getVisibleSidebarThreadIds, getVisibleThreadsForProject, hasUnseenCompletion, isContextMenuPointerDown, isSidebarSubagentThread, isTrailingDoubleClick, orderItemsByPreferredIds, pinOrderKeyBetween, planPinnedReorder, reduceSidebarProjectScopeMenuState, resolveAdjacentThreadId, resolveProjectStatusIndicator, resolveSidebarStageBadgeLabel, resolveSidebarThreadStatus, resolveSidebarV2TopStatus, resolveThreadLastVisitedAt, resolveThreadRowClassName, resolveThreadStatusPill, resolveWorkingStartedAt, searchSidebarThreadsByTitle, shouldClearThreadSelectionOnMouseDown, shouldNavigateAfterProjectRemoval, shouldShowSidebarV2Duration, shouldRecedeSidebarThread, sortLogicalProjectsForSidebar, sortPinnedThreadsForSidebar, sortProjectsForSidebar, sortScopedProjectsForSidebar, sortSettledThreadsForSidebar, sortSidebarV2ProjectGroups, sortThreadsForSidebar, } from "./Sidebar.logic"; import { EnvironmentId, ProjectId, ProviderInstanceId, RunId, ThreadId } from "@t3tools/contracts"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Project, type Thread, } from "../types"; import { makeThreadFixture, type ThreadFixtureOverrides } from "../test-fixtures"; 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 }); }); }); describe("resolveSidebarStageBadgeLabel", () => { it("returns Nightly for nightly primary server versions", () => { expect( resolveSidebarStageBadgeLabel({ primaryServerVersion: "0.0.28-nightly.20260616.12", fallbackStageLabel: "Alpha", }), ).toBe("Nightly"); }); it("returns the fallback label for stable primary server versions", () => { expect( resolveSidebarStageBadgeLabel({ primaryServerVersion: "0.0.27", fallbackStageLabel: "Alpha", }), ).toBe("Alpha"); }); it("returns the fallback label when the primary server version is missing", () => { expect( resolveSidebarStageBadgeLabel({ primaryServerVersion: null, fallbackStageLabel: "Dev", }), ).toBe("Dev"); }); it("returns the fallback label for malformed nightly prerelease versions", () => { expect( resolveSidebarStageBadgeLabel({ primaryServerVersion: "0.0.28-nightly.20260616", fallbackStageLabel: "Alpha", }), ).toBe("Alpha"); }); }); describe("sidebar thread lineage helpers", () => { it("keeps only top-level, unarchived threads in the Sidebar V2 project scope", () => { const parentId = ThreadId.make("thread-parent"); const projectId = ProjectId.make("project-visible"); const environmentId = EnvironmentId.make("environment-visible"); const root = makeThreadFixture({ id: parentId, environmentId, projectId, }); const subagent = makeThreadFixture({ id: ThreadId.make("thread-subagent"), environmentId, projectId, lineage: { rootThreadId: parentId, parentThreadId: parentId, relationshipToParent: "subagent", }, }); const fork = makeThreadFixture({ id: ThreadId.make("thread-fork"), environmentId, projectId, lineage: { rootThreadId: parentId, parentThreadId: parentId, relationshipToParent: "fork", }, }); const archived = makeThreadFixture({ id: ThreadId.make("thread-archived"), environmentId, projectId, archivedAt: "2026-01-02T00:00:00.000Z", }); const otherProject = makeThreadFixture({ id: ThreadId.make("thread-other-project"), environmentId, projectId: ProjectId.make("project-other"), }); expect( filterSidebarV2VisibleThreads( [root, subagent, fork, archived, otherProject], new Set([`${environmentId}:${projectId}`]), ).map((thread) => thread.id), ).toEqual([parentId, fork.id]); }); it("identifies subagent threads so the sidebar can hide them", () => { const parentId = ThreadId.make("thread-parent"); const subagent = makeThreadFixture({ lineage: { rootThreadId: parentId, parentThreadId: parentId, relationshipToParent: "subagent", }, }); expect(isSidebarSubagentThread(subagent)).toBe(true); expect(isSidebarSubagentThread(makeThreadFixture())).toBe(false); }); it("resolves the parent thread for fork sidebar affordances", () => { const parentId = ThreadId.make("thread-parent"); const fallbackParentId = ThreadId.make("thread-fallback-parent"); const runFork = makeThreadFixture({ forkedFrom: { type: "run", threadId: parentId, runId: "run-1" as never }, lineage: { rootThreadId: parentId, parentThreadId: fallbackParentId, relationshipToParent: "fork", }, }); const lineageFork = makeThreadFixture({ lineage: { rootThreadId: parentId, parentThreadId: fallbackParentId, relationshipToParent: "fork", }, }); expect(getSidebarForkParentThreadId(runFork)).toBe(parentId); expect(getSidebarForkParentThreadId(lineageFork)).toBe(fallbackParentId); expect(getSidebarForkParentThreadId(makeThreadFixture())).toBeNull(); }); }); function makeLatestRun(overrides?: { completedAt?: string | null; startedAt?: string | null; }): NonNullable<Thread["latestRun"]> { return { runId: "turn-1" as never, status: "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", latestRun: makeLatestRun(), lastVisitedAt: "2026-03-09T10:04:00.000Z", runtime: null, }), ).toBe(true); }); it("treats a missing client visit marker as read", () => { expect( hasUnseenCompletion({ hasActionableProposedPlan: false, hasPendingApprovals: false, hasPendingUserInput: false, interactionMode: "default", latestRun: makeLatestRun(), lastVisitedAt: undefined, runtime: null, }), ).toBe(false); }); }); describe("shouldRecedeSidebarThread", () => { it.each(["working", "waiting"] 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[] = [];