// @vitest-environment jsdom import { describe, expect, it, vi } from "vitest"; import type { Thread } from "../../../src/domain/threads/model"; import { renderThreadsViewShell } from "../../../src/features/threads-view/shell.dom"; import { type ThreadsRowModel, type ThreadsViewPanelActivity, threadRows } from "../../../src/features/threads-view/state"; import { changeInputValue, installObsidianDomShims } from "../../support/dom"; installObsidianDomShims(); function expectPresent(value: T | null | undefined): T { if (value === null || value === undefined) throw new Error("Expected value to be present"); return value; } function panelActivity(overrides: Partial = {}): ThreadsViewPanelActivity { return { threadId: "thread", selected: false, pending: false, running: false, ...overrides, }; } function threadFixture(overrides: Partial = {}): Thread { return { id: "thread", preview: "", createdAt: 1, updatedAt: 1, name: null, archived: false, provenance: { kind: "interactive" }, ...overrides, }; } function rowFixture(overrides: Partial = {}): ThreadsRowModel { const threadId = overrides.threadId ?? "thread"; const title = overrides.title ?? "Thread"; return { threadId, title, live: null, selected: false, rename: { active: false, draft: title, generating: false, saving: false, autoNameDisabled: true }, archiveConfirm: { active: false, defaultSaveMarkdown: false }, ...overrides, }; } function threadsViewActions() { return { refresh: vi.fn(), loadMore: vi.fn(), openNewPanel: vi.fn(), openThread: vi.fn(), startRename: vi.fn(), updateRename: vi.fn(), saveRename: vi.fn(), cancelRename: vi.fn(), cancelAutoName: vi.fn(), autoNameThread: vi.fn(), startArchive: vi.fn(), archiveThread: vi.fn(), }; } describe("threads view renderer decisions", () => { it("renders an initial load failure as status instead of a navigation row or empty state", () => { const parent = document.createElement("div"); renderThreadsViewShell( parent, { status: { kind: "error", message: "Could not load threads." }, loading: false, rows: [] }, threadsViewActions(), ); const status = expectPresent(parent.querySelector(".codex-panel-threads__status")); expect(status.textContent).toBe("Could not load threads."); expect(status.getAttribute("role")).toBe("status"); expect(status.classList.contains("codex-panel-ui__nav-item")).toBe(false); expect(parent.querySelector(".codex-panel-threads__empty")).toBeNull(); }); it("reserves the empty state for a successfully loaded empty list", () => { const parent = document.createElement("div"); renderThreadsViewShell(parent, { status: null, loading: false, rows: [] }, threadsViewActions()); expect(parent.querySelector(".codex-panel-threads__empty")?.textContent).toBe("No threads"); expect(parent.querySelector(".codex-panel-threads__status")).toBeNull(); }); it("prioritizes open panel live state per thread", () => { expect( threadRows( [threadFixture({ id: "thread" })], [ panelActivity({ threadId: "thread" }), panelActivity({ threadId: "thread", running: true }), panelActivity({ threadId: "thread", pending: true }), ], new Map(), )[0]?.live, ).toMatchObject({ status: "pending" }); expect(threadRows([threadFixture({ id: "thread" })], [panelActivity({ threadId: "thread" })], new Map())[0]?.live).toMatchObject({ status: "open", }); expect( threadRows([threadFixture({ id: "thread" })], [panelActivity({ threadId: null, running: true })], new Map())[0]?.live, ).toBeNull(); }); it("marks a row selected when one open panel for the thread was last focused", () => { const rows = threadRows( [ threadFixture({ id: "closed", preview: "Closed thread" }), threadFixture({ id: "focused", preview: "Focused thread", updatedAt: 2 }), ], [panelActivity({ threadId: "focused", selected: true })], new Map(), ); expect(rows.find((row) => row.threadId === "focused")?.selected).toBe(true); expect(rows.find((row) => row.threadId === "closed")?.selected).toBe(false); }); it("renders thread rows with live state and routes open actions", () => { const parent = document.createElement("div"); const actions = threadsViewActions(); const rows = threadRows( [threadFixture({ id: "closed", preview: "Closed thread" }), threadFixture({ id: "open", preview: "Open thread", updatedAt: 2 })], [panelActivity({ threadId: "open", pending: true, selected: true })], new Map(), ); renderThreadsViewShell(parent, { status: null, loading: false, rows }, actions); const main = expectPresent(parent.querySelector(".codex-panel-threads__row--pending")); expect(main.textContent).toContain("Open thread"); const toolbarButtons = [...parent.querySelectorAll(".codex-panel-threads__toolbar-button")]; expect(toolbarButtons.map((button) => button.getAttribute("aria-label"))).toEqual(["Open new panel", "Refresh threads"]); const refresh = expectPresent(parent.querySelector('[aria-label="Refresh threads"]')); refresh.click(); expect(actions.refresh).toHaveBeenCalledOnce(); const openNewPanel = expectPresent(parent.querySelector('[aria-label="Open new panel"]')); openNewPanel.click(); expect(actions.openNewPanel).toHaveBeenCalledOnce(); expect(parent.querySelector('[aria-label="Rename thread"]')).not.toBeNull(); main.click(); expect(actions.openThread).toHaveBeenCalledWith("open"); }); it("renders threads view archive confirmation with the default action on the right", () => { const parent = document.createElement("div"); const actions = threadsViewActions(); const row = rowFixture({ archiveConfirm: { active: true, defaultSaveMarkdown: false }, }); renderThreadsViewShell(parent, { status: null, loading: false, rows: [row] }, actions); const confirm = expectPresent(parent.querySelector(".codex-panel-threads__archive-confirm")); const archiveButtons = [ ...confirm.querySelectorAll(".codex-panel-threads__archive-alternate, .codex-panel-threads__archive-default"), ]; expect(archiveButtons.map((button) => button.getAttribute("aria-label"))).toEqual([ "Save and archive thread", "Archive thread without saving", ]); expect(parent.querySelector('[aria-label="Rename thread"]')).toBeNull(); archiveButtons[0]?.click(); expect(actions.archiveThread).toHaveBeenCalledWith("thread", true); archiveButtons[1]?.click(); expect(actions.archiveThread).toHaveBeenCalledWith("thread", false); }); it("starts threads view archive confirmation before archiving", () => { const parent = document.createElement("div"); const actions = threadsViewActions(); const row = rowFixture(); renderThreadsViewShell(parent, { status: null, loading: false, rows: [row] }, actions); parent.querySelector('[aria-label="Archive thread"]')?.click(); expect(actions.startArchive).toHaveBeenCalledWith("thread"); expect(actions.archiveThread).not.toHaveBeenCalled(); }); it("renders rename rows and saves entered values", () => { const parent = document.createElement("div"); document.body.append(parent); const actions = threadsViewActions(); const row = rowFixture({ title: "Old name", rename: { active: true, draft: "Old name", generating: false, saving: false, autoNameDisabled: false }, }); renderThreadsViewShell(parent, { status: null, loading: false, rows: [row] }, actions); const input = expectPresent(parent.querySelector(".codex-panel-threads__rename-input")); expect(document.activeElement).toBe(input); expect([input.selectionStart, input.selectionEnd]).toEqual([0, input.value.length]); changeInputValue(input, "New name"); expect(actions.updateRename).toHaveBeenCalledWith("thread", "New name"); renderThreadsViewShell( parent, { status: null, loading: false, rows: [{ ...row, rename: { active: true, draft: "New name", generating: false, saving: false, autoNameDisabled: false } }], }, actions, ); const renamedInput = expectPresent(parent.querySelector(".codex-panel-threads__rename-input")); renamedInput.dispatchEvent(new FocusEvent("blur")); expect(actions.saveRename).toHaveBeenCalledWith("thread", "New name"); actions.saveRename.mockClear(); renamedInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, isComposing: true })); expect(actions.saveRename).not.toHaveBeenCalled(); renamedInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); expect(actions.saveRename).toHaveBeenCalledWith("thread", "New name"); renamedInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); expect(actions.cancelRename).toHaveBeenCalledWith("thread"); expectPresent(parent.querySelector(".codex-panel-threads__row-main")).click(); expect(actions.openThread).not.toHaveBeenCalled(); parent.remove(); }); it("renders threads view rename actions inline with auto-name", () => { const parent = document.createElement("div"); const actions = threadsViewActions(); const row = rowFixture({ title: "Old name", rename: { active: true, draft: "Old name", generating: false, saving: false, autoNameDisabled: false }, }); renderThreadsViewShell(parent, { status: null, loading: false, rows: [row] }, actions); expect(parent.querySelector(".codex-panel-threads__rename-form")).toBeTruthy(); parent.querySelector('[aria-label="Auto-name thread"]')?.click(); expect(actions.autoNameThread).toHaveBeenCalledWith("thread"); }); it("renders threads view rename auto-name loading state", () => { const parent = document.createElement("div"); const actions = threadsViewActions(); const row = rowFixture({ title: "Old name", rename: { active: true, draft: "Old name", generating: true, saving: false, autoNameDisabled: false }, }); renderThreadsViewShell(parent, { status: null, loading: false, rows: [row] }, actions); expect(parent.querySelector(".codex-panel-threads__rename-input")?.disabled).toBe(true); const cancelAutoName = expectPresent(parent.querySelector('[aria-label="Cancel auto-name"]')); expect(cancelAutoName.disabled).toBe(false); cancelAutoName.click(); expect(actions.cancelAutoName).toHaveBeenCalledWith("thread"); }); it("locks threads view rename controls while saving", () => { const parent = document.createElement("div"); const actions = threadsViewActions(); const row = rowFixture({ title: "Old name", rename: { active: true, draft: "Old name", generating: false, saving: true, autoNameDisabled: false }, }); renderThreadsViewShell(parent, { status: null, loading: false, rows: [row] }, actions); const input = expectPresent(parent.querySelector(".codex-panel-threads__rename-input")); expect(input.disabled).toBe(true); input.dispatchEvent(new FocusEvent("blur")); input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); const autoName = expectPresent(parent.querySelector('[aria-label="Auto-name thread"]')); expect(autoName.disabled).toBe(true); autoName.click(); expect(actions.saveRename).not.toHaveBeenCalled(); expect(actions.cancelRename).not.toHaveBeenCalled(); expect(actions.autoNameThread).not.toHaveBeenCalled(); }); it("disables history expansion during any shared thread fetch", () => { const parent = document.createElement("div"); const actions = threadsViewActions(); renderThreadsViewShell(parent, { status: null, loading: false, fetching: true, hasMore: true, rows: [rowFixture()] }, actions); const loadMore = expectPresent(parent.querySelector(".codex-panel-threads__load-more")); expect(loadMore.textContent).toBe("Load more threads"); expect(loadMore.disabled).toBe(true); loadMore.click(); expect(actions.loadMore).not.toHaveBeenCalled(); }); });