import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createThreadJumpHintVisibilityController, getVisibleSidebarThreadIds, resolveAdjacentThreadId, getFallbackThreadIdAfterDelete, getVisibleThreadsForProject, getProjectSortTimestamp, hasUnseenCompletion, isContextMenuPointerDown, orderItemsByPreferredIds, resolveProjectStatusIndicator, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, sortThreadsForSidebar, THREAD_JUMP_HINT_SHOW_DELAY_MS, } from "./Sidebar.logic"; import { OrchestrationLatestTurn, ProjectId, ThreadId } from "@t3tools/contracts"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Project, type Thread, } from "../types"; 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 ?? "2026-03-09T10:00:00.000Z", completedAt: overrides?.completedAt ?? "2026-03-09T10:05:00.000Z", }; } describe("hasUnseenCompletion", () => { it("returns true when a thread completed after its last visit", () => { expect( hasUnseenCompletion({ interactionMode: "default", latestTurn: makeLatestTurn(), lastVisitedAt: "2026-03-09T10:04:00.000Z", proposedPlans: [], session: null, }), ).toBe(true); }); }); 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("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("resolveSidebarNewThreadEnvMode", () => { it("uses the app default when the caller does not request a specific mode", () => { expect( resolveSidebarNewThreadEnvMode({ defaultEnvMode: "worktree", }), ).toBe("worktree"); }); it("preserves an explicit requested mode over the app default", () => { expect( resolveSidebarNewThreadEnvMode({ requestedEnvMode: "local", defaultEnvMode: "worktree", }), ).toBe("local"); }); }); describe("orderItemsByPreferredIds", () => { it("keeps preferred ids first, skips stale ids, and preserves the relative order of remaining items", () => { const ordered = orderItemsByPreferredIds({ items: [ { id: ProjectId.makeUnsafe("project-1"), name: "One" }, { id: ProjectId.makeUnsafe("project-2"), name: "Two" }, { id: ProjectId.makeUnsafe("project-3"), name: "Three" }, ], preferredIds: [ ProjectId.makeUnsafe("project-3"), ProjectId.makeUnsafe("project-missing"), ProjectId.makeUnsafe("project-1"), ], getId: (project) => project.id, }); expect(ordered.map((project) => project.id)).toEqual([ ProjectId.makeUnsafe("project-3"), ProjectId.makeUnsafe("project-1"), ProjectId.makeUnsafe("project-2"), ]); }); it("does not duplicate items when preferred ids repeat", () => { const ordered = orderItemsByPreferredIds({ items: [ { id: ProjectId.makeUnsafe("project-1"), name: "One" }, { id: ProjectId.makeUnsafe("project-2"), name: "Two" }, ], preferredIds: [ ProjectId.makeUnsafe("project-2"), ProjectId.makeUnsafe("project-1"), ProjectId.makeUnsafe("project-2"), ], getId: (project) => project.id, }); expect(ordered.map((project) => project.id)).toEqual([ ProjectId.makeUnsafe("project-2"), ProjectId.makeUnsafe("project-1"), ]); }); }); describe("resolveAdjacentThreadId", () => { it("resolves adjacent thread ids in ordered sidebar traversal", () => { const threads = [ ThreadId.makeUnsafe("thread-1"), ThreadId.makeUnsafe("thread-2"), ThreadId.makeUnsafe("thread-3"), ]; expect( resolveAdjacentThreadId({ threadIds: threads, currentThreadId: threads[1] ?? null, direction: "previous", }), ).toBe(threads[0]); expect( resolveAdjacentThreadId({ threadIds: threads, currentThreadId: threads[1] ?? null, direction: "next", }), ).toBe(threads[2]); expect( resolveAdjacentThreadId({ threadIds: threads, currentThreadId: null, direction: "next", }), ).toBe(threads[0]); expect( resolveAdjacentThreadId({ threadIds: threads, currentThreadId: null, direction: "previous", }), ).toBe(threads[2]); expect( resolveAdjacentThreadId({ threadIds: threads, currentThreadId: threads[0] ?? null, direction: "previous", }), ).toBeNull(); }); }); describe("getVisibleSidebarThreadIds", () => { it("returns only the rendered visible thread order across projects", () => { expect( getVisibleSidebarThreadIds([ { renderedThreads: [ { id: ThreadId.makeUnsafe("thread-12") }, { id: ThreadId.makeUnsafe("thread-11") }, { id: ThreadId.makeUnsafe("thread-10") }, ], }, { renderedThreads: [ { id: ThreadId.makeUnsafe("thread-8") }, { id: ThreadId.makeUnsafe("thread-6") }, ], }, ]), ).toEqual([ ThreadId.makeUnsafe("thread-12"), ThreadId.makeUnsafe("thread-11"), ThreadId.makeUnsafe("thread-10"), ThreadId.makeUnsafe("thread-8"), ThreadId.makeUnsafe("thread-6"), ]); }); it("skips threads from collapsed projects whose thread panels are not shown", () => { expect( getVisibleSidebarThreadIds([ { shouldShowThreadPanel: false, renderedThreads: [ { id: ThreadId.makeUnsafe("thread-hidden-2") }, { id: ThreadId.makeUnsafe("thread-hidden-1") }, ], }, { shouldShowThreadPanel: true, renderedThreads: [ { id: ThreadId.makeUnsafe("thread-12") }, { id: ThreadId.makeUnsafe("thread-11") }, ], }, ]), ).toEqual([ThreadId.makeUnsafe("thread-12"), ThreadId.makeUnsafe("thread-11")]); }); }); describe("isContextMenuPointerDown", () => { it("treats secondary-button presses as context menu gestures on all platforms", () => { expect( isContextMenuPointerDown({ button: 2, ctrlKey: false, isMac: false, }), ).toBe(true); }); it("treats ctrl+primary-click as a context menu gesture on macOS", () => { expect( isContextMenuPointerDown({ button: 0, ctrlKey: true, isMac: true, }), ).toBe(true); }); it("does not treat ctrl+primary-click as a context menu gesture off macOS", () => { expect( isContextMenuPointerDown({ button: 0, ctrlKey: true, isMac: false, }), ).toBe(false); }); }); describe("resolveThreadStatusPill", () => { const baseThread = { interactionMode: "plan" as const, latestTurn: null, lastVisitedAt: undefined, proposedPlans: [], session: { provider: "codex" as const, status: "running" as const, createdAt: "2026-03-09T10:00:00.000Z", updatedAt: "2026-03-09T10:00:00.000Z", orchestrationStatus: "running" as const, }, }; it("shows pending approval before all other statuses", () => { expect( resolveThreadStatusPill({ thread: baseThread, hasPendingApprovals: true, hasPendingUserInput: true, }), ).toMatchObject({ label: "Pending Approval", pulse: false }); }); it("shows awaiting input when plan mode is blocked on user answers", () => { expect( resolveThreadStatusPill({ thread: baseThread, hasPendingApprovals: false, hasPendingUserInput: true, }), ).toMatchObject({ label: "Awaiting Input", pulse: false }); }); it("falls back to working when the thread is actively running without blockers", () => { expect( resolveThreadStatusPill({ thread: baseThread, hasPendingApprovals: false, hasPendingUserInput: false, }), ).toMatchObject({ label: "Working", pulse: true }); }); it("shows plan ready when a settled plan turn has a proposed plan ready for follow-up", () => { expect( resolveThreadStatusPill({ thread: { ...baseThread, latestTurn: makeLatestTurn(), proposedPlans: [ { id: "plan-1" as never, turnId: "turn-1" as never, createdAt: "2026-03-09T10:00:00.000Z", updatedAt: "2026-03-09T10:05:00.000Z", planMarkdown: "# Plan", implementedAt: null, implementationThreadId: null, }, ], session: { ...baseThread.session, status: "ready", orchestrationStatus: "ready", }, }, hasPendingApprovals: false, hasPendingUserInput: false, }), ).toMatchObject({ label: "Plan Ready", pulse: false }); }); it("does not show plan ready after the proposed plan was implemented elsewhere", () => { expect( resolveThreadStatusPill({ thread: { ...baseThread, latestTurn: makeLatestTurn(), proposedPlans: [ { id: "plan-1" as never, turnId: "turn-1" as never, createdAt: "2026-03-09T10:00:00.000Z", updatedAt: "2026-03-09T10:05:00.000Z", planMarkdown: "# Plan", implementedAt: "2026-03-09T10:06:00.000Z", implementationThreadId: "thread-implement" as never, }, ], session: { ...baseThread.session, status: "ready", orchestrationStatus: "ready", }, }, hasPendingApprovals: false, hasPendingUserInput: false, }), ).toMatchObject({ label: "Completed", pulse: false }); }); it("shows completed when there is an unseen completion and no active blocker", () => { expect( resolveThreadStatusPill({ thread: { ...baseThread, interactionMode: "default", latestTurn: makeLatestTurn(), lastVisitedAt: "2026-03-09T10:04:00.000Z", session: { ...baseThread.session, status: "ready", orchestrationStatus: "ready", }, }, hasPendingApprovals: false, hasPendingUserInput: false, }), ).toMatchObject({ label: "Completed", pulse: false }); }); }); describe("resolveThreadRowClassName", () => { it("uses the darker selected palette when a thread is both selected and active", () => { const className = resolveThreadRowClassName({ isActive: true, isSelected: true }); expect(className).toContain("bg-primary/22"); expect(className).toContain("hover:bg-primary/26"); expect(className).toContain("dark:bg-primary/30"); expect(className).not.toContain("bg-accent/85"); }); it("uses selected hover colors for selected threads", () => { const className = resolveThreadRowClassName({ isActive: false, isSelected: true }); expect(className).toContain("bg-primary/15"); expect(className).toContain("hover:bg-primary/19"); expect(className).toContain("dark:bg-primary/22"); expect(className).not.toContain("hover:bg-accent"); }); it("keeps the accent palette for active-only threads", () => { const className = resolveThreadRowClassName({ isActive: true, isSelected: false }); expect(className).toContain("bg-accent/85"); expect(className).toContain("hover:bg-accent"); }); }); describe("resolveProjectStatusIndicator", () => { it("returns null when no threads have a notable status", () => { expect(resolveProjectStatusIndicator([null, null])).toBeNull(); }); it("surfaces the highest-priority actionable state across project threads", () => { expect( resolveProjectStatusIndicator([ { label: "Completed", colorClass: "text-emerald-600", dotClass: "bg-emerald-500", pulse: false, }, { label: "Pending Approval", colorClass: "text-amber-600", dotClass: "bg-amber-500", pulse: false, }, { label: "Working", colorClass: "text-sky-600", dotClass: "bg-sky-500", pulse: true, }, ]), ).toMatchObject({ label: "Pending Approval", dotClass: "bg-amber-500" }); }); it("prefers plan-ready over completed when no stronger action is needed", () => { expect( resolveProjectStatusIndicator([ { label: "Completed", colorClass: "text-emerald-600", dotClass: "bg-emerald-500", pulse: false, }, { label: "Plan Ready", colorClass: "text-violet-600", dotClass: "bg-violet-500", pulse: false, }, ]), ).toMatchObject({ label: "Plan Ready", dotClass: "bg-violet-500" }); }); }); describe("getVisibleThreadsForProject", () => { it("includes the active thread even when it falls below the folded preview", () => { const threads = Array.from({ length: 8 }, (_, index) => makeThread({ id: ThreadId.makeUnsafe(`thread-${index + 1}`), title: `Thread ${index + 1}`, }), ); const result = getVisibleThreadsForProject({ threads, activeThreadId: ThreadId.makeUnsafe("thread-8"), isThreadListExpanded: false, previewLimit: 6, }); expect(result.hasHiddenThreads).toBe(true); expect(result.visibleThreads.map((thread) => thread.id)).toEqual([ ThreadId.makeUnsafe("thread-1"), ThreadId.makeUnsafe("thread-2"), ThreadId.makeUnsafe("thread-3"), ThreadId.makeUnsafe("thread-4"), ThreadId.makeUnsafe("thread-5"), ThreadId.makeUnsafe("thread-6"), ThreadId.makeUnsafe("thread-8"), ]); expect(result.hiddenThreads.map((thread) => thread.id)).toEqual([ ThreadId.makeUnsafe("thread-7"), ]); }); it("returns all threads when the list is expanded", () => { const threads = Array.from({ length: 8 }, (_, index) => makeThread({ id: ThreadId.makeUnsafe(`thread-${index + 1}`), }), ); const result = getVisibleThreadsForProject({ threads, activeThreadId: ThreadId.makeUnsafe("thread-8"), isThreadListExpanded: true, previewLimit: 6, }); expect(result.hasHiddenThreads).toBe(true); expect(result.visibleThreads.map((thread) => thread.id)).toEqual( threads.map((thread) => thread.id), ); expect(result.hiddenThreads).toEqual([]); }); }); function makeProject(overrides: Partial = {}): Project { const { defaultModelSelection, ...rest } = overrides; return { id: ProjectId.makeUnsafe("project-1"), name: "Project", cwd: "/tmp/project", defaultModelSelection: { provider: "codex", model: "gpt-5.4", ...defaultModelSelection, }, createdAt: "2026-03-09T10:00:00.000Z", updatedAt: "2026-03-09T10:00:00.000Z", scripts: [], ...rest, }; } function makeThread(overrides: Partial = {}): Thread { return { id: ThreadId.makeUnsafe("thread-1"), codexThreadId: null, projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", modelSelection: { provider: "codex", model: "gpt-5.4", ...overrides?.modelSelection, }, runtimeMode: DEFAULT_RUNTIME_MODE, interactionMode: DEFAULT_INTERACTION_MODE, session: null, messages: [], proposedPlans: [], error: null, createdAt: "2026-03-09T10:00:00.000Z", archivedAt: null, updatedAt: "2026-03-09T10:00:00.000Z", latestTurn: null, branch: null, worktreePath: null, turnDiffSummaries: [], activities: [], ...overrides, }; } describe("sortThreadsForSidebar", () => { it("sorts threads by the latest user message in recency mode", () => { const sorted = sortThreadsForSidebar( [ makeThread({ id: ThreadId.makeUnsafe("thread-1"), createdAt: "2026-03-09T10:00:00.000Z", updatedAt: "2026-03-09T10:10:00.000Z", messages: [ { id: "message-1" as never, role: "user", text: "older", createdAt: "2026-03-09T10:01:00.000Z", streaming: false, completedAt: "2026-03-09T10:01:00.000Z", }, ], }), makeThread({ id: ThreadId.makeUnsafe("thread-2"), createdAt: "2026-03-09T10:05:00.000Z", updatedAt: "2026-03-09T10:05:00.000Z", messages: [ { id: "message-2" as never, role: "user", text: "newer", createdAt: "2026-03-09T10:06:00.000Z", streaming: false, completedAt: "2026-03-09T10:06:00.000Z", }, ], }), ], "updated_at", ); expect(sorted.map((thread) => thread.id)).toEqual([ ThreadId.makeUnsafe("thread-2"), ThreadId.makeUnsafe("thread-1"), ]); }); it("falls back to thread timestamps when there is no user message", () => { const sorted = sortThreadsForSidebar( [ makeThread({ id: ThreadId.makeUnsafe("thread-1"), createdAt: "2026-03-09T10:00:00.000Z", updatedAt: "2026-03-09T10:01:00.000Z", messages: [ { id: "message-1" as never, role: "assistant", text: "assistant only", createdAt: "2026-03-09T10:02:00.000Z", streaming: false, completedAt: "2026-03-09T10:02:00.000Z", }, ], }), makeThread({ id: ThreadId.makeUnsafe("thread-2"), createdAt: "2026-03-09T10:05:00.000Z", updatedAt: "2026-03-09T10:05:00.000Z", messages: [], }), ], "updated_at", ); expect(sorted.map((thread) => thread.id)).toEqual([ ThreadId.makeUnsafe("thread-2"), ThreadId.makeUnsafe("thread-1"), ]); }); it("falls back to id ordering when threads have no sortable timestamps", () => { const sorted = sortThreadsForSidebar( [ makeThread({ id: ThreadId.makeUnsafe("thread-1"), createdAt: "" as never, updatedAt: undefined, messages: [], }), makeThread({ id: ThreadId.makeUnsafe("thread-2"), createdAt: "" as never, updatedAt: undefined, messages: [], }), ], "updated_at", ); expect(sorted.map((thread) => thread.id)).toEqual([ ThreadId.makeUnsafe("thread-2"), ThreadId.makeUnsafe("thread-1"), ]); }); it("can sort threads by createdAt when configured", () => { const sorted = sortThreadsForSidebar( [ makeThread({ id: ThreadId.makeUnsafe("thread-1"), createdAt: "2026-03-09T10:05:00.000Z", updatedAt: "2026-03-09T10:05:00.000Z", }), makeThread({ id: ThreadId.makeUnsafe("thread-2"), createdAt: "2026-03-09T10:00:00.000Z", updatedAt: "2026-03-09T10:10:00.000Z", }), ], "created_at", ); expect(sorted.map((thread) => thread.id)).toEqual([ ThreadId.makeUnsafe("thread-1"), ThreadId.makeUnsafe("thread-2"), ]); }); }); describe("getFallbackThreadIdAfterDelete", () => { it("returns the top remaining thread in the deleted thread's project sidebar order", () => { const fallbackThreadId = getFallbackThreadIdAfterDelete({ threads: [ makeThread({ id: ThreadId.makeUnsafe("thread-oldest"), projectId: ProjectId.makeUnsafe("project-1"), createdAt: "2026-03-09T10:00:00.000Z", messages: [], }), makeThread({ id: ThreadId.makeUnsafe("thread-active"), projectId: ProjectId.makeUnsafe("project-1"), createdAt: "2026-03-09T10:05:00.000Z", messages: [], }), makeThread({ id: ThreadId.makeUnsafe("thread-newest"), projectId: ProjectId.makeUnsafe("project-1"), createdAt: "2026-03-09T10:10:00.000Z", messages: [], }), makeThread({ id: ThreadId.makeUnsafe("thread-other-project"), projectId: ProjectId.makeUnsafe("project-2"), createdAt: "2026-03-09T10:20:00.000Z", messages: [], }), ], deletedThreadId: ThreadId.makeUnsafe("thread-active"), sortOrder: "created_at", }); expect(fallbackThreadId).toBe(ThreadId.makeUnsafe("thread-newest")); }); it("skips other threads being deleted in the same action", () => { const fallbackThreadId = getFallbackThreadIdAfterDelete({ threads: [ makeThread({ id: ThreadId.makeUnsafe("thread-active"), projectId: ProjectId.makeUnsafe("project-1"), createdAt: "2026-03-09T10:05:00.000Z", messages: [], }), makeThread({ id: ThreadId.makeUnsafe("thread-newest"), projectId: ProjectId.makeUnsafe("project-1"), createdAt: "2026-03-09T10:10:00.000Z", messages: [], }), makeThread({ id: ThreadId.makeUnsafe("thread-next"), projectId: ProjectId.makeUnsafe("project-1"), createdAt: "2026-03-09T10:07:00.000Z", messages: [], }), ], deletedThreadId: ThreadId.makeUnsafe("thread-active"), deletedThreadIds: new Set([ ThreadId.makeUnsafe("thread-active"), ThreadId.makeUnsafe("thread-newest"), ]), sortOrder: "created_at", }); expect(fallbackThreadId).toBe(ThreadId.makeUnsafe("thread-next")); }); }); describe("sortProjectsForSidebar", () => { it("sorts projects by the most recent user message across their threads", () => { const projects = [ makeProject({ id: ProjectId.makeUnsafe("project-1"), name: "Older project" }), makeProject({ id: ProjectId.makeUnsafe("project-2"), name: "Newer project" }), ]; const threads = [ makeThread({ projectId: ProjectId.makeUnsafe("project-1"), updatedAt: "2026-03-09T10:20:00.000Z", messages: [ { id: "message-1" as never, role: "user", text: "older project user message", createdAt: "2026-03-09T10:01:00.000Z", streaming: false, completedAt: "2026-03-09T10:01:00.000Z", }, ], }), makeThread({ id: ThreadId.makeUnsafe("thread-2"), projectId: ProjectId.makeUnsafe("project-2"), updatedAt: "2026-03-09T10:05:00.000Z", messages: [ { id: "message-2" as never, role: "user", text: "newer project user message", createdAt: "2026-03-09T10:05:00.000Z", streaming: false, completedAt: "2026-03-09T10:05:00.000Z", }, ], }), ]; const sorted = sortProjectsForSidebar(projects, threads, "updated_at"); expect(sorted.map((project) => project.id)).toEqual([ ProjectId.makeUnsafe("project-2"), ProjectId.makeUnsafe("project-1"), ]); }); it("falls back to project timestamps when a project has no threads", () => { const sorted = sortProjectsForSidebar( [ makeProject({ id: ProjectId.makeUnsafe("project-1"), name: "Older project", updatedAt: "2026-03-09T10:01:00.000Z", }), makeProject({ id: ProjectId.makeUnsafe("project-2"), name: "Newer project", updatedAt: "2026-03-09T10:05:00.000Z", }), ], [], "updated_at", ); expect(sorted.map((project) => project.id)).toEqual([ ProjectId.makeUnsafe("project-2"), ProjectId.makeUnsafe("project-1"), ]); }); it("falls back to name and id ordering when projects have no sortable timestamps", () => { const sorted = sortProjectsForSidebar( [ makeProject({ id: ProjectId.makeUnsafe("project-2"), name: "Beta", createdAt: undefined, updatedAt: undefined, }), makeProject({ id: ProjectId.makeUnsafe("project-1"), name: "Alpha", createdAt: undefined, updatedAt: undefined, }), ], [], "updated_at", ); expect(sorted.map((project) => project.id)).toEqual([ ProjectId.makeUnsafe("project-1"), ProjectId.makeUnsafe("project-2"), ]); }); it("preserves manual project ordering", () => { const projects = [ makeProject({ id: ProjectId.makeUnsafe("project-2"), name: "Second" }), makeProject({ id: ProjectId.makeUnsafe("project-1"), name: "First" }), ]; const sorted = sortProjectsForSidebar(projects, [], "manual"); expect(sorted.map((project) => project.id)).toEqual([ ProjectId.makeUnsafe("project-2"), ProjectId.makeUnsafe("project-1"), ]); }); it("ignores archived threads when sorting projects", () => { const sorted = sortProjectsForSidebar( [ makeProject({ id: ProjectId.makeUnsafe("project-1"), name: "Visible project", updatedAt: "2026-03-09T10:01:00.000Z", }), makeProject({ id: ProjectId.makeUnsafe("project-2"), name: "Archived-only project", updatedAt: "2026-03-09T10:00:00.000Z", }), ], [ makeThread({ id: ThreadId.makeUnsafe("thread-visible"), projectId: ProjectId.makeUnsafe("project-1"), updatedAt: "2026-03-09T10:02:00.000Z", archivedAt: null, }), makeThread({ id: ThreadId.makeUnsafe("thread-archived"), projectId: ProjectId.makeUnsafe("project-2"), updatedAt: "2026-03-09T10:10:00.000Z", archivedAt: "2026-03-09T10:11:00.000Z", }), ].filter((thread) => thread.archivedAt === null), "updated_at", ); expect(sorted.map((project) => project.id)).toEqual([ ProjectId.makeUnsafe("project-1"), ProjectId.makeUnsafe("project-2"), ]); }); it("returns the project timestamp when no threads are present", () => { const timestamp = getProjectSortTimestamp( makeProject({ updatedAt: "2026-03-09T10:10:00.000Z" }), [], "updated_at", ); expect(timestamp).toBe(Date.parse("2026-03-09T10:10:00.000Z")); }); });