diff --git a/tests/features/chat/panel/chat-state-selector.test.tsx b/tests/features/chat/panel/chat-state-selector.test.tsx index 8b77eda8..e3e3f3a0 100644 --- a/tests/features/chat/panel/chat-state-selector.test.tsx +++ b/tests/features/chat/panel/chat-state-selector.test.tsx @@ -70,7 +70,7 @@ describe("useChatSelector", () => { unmountUiRoot(parent); }); - it("rerenders only the region whose selected state changed", async () => { + it("isolates stream and composer updates to their subscribed regions", async () => { const store = createChatStateStore(); const renders = { toolbar: vi.fn(), goal: vi.fn(), threadStream: vi.fn(), composer: vi.fn() }; const parent = document.createElement("div"); @@ -78,27 +78,7 @@ describe("useChatSelector", () => { await act(async () => { renderUiRoot(parent, ); }); - expect(regionRenderCounts(renders)).toEqual([1, 1, 1, 1]); - - await act(async () => { - store.dispatch({ type: "composer/draft-set", draft: "draft" }); - }); - expect(regionRenderCounts(renders)).toEqual([1, 1, 1, 2]); - - await act(async () => { - store.dispatch({ type: "ui/goal-editor-started", threadId: null, objective: "Goal", tokenBudget: null }); - }); - expect(regionRenderCounts(renders)).toEqual([1, 2, 1, 2]); - - await act(async () => { - store.dispatch({ type: "ui/panel-set", panel: "history" }); - }); - expect(regionRenderCounts(renders)).toEqual([2, 2, 1, 2]); - - await act(async () => { - store.dispatch({ type: "ui/disclosure-set", bucket: "details", id: "item", open: true }); - }); - expect(regionRenderCounts(renders)).toEqual([2, 2, 2, 2]); + clearRegionRenders(renders); await act(async () => { store.dispatch({ @@ -106,16 +86,13 @@ describe("useChatSelector", () => { item: { id: "status", kind: "system", role: "system", text: "Status" }, }); }); - expect(regionRenderCounts(renders)).toEqual([2, 2, 3, 2]); + expect(renderedRegions(renders)).toEqual(["threadStream"]); + clearRegionRenders(renders); await act(async () => { - store.dispatch({ - type: "thread-stream/items-replaced", - historyCursor: null, - items: [{ id: "user", kind: "dialogue", dialogueKind: "user", role: "user", text: "Hello", turnId: "turn" }], - }); + store.dispatch({ type: "composer/draft-set", draft: "draft" }); }); - expect(regionRenderCounts(renders)).toEqual([2, 2, 4, 3]); + expect(renderedRegions(renders)).toEqual(["composer"]); unmountUiRoot(parent); }); }); @@ -155,13 +132,19 @@ function Region({ return null; } -function regionRenderCounts(renders: { +type RegionRenders = { toolbar: Mock<() => void>; goal: Mock<() => void>; threadStream: Mock<() => void>; composer: Mock<() => void>; -}): number[] { - return [renders.toolbar, renders.goal, renders.threadStream, renders.composer].map((rendered) => rendered.mock.calls.length); +}; + +function clearRegionRenders(renders: RegionRenders): void { + for (const rendered of Object.values(renders)) rendered.mockClear(); +} + +function renderedRegions(renders: RegionRenders): string[] { + return Object.entries(renders).flatMap(([region, rendered]) => (rendered.mock.calls.length > 0 ? [region] : [])); } interface ControllableStore extends ChatStateStore { diff --git a/tests/features/chat/panel/shell.test.tsx b/tests/features/chat/panel/shell.test.tsx index 31c88f54..541f8df1 100644 --- a/tests/features/chat/panel/shell.test.tsx +++ b/tests/features/chat/panel/shell.test.tsx @@ -31,9 +31,7 @@ describe("ChatPanelShell", () => { expect(container.classList.contains("codex-panel")).toBe(true); expect(container.querySelector(".codex-panel__toolbar .codex-panel__toolbar-primary")).not.toBeNull(); - expect(container.querySelector(".codex-panel__body > .codex-panel__region--thread-stream")).toBe( - container.querySelector(".codex-panel__body > .codex-panel__thread-stream"), - ); + expect(container.querySelector(".codex-panel__region--thread-stream")).not.toBeNull(); expect(container.querySelector(".codex-panel__region--composer textarea")?.value).toBe(""); expect(container.querySelector(".codex-panel__thread-stream-block")?.textContent).toContain("Send a message"); @@ -183,7 +181,10 @@ describe("ChatPanelShell", () => { }); }); - it("repairs a missing toolbar through an explicit shell render", async () => { + it.each([ + ["a missing toolbar", (container: HTMLElement) => container.querySelector(":scope > .codex-panel__toolbar")?.remove()], + ["an unexpected root child", (container: HTMLElement) => container.appendChild(document.createElement("section"))], + ])("repairs %s through an explicit shell render", async (_description, damageRoot) => { const store = createChatStateStore(); const container = document.createElement("div"); document.body.appendChild(container); @@ -194,34 +195,7 @@ describe("ChatPanelShell", () => { await settleShellEffects(); }); - container.querySelector(":scope > .codex-panel__toolbar")?.remove(); - - await act(async () => { - store.dispatch({ type: "composer/draft-set", draft: "repair toolbar" }); - renderChatPanelShell(container, { stateStore: store, showToolbar: true, parts }); - await settleShellEffects(); - }); - - expect(container.querySelector(".codex-panel__toolbar .codex-panel__toolbar-primary")).not.toBeNull(); - expect(container.querySelector(".codex-panel__region--composer textarea")?.value).toBe("repair toolbar"); - - await act(async () => { - unmountChatPanelShell(container); - }); - }); - - it("repairs unexpected root-level DOM through an explicit shell render", async () => { - const store = createChatStateStore(); - const container = document.createElement("div"); - document.body.appendChild(container); - const parts = shellParts(); - - await act(async () => { - renderChatPanelShell(container, { stateStore: store, showToolbar: true, parts }); - await settleShellEffects(); - }); - - container.appendChild(document.createElement("section")); + damageRoot(container); await act(async () => { store.dispatch({ type: "composer/draft-set", draft: "repair root" }); diff --git a/tests/features/chat/panel/surface/thread-stream-presenter.test.ts b/tests/features/chat/panel/surface/thread-stream-presenter.test.ts index 6ac9305a..62db71bc 100644 --- a/tests/features/chat/panel/surface/thread-stream-presenter.test.ts +++ b/tests/features/chat/panel/surface/thread-stream-presenter.test.ts @@ -18,7 +18,7 @@ import { } from "../../../../../src/features/chat/panel/thread-stream-scroll-binding"; import { ThreadStreamMarkdownRenderer } from "../../../../../src/features/chat/ui/thread-stream/markdown-renderer.obsidian"; import { ThreadStreamViewport } from "../../../../../src/features/chat/ui/thread-stream/stream-blocks"; -import { renderUiRoot, unmountUiRoot } from "../../../../../src/shared/dom/preact-root.dom"; +import { renderUiRoot } from "../../../../../src/shared/dom/preact-root.dom"; import { notices } from "../../../../mocks/obsidian"; import { installObsidianDomShims } from "../../../../support/dom"; import { threadStreamModelFromChatState } from "../../support/shell-selectors"; @@ -26,8 +26,6 @@ import { chatStateFixture, chatStateWith } from "../../support/state"; import { withChatStateThreadStreamItems } from "../../support/thread-stream"; import { installThreadStreamViewportMetrics, pendingApproval } from "../../ui/thread-stream/test-helpers"; -const ESTIMATED_THREAD_STREAM_BLOCK_HEIGHT = 96; - installObsidianDomShims(); function renderThreadStreamPresenter(parent: HTMLElement, presenter: ThreadStreamPresenter, state: ChatState): void { @@ -268,7 +266,7 @@ describe("ThreadStreamPresenter scroll pinning", () => { cleanup(); }); - it("pins to the scroll container bottom without aligning the last stream item element", async () => { + it("pins to the scroll container bottom", async () => { let state = chatStateFixture(); state = chatStateWith(state, { activeThread: { id: "thread" } }); state = withChatStateThreadStreamItems(state, [ @@ -287,17 +285,13 @@ describe("ThreadStreamPresenter scroll pinning", () => { renderThreadStreamPresenter(parent, presenter, state); const viewport = threadStreamViewport(parent); - Object.defineProperty(viewport, "scrollHeight", { value: ESTIMATED_THREAD_STREAM_BLOCK_HEIGHT, configurable: true }); - installThreadStreamViewportMetrics(viewport, { clientHeight: 100 }); - viewport.scrollTop = 920; + installThreadStreamViewportMetrics(viewport, { clientHeight: 100, scrollHeight: 1000 }); + viewport.scrollTop = 100; - const scrollIntoView = vi.spyOn(HTMLElement.prototype, "scrollIntoView"); - scrollIntoView.mockClear(); scrollPortBinding.showLatest(); await settleThreadStreamRender(viewport); - expect(viewport.scrollTop).toBe(0); - expect(scrollIntoView).not.toHaveBeenCalled(); + expect(viewport.scrollTop).toBe(900); }); it("repins after composer growth has changed the scroll viewport height", async () => { @@ -316,12 +310,12 @@ describe("ThreadStreamPresenter scroll pinning", () => { ]); const parent = document.createElement("div"); const { presenter, scrollPortBinding } = threadStreamPresenter(state); - - const viewport = parent.createDiv({ cls: "codex-panel__thread-stream" }); + renderThreadStreamPresenter(parent, presenter, state); + const viewport = threadStreamViewport(parent); let scrollTop = 0; let layoutSettled = false; Object.defineProperties(viewport, { - scrollHeight: { value: ESTIMATED_THREAD_STREAM_BLOCK_HEIGHT, configurable: true }, + scrollHeight: { value: 240, configurable: true }, clientHeight: { get: () => (layoutSettled ? 100 : 160), configurable: true, @@ -335,99 +329,22 @@ describe("ThreadStreamPresenter scroll pinning", () => { scrollTop: { get: () => scrollTop, set: (value: number) => { - scrollTop = Math.min(value, Math.max(0, ESTIMATED_THREAD_STREAM_BLOCK_HEIGHT - viewport.clientHeight)); + scrollTop = Math.min(value, Math.max(0, viewport.scrollHeight - viewport.clientHeight)); }, configurable: true, }, }); viewport.scrollTop = 1000; - renderThreadStreamPresenter(viewport, presenter, state); - await settleThreadStreamRender(viewport); - expect(viewport.scrollTop).toBe(0); + viewport.dispatchEvent(new Event("scroll")); + expect(viewport.scrollTop).toBe(80); scrollPortBinding.showLatest(); - expect(viewport.scrollTop).toBe(0); + expect(viewport.scrollTop).toBe(80); layoutSettled = true; await settleThreadStreamRender(viewport); - expect(viewport.scrollTop).toBe(0); - }); - - it("accepts scroll commands when no thread stream viewport is mounted", () => { - const { presenter, scrollPortBinding } = threadStreamPresenter(); - - expect(() => { - scrollPortBinding.showLatest(); - scrollPortBinding.scrollFromComposer({ kind: "scroll-by", direction: 1, amount: "text-lines" }); - scrollPortBinding.scrollFromComposer({ kind: "scroll-by", direction: -1, amount: "page" }); - scrollPortBinding.scrollFromComposer({ kind: "scroll-to", edge: "start" }); - presenter.dispose(); - scrollPortBinding.showLatest(); - }).not.toThrow(); - }); - - it("detaches the active scroll port when the thread stream unmounts", async () => { - let state = chatStateFixture(); - state = chatStateWith(state, { activeThread: { id: "thread" } }); - state = withChatStateThreadStreamItems(state, [ - { - id: "message", - kind: "dialogue", - role: "assistant", - text: "Rendered message", - turnId: "turn", - dialogueKind: "assistantResponse", - dialogueState: "completed", - }, - ]); - const parent = document.createElement("div"); - const { presenter, scrollPortBinding } = threadStreamPresenter(state); - renderThreadStreamPresenter(parent, presenter, state); - const viewport = threadStreamViewport(parent); - installThreadStreamViewportMetrics(viewport); - await settleThreadStreamRender(viewport); - - unmountUiRoot(parent); - - expect(() => { - scrollPortBinding.showLatest(); - scrollPortBinding.scrollFromComposer({ kind: "scroll-by", direction: 1, amount: "page" }); - }).not.toThrow(); - }); - - it("binds scroll commands to the currently mounted thread stream viewport", async () => { - let state = chatStateFixture(); - state = chatStateWith(state, { activeThread: { id: "thread" } }); - state = withChatStateThreadStreamItems(state, [ - { - id: "message", - kind: "dialogue", - role: "assistant", - text: "Rendered message", - turnId: "turn", - dialogueKind: "assistantResponse", - dialogueState: "completed", - }, - ]); - const parent = document.createElement("div"); - const { presenter, scrollPortBinding } = threadStreamPresenter(state); - renderThreadStreamPresenter(parent, presenter, state); - const oldMessages = threadStreamViewport(parent); - installThreadStreamViewportMetrics(oldMessages, { clientHeight: 100, scrollHeight: 1000 }); - await settleThreadStreamRender(oldMessages); - oldMessages.scrollTop = 125; - - unmountUiRoot(parent); - - renderThreadStreamPresenter(parent, presenter, state); - const newMessages = threadStreamViewport(parent); - installThreadStreamViewportMetrics(newMessages, { clientHeight: 100, scrollHeight: 1000 }); - await settleThreadStreamRender(newMessages); - scrollPortBinding.showLatest(); - - expect(newMessages.scrollTop).toBe(900); - expect(oldMessages.scrollTop).toBe(125); + expect(viewport.scrollTop).toBe(140); }); it("completes bottom pinning after the thread stream viewport commits", async () => { @@ -483,8 +400,6 @@ describe("ThreadStreamPresenter scroll pinning", () => { viewport.scrollTop = 100; viewport.dispatchEvent(new Event("scroll")); - const scrollIntoView = vi.spyOn(HTMLElement.prototype, "scrollIntoView"); - scrollIntoView.mockClear(); state = withChatStateThreadStreamItems(state, [ { id: "message", @@ -499,7 +414,7 @@ describe("ThreadStreamPresenter scroll pinning", () => { renderThreadStreamPresenter(viewport, presenter, state); await settleThreadStreamRender(viewport); - expect(scrollIntoView).not.toHaveBeenCalled(); + expect(viewport.scrollTop).toBe(100); }); it("does not run a pending bottom pin after the user scrolls away", async () => { @@ -525,70 +440,11 @@ describe("ThreadStreamPresenter scroll pinning", () => { viewport.scrollTop = 920; renderThreadStreamPresenter(viewport, presenter, state); - const scrollIntoView = vi.spyOn(HTMLElement.prototype, "scrollIntoView"); - scrollIntoView.mockClear(); viewport.scrollTop = 100; viewport.dispatchEvent(new Event("scroll")); await settleThreadStreamRender(viewport); - expect(scrollIntoView).not.toHaveBeenCalled(); - }); - - it("leaves the mounted thread stream content in place on dispose", async () => { - let state = chatStateFixture(); - state = chatStateWith(state, { activeThread: { id: "thread" } }); - state = withChatStateThreadStreamItems(state, [ - { - id: "message", - kind: "dialogue", - role: "assistant", - text: "Rendered message", - turnId: "turn", - dialogueKind: "assistantResponse", - dialogueState: "completed", - }, - ]); - const parent = document.createElement("div"); - const { presenter } = threadStreamPresenter(state); - - renderThreadStreamPresenter(parent, presenter, state); - let viewport = threadStreamViewport(parent); - installThreadStreamViewportMetrics(viewport); - renderThreadStreamPresenter(parent, presenter, state); - viewport = threadStreamViewport(parent); - await settleThreadStreamRender(viewport); - expect(parent.querySelector(".codex-panel__thread-stream")).not.toBeNull(); - - presenter.dispose(); - - expect(parent.querySelector(".codex-panel__thread-stream")).not.toBeNull(); - }); - - it("renders large thread streams through the flow viewport", async () => { - let state = chatStateFixture(); - state = chatStateWith(state, { activeThread: { id: "thread" } }); - state = withChatStateThreadStreamItems( - state, - Array.from({ length: 200 }, (_value, index) => ({ - id: `message-${String(index)}`, - kind: "dialogue", - role: "assistant", - text: `Message ${String(index)}`, - turnId: "turn", - dialogueKind: "assistantResponse", - dialogueState: "completed", - })), - ); - const parent = document.createElement("div"); - const { presenter } = threadStreamPresenter(state); - - renderThreadStreamPresenter(parent, presenter, state); - const viewport = threadStreamViewport(parent); - installThreadStreamViewportMetrics(viewport, { clientHeight: 320, scrollHeight: 19_200 }); - await settleThreadStreamRender(viewport); - - expect(parent.querySelector(".codex-panel__thread-stream-flow")).not.toBeNull(); - expect(parent.querySelectorAll("[data-codex-panel-block-key]").length).toBeGreaterThan(0); + expect(viewport.scrollTop).toBe(100); }); }); diff --git a/tests/features/chat/panel/thread-stream-scroll-binding.test.ts b/tests/features/chat/panel/thread-stream-scroll-binding.test.ts new file mode 100644 index 00000000..0efd84bb --- /dev/null +++ b/tests/features/chat/panel/thread-stream-scroll-binding.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createChatThreadStreamScrollBinding } from "../../../../src/features/chat/panel/thread-stream-scroll-binding"; +import type { ThreadStreamScrollPort } from "../../../../src/features/chat/ui/thread-stream/flow-scroll.measure"; + +describe("chat thread stream scroll binding", () => { + it("forwards panel and composer commands to the mounted scroll port", () => { + const binding = createChatThreadStreamScrollBinding(); + const dispatchScrollCommand = vi.fn(); + + binding.showLatest(); + binding.scrollFromComposer({ kind: "scroll-to", edge: "start" }); + expect(dispatchScrollCommand).not.toHaveBeenCalled(); + + binding.mountScrollPort({ dispatchScrollCommand }); + binding.showLatest(); + binding.scrollFromComposer({ kind: "scroll-by", direction: -1, amount: "page" }); + + expect(dispatchScrollCommand.mock.calls).toEqual([[{ kind: "show-latest" }], [{ kind: "scroll-by", direction: -1, amount: "page" }]]); + }); + + it("keeps the newest port mounted and stops forwarding after unmount or disposal", () => { + const binding = createChatThreadStreamScrollBinding(); + const first = vi.fn(); + const second = vi.fn(); + const unmountFirst = binding.mountScrollPort({ dispatchScrollCommand: first }); + const unmountSecond = binding.mountScrollPort({ dispatchScrollCommand: second }); + + unmountFirst(); + binding.showLatest(); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledWith({ kind: "show-latest" }); + + unmountSecond(); + binding.showLatest(); + expect(second).toHaveBeenCalledOnce(); + + binding.mountScrollPort({ dispatchScrollCommand: second }); + binding.dispose(); + binding.showLatest(); + expect(second).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/features/chat/presentation/thread-stream/view-model.test.ts b/tests/features/chat/presentation/thread-stream/view-model.test.ts index 376e94fb..630daacf 100644 --- a/tests/features/chat/presentation/thread-stream/view-model.test.ts +++ b/tests/features/chat/presentation/thread-stream/view-model.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { ThreadStreamItem } from "../../../../../src/features/chat/domain/thread-stream/items"; +import type { PendingRequestBlockSnapshot } from "../../../../../src/features/chat/presentation/pending-requests/view-model"; import { threadStreamViewBlocks } from "../../../../../src/features/chat/presentation/thread-stream/view-model"; describe("thread stream presentation blocks", () => { @@ -41,6 +42,19 @@ describe("thread stream presentation blocks", () => { expect(blocks.find((block) => block.kind === "liveAgentSummary")).toMatchObject({ key: "live-agents:turn" }); }); + it("orders active live blocks by insertion and appends pending requests", () => { + const blocks = threadStreamViewBlocks({ + activeThreadId: "thread", + activeTurnId: "turn", + historyCursor: null, + loadingHistory: false, + items: [userDialogue("u1", "turn"), agentItem("agent", "turn"), taskProgressItem("task", "turn")], + pendingRequests: { signature: "request:1", snapshot: emptyPendingRequestSnapshot() }, + }); + + expect(blocks.map((block) => block.key)).toEqual(["item:u1", "item:agent", "live-agents:turn", "live-task:task", "pending-requests"]); + }); + it("renders unknown item kinds as generic status updates", () => { const blocks = threadStreamViewBlocks({ activeThreadId: "thread", @@ -111,3 +125,14 @@ function agentItem(id: string, turnId: string): ThreadStreamItem { executionState: "running", }; } + +function emptyPendingRequestSnapshot(): PendingRequestBlockSnapshot { + return { + approvals: [], + pendingUserInputs: [], + pendingMcpElicitations: [], + userInputDrafts: new Map(), + mcpElicitationDrafts: new Map(), + approvalDetails: new Set(), + }; +} diff --git a/tests/features/chat/ui/composer.test.ts b/tests/features/chat/ui/composer.test.ts index 1187c1c0..7402ed74 100644 --- a/tests/features/chat/ui/composer.test.ts +++ b/tests/features/chat/ui/composer.test.ts @@ -116,16 +116,13 @@ describe("ComposerShell decisions", () => { ], }); - const meta = parent.querySelector(".codex-panel__composer-meta"); const contextDots = Array.from(parent.querySelectorAll(".codex-panel__composer-meta-context-dot")); const modeIcons = Array.from(parent.querySelectorAll(".codex-panel__composer-meta-icon")); const statusSummary = parent.querySelector(".codex-panel__composer-meta-summary"); const statusVisual = parent.querySelector(".codex-panel__composer-meta-status-visual"); - expect(meta?.getAttribute("aria-hidden")).toBeNull(); expect(statusSummary?.textContent).toBe("Context 42%, plan on, auto-review off, fast on, model gpt-5.5, reasoning effort high"); expect(statusVisual?.getAttribute("aria-hidden")).toBe("true"); expect(statusVisual?.textContent).toBe("|⣿⣶⣀⣀42%|gpt-5.5|high"); - expect(parent.querySelector(".codex-panel__composer-meta-status")?.getAttribute("aria-hidden")).toBeNull(); expect(parent.querySelector(".codex-panel__composer-meta-context")?.textContent).toBe("⣿⣶⣀⣀42%"); expect(contextDots.map((dot) => dot.textContent)).toEqual(["⣿", "⣶", "⣀", "⣀"]); expect(contextDots.map((dot) => dot.classList.contains("is-placeholder"))).toEqual([false, false, true, true]); @@ -133,17 +130,7 @@ describe("ComposerShell decisions", () => { expect(parent.querySelector(".codex-panel__composer-meta-effort")?.textContent).toBe("high"); expect(modeIcons.map((icon) => icon.dataset["icon"])).toEqual(["list-todo", "shield", "zap"]); expect(modeIcons.map((icon) => icon.classList.contains("is-active"))).toEqual([true, false, true]); - expect(modeIcons.map((icon) => icon.tagName)).toEqual(["SPAN", "SPAN", "SPAN"]); - expect(modeIcons.map((icon) => icon.getAttribute("role"))).toEqual([null, null, null]); - expect(modeIcons.map((icon) => icon.getAttribute("tabindex"))).toEqual([null, null, null]); - expect(modeIcons.map((icon) => icon.getAttribute("aria-label"))).toEqual([null, null, null]); expect(modeIcons.map((icon) => icon.getAttribute("aria-hidden"))).toEqual(["true", "true", "true"]); - expect(modeIcons.every((icon) => icon.classList.contains("codex-panel__composer-meta-trigger"))).toBe(true); - expect(modeIcons.every((icon) => !icon.className.includes("clickable-icon"))).toBe(true); - expect(parent.querySelector(".codex-panel__composer-meta-model")?.tagName).toBe("SPAN"); - expect(parent.querySelector(".codex-panel__composer-meta-effort")?.tagName).toBe("SPAN"); - expect(parent.querySelector(".codex-panel__composer-meta-model")?.getAttribute("role")).toBeNull(); - expect(parent.querySelector(".codex-panel__composer-meta-effort")?.getAttribute("tabindex")).toBeNull(); expect(parent.querySelector(".codex-panel__composer-action.codex-panel__send")).not.toBeNull(); expect(parent.querySelector(".codex-panel__new-chat")).toBeNull(); }); @@ -194,12 +181,7 @@ describe("ComposerShell decisions", () => { expect(parent.querySelector('[data-codex-panel-composer-meta-kind="model"]')?.textContent).toBe("gpt-5.5gpt-5.4"); }); const metaOptions = Array.from(parent.querySelectorAll(".codex-panel__composer-meta-option")); - expect(metaOptions.map((option) => option.getAttribute("role"))).toEqual([null, null]); - expect(metaOptions.map((option) => option.getAttribute("tabindex"))).toEqual([null, null]); - expect(metaOptions.map((option) => option.getAttribute("aria-selected"))).toEqual([null, null]); - expect(metaOptions.every((option) => !option.classList.contains("is-selected"))).toBe(true); - expect(metaOptions.every((option) => !option.classList.contains("codex-panel-ui__nav-item"))).toBe(true); - expect(metaOptions.every((option) => !option.classList.contains("codex-panel__toolbar-panel-item"))).toBe(true); + expect(metaOptions.map((option) => option.textContent)).toEqual(["gpt-5.5", "gpt-5.4"]); parent.querySelector(".codex-panel__composer-meta-effort")?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); await waitForAsyncWork(() => { @@ -484,7 +466,6 @@ describe("ComposerShell decisions", () => { }), ); - expect(parent.querySelector(".codex-panel__composer-input")).toBe(composer); expect(composer.style.height).toBe("56px"); expect(callbacks.onHeightChange).toHaveBeenCalled(); } finally { diff --git a/tests/features/chat/ui/thread-stream/blocks-and-messages.test.tsx b/tests/features/chat/ui/thread-stream/blocks-and-messages.test.tsx index f2134914..d7accf45 100644 --- a/tests/features/chat/ui/thread-stream/blocks-and-messages.test.tsx +++ b/tests/features/chat/ui/thread-stream/blocks-and-messages.test.tsx @@ -3,11 +3,8 @@ import { MarkdownRenderer } from "obsidian"; import { act } from "preact/test-utils"; import { describe, expect, it, vi } from "vitest"; -import { implementPlanTarget } from "../../../../../src/features/chat/application/turns/plan-implementation"; import { pendingWebSubmissionItem } from "../../../../../src/features/chat/application/turns/web-submission"; -import { setCollaborationModeIntent } from "../../../../../src/features/chat/domain/runtime/intent"; import type { ThreadStreamItem } from "../../../../../src/features/chat/domain/thread-stream/items"; -import { THREAD_STREAM_CONTENT_RENDERED_EVENT } from "../../../../../src/features/chat/ui/thread-stream/content-rendered-event.dom"; import { ThreadStreamMarkdownRenderer } from "../../../../../src/features/chat/ui/thread-stream/markdown-renderer.obsidian"; import { deferred } from "../../../../support/async"; import { attributeValues, textContents, topLevelDetailsSummaries } from "../../../../support/dom"; @@ -83,90 +80,6 @@ describe("thread stream rendering and action menu", () => { unmountUiRootInAct(parent); }); - it("keeps Work details in the flow when the activity group is collapsed", async () => { - const parent = document.createElement("div"); - const blocks = threadStreamBlocks({ - items: [ - { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "do it", turnId: "t1" }, - { - id: "hook-1", - kind: "hook", - role: "tool", - text: "userPromptSubmit: Saving jj baseline", - toolName: "hook", - turnId: "t1", - status: "completed", - }, - { - id: "a1", - kind: "dialogue", - role: "assistant", - text: "done", - turnId: "t1", - dialogueKind: "assistantResponse", - dialogueState: "completed", - }, - ], - disclosures: testDisclosures({ activityGroups: ["t1"] }), - }); - const activityBlock = expectPresent(blocks.find((block) => block.key === "activity:turn-t1-activity")); - - renderThreadStreamBlocksInAct(parent, [activityBlock]); - - const host = expectPresent(parent.querySelector(".codex-panel__thread-stream-block")); - const threadStreamFlow = expectPresent(parent.querySelector(".codex-panel__thread-stream-flow")); - const activityGroup = expectPresent(parent.querySelector(".codex-panel__activity-group")); - - Object.defineProperty(host, "offsetHeight", { value: 520, configurable: true }); - await act(async () => { - activityGroup.dispatchEvent(new Event(THREAD_STREAM_CONTENT_RENDERED_EVENT, { bubbles: true })); - await Promise.resolve(); - }); - expect(threadStreamFlow.style.height).toBe(""); - expect(host.style.transform).toBe(""); - - Object.defineProperty(host, "offsetHeight", { value: 120, configurable: true }); - await act(async () => { - activityGroup.open = false; - activityGroup.dispatchEvent(new Event("toggle")); - await Promise.resolve(); - }); - - expect(threadStreamFlow.style.height).toBe(""); - expect(host.style.transform).toBe(""); - unmountUiRootInAct(parent); - }); - - it("keeps blocks in the flow after their rendered content shrinks on rerender", () => { - const parent = document.createElement("div"); - const block = threadStreamBlocks({ - items: [{ id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "expanded", turnId: "t1" }], - })[0]; - - renderThreadStreamBlocksInAct(parent, [block]); - - const host = expectPresent(parent.querySelector(".codex-panel__thread-stream-block")); - const threadStreamFlow = expectPresent(parent.querySelector(".codex-panel__thread-stream-flow")); - - Object.defineProperty(host, "offsetHeight", { value: 520, configurable: true }); - void act(() => { - host.dispatchEvent(new Event(THREAD_STREAM_CONTENT_RENDERED_EVENT, { bubbles: true })); - }); - expect(threadStreamFlow.style.height).toBe(""); - expect(host.style.transform).toBe(""); - - Object.defineProperty(host, "offsetHeight", { value: 120, configurable: true }); - renderThreadStreamBlocksInAct(parent, [ - threadStreamBlocks({ - items: [{ id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "collapsed", turnId: "t1" }], - })[0], - ]); - - expect(threadStreamFlow.style.height).toBe(""); - expect(host.style.transform).toBe(""); - unmountUiRootInAct(parent); - }); - it("renders review result items as compact auto-review tool rows", () => { const block = threadStreamBlocks({ items: [{ id: "review-1", kind: "reviewResult", role: "tool", text: "Auto-review denied this command." }], @@ -248,7 +161,6 @@ describe("thread stream rendering and action menu", () => { expect(element.classList.contains("codex-panel__stream-item--system")).toBe(true); expect(element.querySelector(".codex-panel__stream-item-content")?.textContent).toBe("Available slash commands"); - expect(element.querySelector(".codex-panel__stream-item-content")?.classList.contains("markdown-rendered")).toBe(false); expect(renderMarkdown).not.toHaveBeenCalled(); expect(element.querySelector("details")).toBeNull(); expect(element.querySelector(".codex-panel__output-title")).toBeNull(); @@ -596,7 +508,6 @@ describe("thread stream rendering and action menu", () => { }), ); const contentAfterUpdate = expectPresent(parent.querySelector(".codex-panel__stream-item-content")); - expect(contentAfterUpdate).toBe(content); expect(contentAfterUpdate.textContent).toBe("rendered:old"); secondRender.resolve(undefined); @@ -779,53 +690,6 @@ describe("thread stream rendering and action menu", () => { expect(onImplementPlan).toHaveBeenCalledWith({ itemId: "p1" }); }); - it("selects only the latest proposed plan as an implement candidate", () => { - const firstPlan = { - id: "p1", - kind: "dialogue", - role: "assistant", - text: "# First plan", - turnId: "turn-1", - dialogueKind: "proposedPlan", - dialogueState: "completed", - } as const; - const secondPlan = { - id: "p2", - kind: "dialogue", - role: "assistant", - text: "# Second plan", - turnId: "turn-2", - dialogueKind: "proposedPlan", - dialogueState: "completed", - } as const; - const baseState = { - activeThread: { id: "thread", provenance: null }, - turn: { lifecycle: { kind: "idle" as const } }, - runtime: { pending: { collaborationMode: setCollaborationModeIntent("plan") } }, - threadStream: { - stableItems: [ - firstPlan, - { - id: "a1", - kind: "dialogue", - role: "assistant", - text: "answer", - dialogueKind: "assistantResponse", - dialogueState: "completed", - } as const, - secondPlan, - ], - activeSegment: null, - }, - }; - - expect(implementPlanTarget(baseState)).toEqual({ itemId: secondPlan.id }); - expect( - implementPlanTarget({ ...baseState, runtime: { pending: { collaborationMode: setCollaborationModeIntent("default") } } }), - ).toBeNull(); - expect(implementPlanTarget({ ...baseState, turn: { lifecycle: { kind: "running", turnId: "turn-2" } } })).toBeNull(); - }); - it("does not render copy actions for tool items", () => { const block = threadStreamBlocks({ items: [ diff --git a/tests/features/chat/ui/thread-stream/pending-requests.test.tsx b/tests/features/chat/ui/thread-stream/pending-requests.test.tsx index b27d357e..29979e13 100644 --- a/tests/features/chat/ui/thread-stream/pending-requests.test.tsx +++ b/tests/features/chat/ui/thread-stream/pending-requests.test.tsx @@ -107,7 +107,6 @@ describe("pending request renderer decisions", () => { }; render(); - expect(parent.querySelector(".codex-panel__user-input-other-text")?.getAttribute("aria-label")).toBeNull(); actEvent(() => { changeInputValue(expectPresent(parent.querySelector(".codex-panel__user-input-other-text")), "Custom scope"); }); @@ -268,7 +267,6 @@ describe("pending request renderer decisions", () => { ); const inputElement = parent.querySelector(".codex-panel__user-input-text"); - expect(inputElement?.getAttribute("aria-label")).toBeNull(); expect(document.activeElement).toBe(inputElement); } finally { unmountUiRootInAct(parent); @@ -413,7 +411,6 @@ describe("pending request renderer decisions", () => { expect(element.querySelector(".codex-panel__stream-item-role")?.textContent).toBe("Input"); expect(element.querySelector(".codex-panel__stream-item-content")?.textContent).toBe("Input submitted for 1 question."); - expect(element.querySelector(".codex-panel__stream-item-content")?.classList.contains("markdown-rendered")).toBe(false); expect(renderMarkdown).not.toHaveBeenCalled(); expect(element.textContent).not.toContain("Approval"); expect(element.querySelector("details summary")?.textContent).toBe("Question: Scope"); @@ -532,7 +529,6 @@ describe("pending request renderer decisions", () => { const input = expectPresent(parent.querySelector(".codex-panel__mcp-elicitation-input")); const label = expectPresent(parent.querySelector(".codex-panel__mcp-elicitation-label")); expect(label.id).toContain("test-panel"); - expect(label.tagName).toBe("LABEL"); expect(label.getAttribute("for")).toBe(input.id); changeInputValue(input, "Updated"); actEvent(() => { diff --git a/tests/features/chat/ui/thread-stream/stream-items.test.tsx b/tests/features/chat/ui/thread-stream/stream-items.test.tsx index c83af2a7..ecf2978d 100644 --- a/tests/features/chat/ui/thread-stream/stream-items.test.tsx +++ b/tests/features/chat/ui/thread-stream/stream-items.test.tsx @@ -300,172 +300,6 @@ describe("thread stream item renderer decisions", () => { expect(element.textContent).toContain("[>]Patch UI"); }); - it("renders active task progress with the shared bottom live blocks", () => { - const blocks = threadStreamBlocks({ - turnLifecycle: runningTurnLifecycle("turn"), - items: [ - { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "Do it", turnId: "turn" }, - { - id: "plan-progress-turn", - kind: "taskProgress", - role: "tool", - text: "[>] Patch UI", - turnId: "turn", - explanation: null, - steps: [{ step: "Patch UI", status: "inProgress" }], - status: "inProgress", - }, - { - id: "a1", - kind: "dialogue", - role: "assistant", - text: "Working", - turnId: "turn", - dialogueKind: "assistantResponse", - dialogueState: "completed", - }, - { - id: "agent-1", - kind: "agent", - role: "tool", - text: "Wait for agent", - turnId: "turn", - tool: "wait", - status: "running", - senderThreadId: "parent", - receiverThreadIds: ["running"], - prompt: null, - model: null, - reasoningEffort: null, - agents: [{ threadId: "running", status: "running", executionState: "running", message: "Inspecting renderer" }], - }, - ], - pendingRequests: { - controlNamespace: "test-panel", - signature: "request:1", - snapshot: () => ({ - approvals: [], - pendingUserInputs: [], - pendingMcpElicitations: [], - userInputDrafts: new Map(), - mcpElicitationDrafts: new Map(), - approvalDetails: new Set(), - }), - actions: () => ({ - resolveApproval: vi.fn(), - resolveUserInput: vi.fn(), - cancelUserInput: vi.fn(), - resolveMcpElicitation: vi.fn(), - setUserInputDraft: vi.fn(), - setMcpElicitationDraft: vi.fn(), - }), - consumeAutoFocus: () => false, - }, - }); - - expect(blocks.map((block) => block.key)).toEqual([ - "item:u1", - "item:a1", - "item:agent-1", - "live-task:plan-progress-turn", - "live-agents:turn", - "pending-requests", - ]); - }); - - it("orders shared bottom live blocks by insertion order", () => { - const blocks = threadStreamBlocks({ - turnLifecycle: runningTurnLifecycle("turn"), - items: [ - { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "Do it", turnId: "turn" }, - { - id: "agent-1", - kind: "agent", - role: "tool", - text: "Wait for agent", - turnId: "turn", - tool: "wait", - status: "running", - senderThreadId: "parent", - receiverThreadIds: ["running"], - prompt: null, - model: null, - reasoningEffort: null, - agents: [{ threadId: "running", status: "running", executionState: "running", message: null }], - }, - { - id: "plan-progress-turn", - kind: "taskProgress", - role: "tool", - text: "[>] Patch UI", - turnId: "turn", - explanation: null, - steps: [{ step: "Patch UI", status: "inProgress" }], - status: "inProgress", - }, - ], - }); - - expect(blocks.map((block) => block.key)).toEqual(["item:u1", "item:agent-1", "live-agents:turn", "live-task:plan-progress-turn"]); - }); - - it("anchors the live agent summary at the first agent activity", () => { - const blocks = threadStreamBlocks({ - turnLifecycle: runningTurnLifecycle("turn"), - items: [ - { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "Do it", turnId: "turn" }, - { - id: "agent-spawn", - kind: "agent", - role: "tool", - text: "Spawn agent", - turnId: "turn", - tool: "spawnAgent", - status: "completed", - senderThreadId: "parent", - receiverThreadIds: ["child"], - prompt: "Inspect the renderer.", - model: null, - reasoningEffort: null, - agents: [{ threadId: "child", status: "completed", executionState: "completed", message: null }], - }, - { - id: "plan-progress-turn", - kind: "taskProgress", - role: "tool", - text: "[>] Patch UI", - turnId: "turn", - explanation: null, - steps: [{ step: "Patch UI", status: "inProgress" }], - status: "inProgress", - }, - { - id: "agent-wait", - kind: "agent", - role: "tool", - text: "Wait for agent", - turnId: "turn", - tool: "wait", - status: "running", - senderThreadId: "parent", - receiverThreadIds: ["child"], - prompt: null, - model: null, - reasoningEffort: null, - agents: [{ threadId: "child", status: "running", executionState: "running", message: "Inspecting renderer" }], - }, - ], - }); - - expect(blocks.map((block) => block.key)).toEqual([ - "item:u1", - "item:agent-spawn", - "item:agent-wait", - "live-agents:turn", - "live-task:plan-progress-turn", - ]); - }); - it("renders agent activity as a one-line summary with consolidated details", () => { const block = threadStreamBlocks({ turnLifecycle: runningTurnLifecycle("turn"), @@ -529,8 +363,6 @@ describe("thread stream item renderer decisions", () => { const header = expectPresent(element.querySelector("details summary")); const open = expectPresent(header.querySelector('[aria-label="Open agent thread"]')); - expect(open.classList.contains("codex-panel__hover-action")).toBe(true); - expect(open.classList.contains("codex-panel-ui__nav-row-action")).toBe(false); expect(textContents(element, "details summary")).toEqual(["agent"]); open.click(); @@ -704,8 +536,6 @@ describe("thread stream item renderer decisions", () => { expect(summary.textContent).not.toContain("donecompleted"); expect(summary.querySelector('[aria-label="Open agent thread"]')).toBeNull(); const open = expectPresent(summary.querySelector(".codex-panel__agent-row--interactive")); - expect(open.classList.contains("codex-panel-ui__nav-item")).toBe(true); - expect(open.getAttribute("aria-label")).toBeNull(); expect(open.textContent).toBe("runningrunning: Inspecting renderer"); open.click(); expect(openThreadInNewView).toHaveBeenCalledWith("running"); diff --git a/tests/features/chat/ui/toolbar.test.ts b/tests/features/chat/ui/toolbar.test.ts index 922fec62..24b6c786 100644 --- a/tests/features/chat/ui/toolbar.test.ts +++ b/tests/features/chat/ui/toolbar.test.ts @@ -36,30 +36,23 @@ describe("Toolbar decisions", () => { "Show chat actions", "Show status", ]); - expect([...expectPresent(navButtons).children].map((button) => button.tagName)).toEqual(["DIV", "DIV", "DIV"]); - expect([...expectPresent(navButtons).children].map((button) => button.getAttribute("role"))).toEqual([null, null, null]); expect(parent.querySelector(".codex-panel__plan-toggle")).toBeNull(); expect(parent.querySelector(".codex-panel__auto-review-toggle")).toBeNull(); expect(parent.querySelector(".codex-panel__runtime-model")).toBeNull(); const newChatButton = parent.querySelector(".codex-panel__new-chat"); expect(newChatButton?.getAttribute("aria-label")).toBe("Show chat actions"); - expect(newChatButton?.getAttribute("aria-expanded")).toBeNull(); - expect(newChatButton?.classList.contains("is-disabled")).toBe(false); newChatButton?.click(); expect(toggleChatActions).toHaveBeenCalled(); expect(startNewThread).not.toHaveBeenCalled(); const statusButton = parent.querySelector(".codex-panel__status-menu-toggle"); expect(statusButton?.getAttribute("aria-label")).toBe("Show status"); - expect(statusButton?.getAttribute("aria-expanded")).toBeNull(); const historyButton = parent.querySelector(".codex-panel__history-toggle"); expect(historyButton?.getAttribute("aria-label")).toBe("Show thread list"); - expect(historyButton?.getAttribute("aria-pressed")).toBeNull(); historyButton?.click(); expect(toggleHistory).toHaveBeenCalled(); parent.empty(); mountToolbar(parent, toolbarModel({ newChatDisabled: true }), toolbarActions()); - expect(parent.querySelector(".codex-panel__new-chat")?.tagName).toBe("DIV"); - expect(parent.querySelector(".codex-panel__new-chat")?.classList.contains("is-disabled")).toBe(false); + expect(parent.querySelector(".codex-panel__new-chat")?.getAttribute("aria-label")).toBe("Show chat actions"); parent.empty(); mountToolbar(parent, toolbarModel({ chatActionsOpen: true, historyOpen: true, statusPanelOpen: true }), toolbarActions()); @@ -85,11 +78,7 @@ describe("Toolbar decisions", () => { ); const items = [...parent.querySelectorAll(".codex-panel__chat-actions-panel-item")]; - expect(parent.querySelector(".codex-panel__chat-actions-panel-items")?.tagName).toBe("DIV"); - expect(parent.querySelector(".codex-panel__chat-actions-panel-items")?.getAttribute("aria-label")).toBeNull(); expect(items).toHaveLength(4); - expect(items.map((item) => item.tagName)).toEqual(["DIV", "DIV", "DIV", "DIV"]); - expect(items.map((item) => item.getAttribute("role"))).toEqual([null, null, null, null]); items[0]?.click(); items[1]?.click(); items[2]?.click(); @@ -98,6 +87,19 @@ describe("Toolbar decisions", () => { expect(startSideChat).toHaveBeenCalledOnce(); expect(compactContext).toHaveBeenCalledOnce(); expect(setGoal).toHaveBeenCalledOnce(); + + mountToolbar( + parent, + toolbarModel({ chatActionsOpen: true, openPanel: "chat-actions", newChatDisabled: true }), + toolbarActions({ startNewThread, startSideChat, compactContext, setGoal }), + ); + const disabledStart = expectPresent( + [...parent.querySelectorAll(".codex-panel__chat-actions-panel-item")].find( + (item) => item.textContent === "Start new chat", + ), + ); + disabledStart.click(); + expect(startNewThread).toHaveBeenCalledOnce(); }); it("keeps context out of the toolbar and Codex limits inside the status menu", () => { @@ -145,12 +147,8 @@ describe("Toolbar decisions", () => { expect(parent.querySelector(".codex-panel__limit-panel")?.textContent).toContain("21%"); expect(parent.querySelector(".codex-panel__limit-panel")?.textContent).toContain("reset in 3d 4h"); const meters = [...parent.querySelectorAll(".codex-panel__limit-panel-meter")]; - expect(meters.map((meter) => meter.classList.contains("codex-panel__limit-panel-meter--divided"))).toEqual([true, true]); - expect(meters[0]?.classList.contains("codex-panel__limit-panel-meter--5")).toBe(true); - expect(meters[1]?.classList.contains("codex-panel__limit-panel-meter--7")).toBe(true); expect(meters.map((meter) => meter.getAttribute("role"))).toEqual(["progressbar", "progressbar"]); expect(meters.map((meter) => meter.getAttribute("aria-valuenow"))).toEqual(["42", "21"]); - expect(meters.map((meter) => meter.getAttribute("aria-label"))).toEqual([null, null]); }); it("renders connection diagnostics in the status menu", () => { @@ -184,11 +182,6 @@ describe("Toolbar decisions", () => { expect(parent.textContent).toContain("codex-cli/1.2.3"); expect(parent.querySelector(".codex-panel__status-diagnostics-row--error")?.textContent).toContain("model/list failed"); const statusItems = [...parent.querySelectorAll(".codex-panel__status-panel-item")]; - expect(parent.querySelector(".codex-panel__status-panel-items")?.tagName).toBe("DIV"); - expect(parent.querySelector(".codex-panel__status-panel-items")?.getAttribute("aria-label")).toBeNull(); - expect(statusItems.map((item) => item.tagName)).toEqual(["DIV", "DIV", "DIV"]); - expect(statusItems.map((item) => item.getAttribute("role"))).toEqual([null, null, null]); - expect(statusItems.every((item) => item.getAttribute("aria-selected") === null)).toBe(true); statusItems.find((item) => item.textContent.includes("Refresh"))?.click(); expect(refreshStatus).toHaveBeenCalled(); }); @@ -262,6 +255,7 @@ describe("Toolbar decisions", () => { it("renders thread list rename actions and an inline rename editor", () => { const parent = document.createElement("div"); + document.body.append(parent); const startRenameThread = vi.fn(); const updateRenameDraft = vi.fn(); const saveRenameThread = vi.fn(); @@ -294,11 +288,12 @@ describe("Toolbar decisions", () => { expect(startRenameThread).toHaveBeenCalledWith("thread"); const threadRow = parent.querySelector(".codex-panel__thread-row"); expect(threadRow?.classList.contains("codex-panel__thread-row--selected")).toBe(true); - expect(threadRow?.classList.contains("is-selected")).toBe(true); const input = parent.querySelector(".codex-panel__thread-rename-input"); if (!input) throw new Error("Missing thread rename input"); expect(input.value).toBe("Draft title"); + expect(document.activeElement).toBe(input); + expect([input.selectionStart, input.selectionEnd]).toEqual([0, input.value.length]); changeInputValue(input, "New title"); expect(updateRenameDraft).toHaveBeenCalledWith("editing", "New title"); @@ -321,14 +316,19 @@ describe("Toolbar decisions", () => { }), actions, ); - expectPresent(parent.querySelector(".codex-panel__thread-rename-input")).dispatchEvent(new FocusEvent("blur")); + const renamedInput = expectPresent(parent.querySelector(".codex-panel__thread-rename-input")); + renamedInput.dispatchEvent(new FocusEvent("blur")); expect(saveRenameThread).toHaveBeenCalledWith("editing", "New title"); - expectPresent(parent.querySelector(".codex-panel__thread-rename-input")).dispatchEvent( - new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), - ); + saveRenameThread.mockClear(); + renamedInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, isComposing: true })); + expect(saveRenameThread).not.toHaveBeenCalled(); + renamedInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + expect(saveRenameThread).toHaveBeenCalledWith("editing", "New title"); + renamedInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); expect(cancelRenameThread).toHaveBeenCalledWith("editing"); parent.querySelector('[aria-label="Auto-name thread"]')?.click(); expect(autoNameThread).toHaveBeenCalledWith("editing"); + parent.remove(); }); it("renders auto-name loading without disabling the rename draft field", () => { @@ -392,7 +392,6 @@ describe("Toolbar decisions", () => { "Archive thread without saving", "Save and archive thread", ]); - expect(archiveButtons.every((button) => !button.classList.contains("nav-action-button"))).toBe(true); expect(parent.querySelector('[aria-label="Rename thread"]')).toBeNull(); archiveButtons[0]?.click(); expect(archiveThread).toHaveBeenCalledWith("thread", false); diff --git a/tests/features/threads-view/shell.test.tsx b/tests/features/threads-view/shell.test.tsx index 1c3ab0c9..5a16057b 100644 --- a/tests/features/threads-view/shell.test.tsx +++ b/tests/features/threads-view/shell.test.tsx @@ -114,25 +114,16 @@ describe("threads view renderer decisions", () => { renderThreadsViewShell(parent, { status: "2 threads", loading: false, rows }, actions); const main = expectPresent(parent.querySelector(".codex-panel-threads__row--pending")); - const row = expectPresent(main.closest(".codex-panel-threads__row")); - expect(row.classList.contains("codex-panel-ui__nav-row")).toBe(true); - expect(row.classList.contains("is-selected")).toBe(true); - expect(row.classList.contains("codex-panel-threads__row--selected")).toBe(true); - expect(main.classList.contains("codex-panel-ui__nav-item")).toBe(true); + 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"]')); - expect(refresh.classList.contains("nav-action-button")).toBe(true); - expect(refresh.classList.contains("codex-panel-ui__toolbar-action")).toBe(true); - expect(refresh.classList.contains("codex-panel-ui__nav-row-action")).toBe(false); refresh.click(); expect(actions.refresh).toHaveBeenCalledOnce(); const openNewPanel = expectPresent(parent.querySelector('[aria-label="Open new panel"]')); openNewPanel.click(); expect(actions.openNewPanel).toHaveBeenCalledOnce(); - const rename = expectPresent(parent.querySelector('[aria-label="Rename thread"]')); - expect(rename.classList.contains("nav-action-button")).toBe(false); - expect(rename.classList.contains("codex-panel-ui__nav-row-action")).toBe(true); + expect(parent.querySelector('[aria-label="Rename thread"]')).not.toBeNull(); main.click(); expect(actions.openThread).toHaveBeenCalledWith("open"); }); @@ -175,6 +166,7 @@ describe("threads view renderer decisions", () => { 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", @@ -184,6 +176,8 @@ describe("threads view renderer decisions", () => { renderThreadsViewShell(parent, { status: "1 thread", 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"); @@ -192,11 +186,20 @@ describe("threads view renderer decisions", () => { { status: "1 thread", loading: false, rows: [{ ...row, rename: { active: true, draft: "New name", generating: false } }] }, actions, ); - expectPresent(parent.querySelector(".codex-panel-threads__rename-input")).dispatchEvent(new FocusEvent("blur")); + 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", () => { diff --git a/tests/settings/dynamic-sections-controller.test.ts b/tests/settings/dynamic-sections-controller.test.ts new file mode 100644 index 00000000..f6dea350 --- /dev/null +++ b/tests/settings/dynamic-sections-controller.test.ts @@ -0,0 +1,328 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { CatalogHookMetadata } from "../../src/app-server/protocol/catalog"; +import { modelMetadataFromCatalogModels } from "../../src/app-server/protocol/catalog"; +import type { ThreadRecord } from "../../src/app-server/protocol/thread"; +import type { ObservedResult } from "../../src/app-server/query/observed-result"; +import type { ModelMetadata } from "../../src/domain/catalog/metadata"; +import type { Thread } from "../../src/domain/threads/model"; +import { SettingsDynamicSectionsController, type SettingsDynamicSectionsSnapshot } from "../../src/settings/dynamic-sections-controller"; +import { deferred } from "../support/async"; +import { + appServerThread, + flushPromises, + hook, + model, + panelThread, + setSettingsShortLivedClientMock, + settingsClient, + settingsRequestClient, + settingsTabHost, + useShortLivedClients, +} from "./test-support"; + +const { withShortLivedAppServerClientMock } = vi.hoisted(() => ({ + withShortLivedAppServerClientMock: vi.fn(), +})); + +vi.mock("../../src/app-server/connection/short-lived-client", () => ({ + withShortLivedAppServerClient: withShortLivedAppServerClientMock, +})); + +setSettingsShortLivedClientMock(withShortLivedAppServerClientMock); + +describe("SettingsDynamicSectionsController", () => { + beforeEach(() => { + withShortLivedAppServerClientMock.mockReset(); + }); + + it("publishes archived thread catalog updates", () => { + let emitArchived = (_threads: readonly Thread[]): void => { + throw new Error("Expected archived thread observer"); + }; + const display = vi.fn(); + const controller = new SettingsDynamicSectionsController( + settingsTabHost({ + observeArchived: (listener) => { + emitArchived = (threads) => { + listener({ value: threads, error: null, isFetching: false } satisfies ObservedResult); + }; + return () => undefined; + }, + }), + { display, notify: vi.fn() }, + ); + + controller.activate(); + emitArchived([panelThread({ id: "thread-archived", preview: "Archived elsewhere", archived: true })]); + + expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["Archived elsewhere"]); + expect(controller.snapshot().archivedThreadsLifecycle.kind).toBe("loaded"); + expect(display).toHaveBeenCalledOnce(); + }); + + it("ignores stale dynamic sections refresh results after a newer refresh completes", async () => { + const firstModels = deferred(); + const firstClient = settingsClient(); + const secondClient = settingsClient({ models: [model("gpt-new")] }); + useShortLivedClients(firstClient, secondClient); + const refreshModels = vi + .fn() + .mockReturnValueOnce(firstModels.promise) + .mockResolvedValueOnce(modelMetadataFromCatalogModels([model("gpt-new")])); + const refreshArchived = vi + .fn() + .mockResolvedValueOnce([panelThread({ id: "thread-old", preview: "Old", archived: true })]) + .mockResolvedValueOnce([panelThread({ id: "thread-new", preview: "New", archived: true })]); + const controller = new SettingsDynamicSectionsController(settingsTabHost({ refreshModels, refreshArchived }), { + display: vi.fn(), + notify: vi.fn(), + }); + + const firstRefresh = controller.refreshDynamicSections(); + await flushPromises(); + await controller.refreshDynamicSections(); + + expect(controller.snapshot().models.map((item) => item.model)).toEqual(["gpt-new"]); + expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["New"]); + + firstModels.resolve(modelMetadataFromCatalogModels([model("gpt-old")])); + await firstRefresh; + + expect(controller.snapshot().models.map((item) => item.model)).toEqual(["gpt-new"]); + expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["New"]); + }); + + it("does not display dynamic refresh results after disposal", async () => { + const models = deferred(); + const display = vi.fn(); + const controller = new SettingsDynamicSectionsController( + settingsTabHost({ + refreshModels: vi.fn(() => models.promise), + refreshArchived: vi.fn().mockResolvedValue([]), + }), + { display, notify: vi.fn() }, + ); + + const refresh = controller.refreshDynamicSections(); + await flushPromises(); + display.mockClear(); + controller.dispose(); + models.resolve([]); + await refresh; + + expect(display).not.toHaveBeenCalled(); + }); + + it("ignores stale hook reload results after a newer dynamic operation completes", async () => { + const staleHooks = deferred<{ + data: { cwd: string; hooks: CatalogHookMetadata[]; warnings: string[]; errors: unknown[] }[]; + }>(); + const initialClient = settingsClient({ + hooks: [hook({ key: "hook-initial", command: "initial hook", currentHash: "initialhash" })], + }); + const trustClient = settingsRequestClient({ + "config/batchWrite": vi.fn().mockResolvedValue({}), + }); + const staleClient = settingsClient(); + staleClient.requestHandlers["hooks/list"]?.mockReturnValue(staleHooks.promise); + const newerClient = settingsClient({ + hooks: [hook({ key: "hook-new", command: "new hook", currentHash: "newhash" })], + }); + useShortLivedClients(initialClient, trustClient, staleClient, newerClient); + const controller = new SettingsDynamicSectionsController(settingsTabHost(), { display: vi.fn(), notify: vi.fn() }); + + await controller.refreshDynamicSections(); + const initialHook = controller.snapshot().hooks[0]; + if (!initialHook) throw new Error("Expected initial hook to load"); + const staleReload = controller.trustHook(initialHook); + await flushPromises(); + await controller.refreshDynamicSections(); + + expect(controller.snapshot().hooks.map((hook) => hook.currentHash)).toEqual(["newhash"]); + + staleHooks.resolve({ + data: [{ cwd: "/vault", hooks: [hook({ key: "hook-old", command: "old hook", currentHash: "oldhash" })], warnings: [], errors: [] }], + }); + await staleReload; + + expect(controller.snapshot().hooks.map((item) => item.currentHash)).toEqual(["newhash"]); + }); + + it("keeps full refresh models and archived threads current when hook operations overlap", async () => { + const models = deferred(); + const fullRefreshClient = settingsClient({ + hooks: [hook({ key: "hook-full", command: "full refresh hook", currentHash: "fullhash" })], + }); + const trustClient = settingsRequestClient({ + "config/batchWrite": vi.fn().mockResolvedValue({}), + }); + const hookReloadClient = settingsClient({ + hooks: [hook({ key: "hook-new", command: "new hook", currentHash: "newhash" })], + }); + useShortLivedClients(fullRefreshClient, trustClient, hookReloadClient); + const refreshModels = vi.fn().mockReturnValue(models.promise); + const refreshArchived = vi.fn().mockResolvedValue([panelThread({ id: "thread-full", preview: "Full archived", archived: true })]); + const controller = new SettingsDynamicSectionsController(settingsTabHost({ refreshModels, refreshArchived }), { + display: vi.fn(), + notify: vi.fn(), + }); + + const fullRefresh = controller.refreshDynamicSections(); + await flushPromises(); + await controller.trustHook(hook({ key: "hook-initial", command: "initial hook", currentHash: "initialhash" })); + + models.resolve(modelMetadataFromCatalogModels([model("gpt-refreshed")])); + await fullRefresh; + + const snapshot = controller.snapshot(); + expect(snapshot.models.map((item) => item.model)).toEqual(["gpt-refreshed"]); + expect(snapshot.modelsLifecycle.kind).toBe("loaded"); + expect(snapshot.archivedThreads.map((thread) => thread.preview)).toEqual(["Full archived"]); + expect(snapshot.archivedThreadsLifecycle.kind).toBe("loaded"); + expect(snapshot.hooks.map((item) => item.currentHash)).toEqual(["newhash"]); + }); + + it("ignores stale archived restore results after a newer dynamic operation completes", async () => { + const staleRestore = deferred<{ thread: ThreadRecord }>(); + const applyThreadCatalogEvent = vi.fn(); + const initialClient = settingsClient(); + const restoreClient = settingsRequestClient({ + "thread/unarchive": vi.fn(() => staleRestore.promise), + }); + const newerClient = settingsClient(); + useShortLivedClients(initialClient, restoreClient, newerClient); + const refreshArchived = vi + .fn() + .mockResolvedValueOnce([panelThread({ id: "thread-old", preview: "Old archived", archived: true })]) + .mockResolvedValueOnce([panelThread({ id: "thread-new", preview: "New archived", archived: true })]); + const controller = new SettingsDynamicSectionsController(settingsTabHost({ applyThreadCatalogEvent, refreshArchived }), { + display: vi.fn(), + notify: vi.fn(), + }); + + await controller.refreshDynamicSections(); + const restore = controller.restoreArchivedThread("thread-old"); + await flushPromises(); + await controller.refreshDynamicSections(); + + expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["New archived"]); + + staleRestore.resolve({ thread: appServerThread({ id: "thread-old", preview: "Restored old" }) }); + await restore; + + expect(applyThreadCatalogEvent).not.toHaveBeenCalled(); + expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["New archived"]); + }); + + it("records restored archived threads in the active catalog", async () => { + const notify = vi.fn(); + const applyThreadCatalogEvent = vi.fn(); + const initialClient = settingsClient(); + const restoreClient = settingsRequestClient({ + "thread/unarchive": vi.fn().mockResolvedValue({ thread: appServerThread({ id: "thread-old", preview: "Restored old" }) }), + }); + useShortLivedClients(initialClient, restoreClient); + const controller = new SettingsDynamicSectionsController( + settingsTabHost({ + archivedThreads: [panelThread({ id: "thread-old", preview: "Old archived", archived: true })], + applyThreadCatalogEvent, + }), + { display: vi.fn(), notify }, + ); + + await controller.refreshDynamicSections(); + await controller.restoreArchivedThread("thread-old"); + + const snapshot = controller.snapshot(); + expect(snapshot.archivedThreads).toEqual([]); + expect(snapshot.archivedThreadsLifecycle.kind).toBe("loaded"); + expect(applyThreadCatalogEvent).toHaveBeenCalledWith({ + type: "thread-restored", + thread: expect.objectContaining({ id: "thread-old", preview: "Restored old", archived: false }), + }); + expect(notify).not.toHaveBeenCalledWith("Could not restore archived Codex thread."); + }); + + it("displays restored archived thread state after recording the active catalog event", async () => { + const snapshots: SettingsDynamicSectionsSnapshot[] = []; + const initialClient = settingsClient(); + const restoreClient = settingsRequestClient({ + "thread/unarchive": vi.fn().mockResolvedValue({ thread: appServerThread({ id: "thread-old", preview: "Restored old" }) }), + }); + useShortLivedClients(initialClient, restoreClient); + const controllerRef: { current: SettingsDynamicSectionsController | null } = { current: null }; + let emitArchived = (_threads: readonly Thread[]): void => undefined; + const controller = new SettingsDynamicSectionsController( + settingsTabHost({ + archivedThreads: [panelThread({ id: "thread-old", preview: "Old archived", archived: true })], + observeArchived: (listener) => { + emitArchived = (threads) => { + listener({ value: threads, error: null, isFetching: false } satisfies ObservedResult); + }; + return () => undefined; + }, + applyThreadCatalogEvent: () => { + emitArchived([]); + }, + }), + { + display: () => { + const snapshot = controllerRef.current?.snapshot(); + if (snapshot) snapshots.push(snapshot); + }, + notify: vi.fn(), + }, + ); + controllerRef.current = controller; + + await controller.refreshDynamicSections(); + snapshots.length = 0; + await controller.restoreArchivedThread("thread-old"); + + expect(snapshots.at(-1)?.archivedThreads).toEqual([]); + expect(snapshots.at(-1)?.archivedThreadsLifecycle.kind).toBe("loaded"); + expect(snapshots.at(-1)?.archivedThreadsLifecycle.status).toBe('Restored "Restored old".'); + }); + + it("displays deleted archived thread status after recording the catalog event", async () => { + const snapshots: SettingsDynamicSectionsSnapshot[] = []; + const initialClient = settingsClient(); + const deleteClient = settingsRequestClient({ + "thread/delete": vi.fn().mockResolvedValue({}), + }); + useShortLivedClients(initialClient, deleteClient); + const controllerRef: { current: SettingsDynamicSectionsController | null } = { current: null }; + let emitArchived = (_threads: readonly Thread[]): void => undefined; + const controller = new SettingsDynamicSectionsController( + settingsTabHost({ + archivedThreads: [panelThread({ id: "thread-old", preview: "Old archived", archived: true })], + observeArchived: (listener) => { + emitArchived = (threads) => { + listener({ value: threads, error: null, isFetching: false } satisfies ObservedResult); + }; + return () => undefined; + }, + applyThreadCatalogEvent: () => { + emitArchived([]); + }, + }), + { + display: () => { + const snapshot = controllerRef.current?.snapshot(); + if (snapshot) snapshots.push(snapshot); + }, + notify: vi.fn(), + }, + ); + controllerRef.current = controller; + + await controller.refreshDynamicSections(); + snapshots.length = 0; + await controller.deleteArchivedThread("thread-old"); + + expect(snapshots.at(-1)?.archivedThreads).toEqual([]); + expect(snapshots.at(-1)?.archivedThreadsLifecycle.kind).toBe("loaded"); + expect(snapshots.at(-1)?.archivedThreadsLifecycle.status).toBe('Deleted "Old archived".'); + }); +}); diff --git a/tests/settings/settings-tab.test.ts b/tests/settings/settings-tab.test.ts index 4a4de9b3..24b9711e 100644 --- a/tests/settings/settings-tab.test.ts +++ b/tests/settings/settings-tab.test.ts @@ -1,24 +1,26 @@ // @vitest-environment jsdom import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { AppServerClient } from "../../src/app-server/connection/client"; -import type { AppServerClientAccessOptions } from "../../src/app-server/connection/client-access"; -import type { CatalogHookMetadata, CatalogModel } from "../../src/app-server/protocol/catalog"; import { modelMetadataFromCatalogModels } from "../../src/app-server/protocol/catalog"; -import type { ThreadRecord } from "../../src/app-server/protocol/thread"; -import type { ObservedResult } from "../../src/app-server/query/observed-result"; -import type { ModelMetadata, ReasoningEffort } from "../../src/domain/catalog/metadata"; -import type { Thread } from "../../src/domain/threads/model"; -import type { ThreadCatalogEvent } from "../../src/features/threads/catalog/thread-catalog"; -import { createSettingsAppServerDynamicData } from "../../src/settings/app-server-dynamic-data"; -import type { SettingsDynamicDataAccess } from "../../src/settings/dynamic-data"; -import { SettingsDynamicSectionsController, type SettingsDynamicSectionsSnapshot } from "../../src/settings/dynamic-sections-controller"; import type { CodexPanelSettingTabHost } from "../../src/settings/host"; -import { type CodexPanelSettings, DEFAULT_SETTINGS } from "../../src/settings/model"; +import { DEFAULT_SETTINGS } from "../../src/settings/model"; import { CodexPanelSettingTab } from "../../src/settings/tab.obsidian"; import { notices } from "../mocks/obsidian"; import { deferred } from "../support/async"; import { installObsidianDomShims } from "../support/dom"; +import { + expectRequestTimes, + flushPromises, + hook, + model, + panelThread, + requestMethods, + type SettingsTabHostOptions, + setSettingsShortLivedClientMock, + settingsClient, + settingsTabHost, + useShortLivedClients, +} from "./test-support"; installObsidianDomShims(); @@ -30,6 +32,8 @@ vi.mock("../../src/app-server/connection/short-lived-client", () => ({ withShortLivedAppServerClient: withShortLivedAppServerClientMock, })); +setSettingsShortLivedClientMock(withShortLivedAppServerClientMock); + describe("settings tab", () => { beforeEach(() => { withShortLivedAppServerClientMock.mockReset(); @@ -85,12 +89,8 @@ describe("settings tab", () => { const tab = newSettingsTab({ saveSettings }); tab.display(); - const codexPath = inputForSetting(tab, "Codex executable"); - expect(codexPath?.getAttribute("aria-label")).toBeNull(); - expect(codexPath?.type).toBe("text"); const shortcut = selectForSetting(tab, "Send shortcut"); if (!shortcut) throw new Error("Missing send shortcut dropdown"); - expect(shortcut.classList.contains("dropdown")).toBe(true); shortcut.value = "mod-enter"; shortcut.dispatchEvent(new Event("change")); @@ -109,8 +109,6 @@ describe("settings tab", () => { tab.display(); const toggle = inputForSetting(tab, "Show chat toolbar"); if (!toggle) throw new Error("Missing toolbar visibility toggle"); - expect(toggle.parentElement?.classList.contains("checkbox-container")).toBe(true); - toggle.checked = false; toggle.dispatchEvent(new Event("change")); await flushPromises(); @@ -220,8 +218,6 @@ describe("settings tab", () => { tab.display(); const toggle = inputForSetting(tab, "Scroll conversation from composer line edges"); if (!toggle) throw new Error("Missing composer line edge scroll toggle"); - expect(toggle.parentElement?.classList.contains("checkbox-container")).toBe(true); - toggle.checked = true; toggle.dispatchEvent(new Event("change")); await flushPromises(); @@ -237,8 +233,6 @@ describe("settings tab", () => { tab.display(); const toggle = inputForSetting(tab, "Reference active file on send"); if (!toggle) throw new Error("Missing active file reference toggle"); - expect(toggle.parentElement?.classList.contains("checkbox-container")).toBe(true); - toggle.checked = true; toggle.dispatchEvent(new Event("change")); await flushPromises(); @@ -254,8 +248,6 @@ describe("settings tab", () => { tab.display(); const folder = inputForSetting(tab, "Attachment folder"); if (!folder) throw new Error("Missing attachment folder input"); - expect(folder.type).toBe("text"); - folder.value = "Files/Codex"; folder.dispatchEvent(new Event("blur")); await flushPromises(); @@ -271,32 +263,24 @@ describe("settings tab", () => { tab.display(); const toggle = inputForSetting(tab, "Save note by default"); if (!toggle) throw new Error("Missing archive export toggle"); - expect(toggle.parentElement?.classList.contains("checkbox-container")).toBe(true); - toggle.checked = true; toggle.dispatchEvent(new Event("change")); await flushPromises(); const folder = inputForSetting(tab, "Saved note folder"); if (!folder) throw new Error("Missing archive export folder input"); - expect(folder.getAttribute("aria-label")).toBeNull(); - expect(folder.type).toBe("text"); folder.value = "Saved Threads"; folder.dispatchEvent(new Event("blur")); await flushPromises(); const filename = inputForSetting(tab, "Saved note filename"); if (!filename) throw new Error("Missing archive export filename input"); - expect(filename.getAttribute("aria-label")).toBeNull(); - expect(filename.type).toBe("text"); filename.value = "{{date}} {{title}}.md"; filename.dispatchEvent(new Event("blur")); await flushPromises(); const tags = inputForSetting(tab, "Saved note tags"); if (!tags) throw new Error("Missing archive export tags input"); - expect(tags.getAttribute("aria-label")).toBeNull(); - expect(tags.type).toBe("text"); tags.value = "codex, archive"; tags.dispatchEvent(new Event("blur")); await flushPromises(); @@ -469,296 +453,6 @@ describe("settings tab", () => { expect(unsubscribe).toHaveBeenCalledOnce(); }); - it("publishes archived thread catalog updates to the archived settings section", () => { - let emitArchived = (_threads: readonly Thread[]): void => { - throw new Error("Expected archived thread observer"); - }; - const display = vi.fn(); - const controller = new SettingsDynamicSectionsController( - settingsTabHost({ - observeArchived: (listener) => { - emitArchived = (threads) => { - listener({ value: threads, error: null, isFetching: false } satisfies ObservedResult); - }; - return () => undefined; - }, - }), - { display, notify: vi.fn() }, - ); - - controller.activate(); - emitArchived([panelThread({ id: "thread-archived", preview: "Archived elsewhere", archived: true })]); - - expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["Archived elsewhere"]); - expect(controller.snapshot().archivedThreadsLifecycle.kind).toBe("loaded"); - expect(display).toHaveBeenCalledOnce(); - }); - - it("ignores stale dynamic sections refresh results after a newer refresh completes", async () => { - const firstModels = deferred(); - const firstClient = settingsClient(); - const secondClient = settingsClient({ models: [model("gpt-new")] }); - useShortLivedClients(firstClient, secondClient); - const refreshModels = vi - .fn() - .mockReturnValueOnce(firstModels.promise) - .mockResolvedValueOnce(modelMetadataFromCatalogModels([model("gpt-new")])); - const refreshArchived = vi - .fn() - .mockResolvedValueOnce([panelThread({ id: "thread-old", preview: "Old", archived: true })]) - .mockResolvedValueOnce([panelThread({ id: "thread-new", preview: "New", archived: true })]); - const controller = new SettingsDynamicSectionsController(settingsTabHost({ refreshModels, refreshArchived }), { - display: vi.fn(), - notify: vi.fn(), - }); - - const firstRefresh = controller.refreshDynamicSections(); - await flushPromises(); - await controller.refreshDynamicSections(); - - expect(controller.snapshot().models.map((item) => item.model)).toEqual(["gpt-new"]); - expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["New"]); - - firstModels.resolve(modelMetadataFromCatalogModels([model("gpt-old")])); - await firstRefresh; - - expect(controller.snapshot().models.map((item) => item.model)).toEqual(["gpt-new"]); - expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["New"]); - }); - - it("does not display dynamic refresh results after disposal", async () => { - const models = deferred(); - const display = vi.fn(); - const controller = new SettingsDynamicSectionsController( - settingsTabHost({ - refreshModels: vi.fn(() => models.promise), - refreshArchived: vi.fn().mockResolvedValue([]), - }), - { display, notify: vi.fn() }, - ); - - const refresh = controller.refreshDynamicSections(); - await flushPromises(); - display.mockClear(); - controller.dispose(); - models.resolve([]); - await refresh; - - expect(display).not.toHaveBeenCalled(); - }); - - it("ignores stale hook reload results after a newer dynamic operation completes", async () => { - const staleHooks = deferred<{ - data: { cwd: string; hooks: CatalogHookMetadata[]; warnings: string[]; errors: unknown[] }[]; - }>(); - const initialClient = settingsClient({ - hooks: [hook({ key: "hook-initial", command: "initial hook", currentHash: "initialhash" })], - }); - const trustClient = settingsRequestClient({ - "config/batchWrite": vi.fn().mockResolvedValue({}), - }); - const staleClient = settingsClient(); - staleClient.requestHandlers["hooks/list"]?.mockReturnValue(staleHooks.promise); - const newerClient = settingsClient({ - hooks: [hook({ key: "hook-new", command: "new hook", currentHash: "newhash" })], - }); - useShortLivedClients(initialClient, trustClient, staleClient, newerClient); - const controller = new SettingsDynamicSectionsController(settingsTabHost(), { display: vi.fn(), notify: vi.fn() }); - - await controller.refreshDynamicSections(); - const initialHook = controller.snapshot().hooks[0]; - if (!initialHook) throw new Error("Expected initial hook to load"); - const staleReload = controller.trustHook(initialHook); - await flushPromises(); - await controller.refreshDynamicSections(); - - expect(controller.snapshot().hooks.map((hook) => hook.currentHash)).toEqual(["newhash"]); - - staleHooks.resolve({ - data: [{ cwd: "/vault", hooks: [hook({ key: "hook-old", command: "old hook", currentHash: "oldhash" })], warnings: [], errors: [] }], - }); - await staleReload; - - expect(controller.snapshot().hooks.map((item) => item.currentHash)).toEqual(["newhash"]); - }); - - it("keeps full refresh models and archived threads current when hook operations overlap", async () => { - const models = deferred(); - const fullRefreshClient = settingsClient({ - hooks: [hook({ key: "hook-full", command: "full refresh hook", currentHash: "fullhash" })], - }); - const trustClient = settingsRequestClient({ - "config/batchWrite": vi.fn().mockResolvedValue({}), - }); - const hookReloadClient = settingsClient({ - hooks: [hook({ key: "hook-new", command: "new hook", currentHash: "newhash" })], - }); - useShortLivedClients(fullRefreshClient, trustClient, hookReloadClient); - const refreshModels = vi.fn().mockReturnValue(models.promise); - const refreshArchived = vi.fn().mockResolvedValue([panelThread({ id: "thread-full", preview: "Full archived", archived: true })]); - const controller = new SettingsDynamicSectionsController(settingsTabHost({ refreshModels, refreshArchived }), { - display: vi.fn(), - notify: vi.fn(), - }); - - const fullRefresh = controller.refreshDynamicSections(); - await flushPromises(); - await controller.trustHook(hook({ key: "hook-initial", command: "initial hook", currentHash: "initialhash" })); - - models.resolve(modelMetadataFromCatalogModels([model("gpt-refreshed")])); - await fullRefresh; - - const snapshot = controller.snapshot(); - expect(snapshot.models.map((item) => item.model)).toEqual(["gpt-refreshed"]); - expect(snapshot.modelsLifecycle.kind).toBe("loaded"); - expect(snapshot.archivedThreads.map((thread) => thread.preview)).toEqual(["Full archived"]); - expect(snapshot.archivedThreadsLifecycle.kind).toBe("loaded"); - expect(snapshot.hooks.map((item) => item.currentHash)).toEqual(["newhash"]); - }); - - it("ignores stale archived restore results after a newer dynamic operation completes", async () => { - const staleRestore = deferred<{ thread: ThreadRecord }>(); - const applyThreadCatalogEvent = vi.fn(); - const initialClient = settingsClient(); - const restoreClient = settingsRequestClient({ - "thread/unarchive": vi.fn(() => staleRestore.promise), - }); - const newerClient = settingsClient(); - useShortLivedClients(initialClient, restoreClient, newerClient); - const refreshArchived = vi - .fn() - .mockResolvedValueOnce([panelThread({ id: "thread-old", preview: "Old archived", archived: true })]) - .mockResolvedValueOnce([panelThread({ id: "thread-new", preview: "New archived", archived: true })]); - const controller = new SettingsDynamicSectionsController(settingsTabHost({ applyThreadCatalogEvent, refreshArchived }), { - display: vi.fn(), - notify: vi.fn(), - }); - - await controller.refreshDynamicSections(); - const restore = controller.restoreArchivedThread("thread-old"); - await flushPromises(); - await controller.refreshDynamicSections(); - - expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["New archived"]); - - staleRestore.resolve({ thread: appServerThread({ id: "thread-old", preview: "Restored old" }) }); - await restore; - - expect(applyThreadCatalogEvent).not.toHaveBeenCalled(); - expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["New archived"]); - }); - - it("records restored archived threads in the active catalog", async () => { - const notify = vi.fn(); - const applyThreadCatalogEvent = vi.fn(); - const initialClient = settingsClient(); - const restoreClient = settingsRequestClient({ - "thread/unarchive": vi.fn().mockResolvedValue({ thread: appServerThread({ id: "thread-old", preview: "Restored old" }) }), - }); - useShortLivedClients(initialClient, restoreClient); - const controller = new SettingsDynamicSectionsController( - settingsTabHost({ - archivedThreads: [panelThread({ id: "thread-old", preview: "Old archived", archived: true })], - applyThreadCatalogEvent, - }), - { display: vi.fn(), notify }, - ); - - await controller.refreshDynamicSections(); - await controller.restoreArchivedThread("thread-old"); - - const snapshot = controller.snapshot(); - expect(snapshot.archivedThreads).toEqual([]); - expect(snapshot.archivedThreadsLifecycle.kind).toBe("loaded"); - expect(applyThreadCatalogEvent).toHaveBeenCalledWith({ - type: "thread-restored", - thread: expect.objectContaining({ id: "thread-old", preview: "Restored old", archived: false }), - }); - expect(notify).not.toHaveBeenCalledWith("Could not restore archived Codex thread."); - }); - - it("displays restored archived thread state after recording the active catalog event", async () => { - const snapshots: SettingsDynamicSectionsSnapshot[] = []; - const initialClient = settingsClient(); - const restoreClient = settingsRequestClient({ - "thread/unarchive": vi.fn().mockResolvedValue({ thread: appServerThread({ id: "thread-old", preview: "Restored old" }) }), - }); - useShortLivedClients(initialClient, restoreClient); - const controllerRef: { current: SettingsDynamicSectionsController | null } = { current: null }; - let emitArchived = (_threads: readonly Thread[]): void => undefined; - const controller = new SettingsDynamicSectionsController( - settingsTabHost({ - archivedThreads: [panelThread({ id: "thread-old", preview: "Old archived", archived: true })], - observeArchived: (listener) => { - emitArchived = (threads) => { - listener({ value: threads, error: null, isFetching: false } satisfies ObservedResult); - }; - return () => undefined; - }, - applyThreadCatalogEvent: () => { - emitArchived([]); - }, - }), - { - display: () => { - const snapshot = controllerRef.current?.snapshot(); - if (snapshot) snapshots.push(snapshot); - }, - notify: vi.fn(), - }, - ); - controllerRef.current = controller; - - await controller.refreshDynamicSections(); - snapshots.length = 0; - await controller.restoreArchivedThread("thread-old"); - - expect(snapshots.at(-1)?.archivedThreads).toEqual([]); - expect(snapshots.at(-1)?.archivedThreadsLifecycle.kind).toBe("loaded"); - expect(snapshots.at(-1)?.archivedThreadsLifecycle.status).toBe('Restored "Restored old".'); - }); - - it("displays deleted archived thread status after recording the catalog event", async () => { - const snapshots: SettingsDynamicSectionsSnapshot[] = []; - const initialClient = settingsClient(); - const deleteClient = settingsRequestClient({ - "thread/delete": vi.fn().mockResolvedValue({}), - }); - useShortLivedClients(initialClient, deleteClient); - const controllerRef: { current: SettingsDynamicSectionsController | null } = { current: null }; - let emitArchived = (_threads: readonly Thread[]): void => undefined; - const controller = new SettingsDynamicSectionsController( - settingsTabHost({ - archivedThreads: [panelThread({ id: "thread-old", preview: "Old archived", archived: true })], - observeArchived: (listener) => { - emitArchived = (threads) => { - listener({ value: threads, error: null, isFetching: false } satisfies ObservedResult); - }; - return () => undefined; - }, - applyThreadCatalogEvent: () => { - emitArchived([]); - }, - }), - { - display: () => { - const snapshot = controllerRef.current?.snapshot(); - if (snapshot) snapshots.push(snapshot); - }, - notify: vi.fn(), - }, - ); - controllerRef.current = controller; - - await controller.refreshDynamicSections(); - snapshots.length = 0; - await controller.deleteArchivedThread("thread-old"); - - expect(snapshots.at(-1)?.archivedThreads).toEqual([]); - expect(snapshots.at(-1)?.archivedThreadsLifecycle.kind).toBe("loaded"); - expect(snapshots.at(-1)?.archivedThreadsLifecycle.status).toBe('Deleted "Old archived".'); - }); - it("uses cached models initially and publishes refreshed models", async () => { const fetchModels = vi.fn().mockResolvedValue(modelMetadataFromCatalogModels([model("gpt-5.5")])); const client = settingsClient({ models: [model("gpt-5.5")] }); @@ -815,8 +509,6 @@ describe("settings tab", () => { expect(selectOptions(tab, "Automatic thread naming", 1)).toEqual(["Codex default", "saved-custom-effort (saved)", "extreme"]); expect(selectOptions(tab, "Selection rewrite", 1)).toEqual(["Codex default", "extreme"]); - expect(selectForSetting(tab, "Automatic thread naming")?.classList.contains("dropdown")).toBe(true); - expect(selectForSetting(tab, "Selection rewrite")?.classList.contains("dropdown")).toBe(true); }); it("keeps successful sections when one dynamic sections request fails", async () => { @@ -892,7 +584,7 @@ describe("settings tab", () => { expect(requestMethods(client)).not.toContain("thread/delete"); }); - it("keeps the settings shell mounted while dynamic sections update", async () => { + it("preserves an in-progress Codex executable edit while dynamic sections update", async () => { const saveSettings = vi.fn().mockResolvedValue(undefined); const client = settingsClient(); useShortLivedClients(client); @@ -902,69 +594,35 @@ describe("settings tab", () => { }); tab.display(); + document.body.appendChild(tab.containerEl); await flushPromises(); const codexInput = inputForSetting(tab, "Codex executable"); - const shortcut = selectForSetting(tab, "Send shortcut"); const archiveToggle = inputForSetting(tab, "Save note by default"); - if (!codexInput || !shortcut || !archiveToggle) throw new Error("Missing settings controls"); + if (!codexInput || !archiveToggle) throw new Error("Missing settings controls"); + + codexInput.focus(); + codexInput.value = "/opt/codex-draft"; + codexInput.setSelectionRange(5, 10); archiveToggle.checked = true; archiveToggle.dispatchEvent(new Event("change")); await flushPromises(); expect(saveSettings).toHaveBeenCalledOnce(); - expect(inputForSetting(tab, "Codex executable")).toBe(codexInput); - expect(selectForSetting(tab, "Send shortcut")).toBe(shortcut); + expect(inputForSetting(tab, "Codex executable")?.value).toBe("/opt/codex-draft"); + expect(document.activeElement).toBe(inputForSetting(tab, "Codex executable")); + expect(inputForSetting(tab, "Codex executable")?.selectionStart).toBe(5); + expect(inputForSetting(tab, "Codex executable")?.selectionEnd).toBe(10); clickButtonByLabel(tab, "Delete thread"); - expect(inputForSetting(tab, "Codex executable")).toBe(codexInput); - expect(selectForSetting(tab, "Send shortcut")).toBe(shortcut); + expect(inputForSetting(tab, "Codex executable")?.value).toBe("/opt/codex-draft"); + expect(document.activeElement).toBe(inputForSetting(tab, "Codex executable")); + expect(inputForSetting(tab, "Codex executable")?.selectionStart).toBe(5); + expect(inputForSetting(tab, "Codex executable")?.selectionEnd).toBe(10); expect(tab.containerEl.querySelector(".codex-panel-settings__archived-row--delete-confirming")).not.toBeNull(); - }); - - it("rerenders only the changed dynamic section for archive and hook actions", async () => { - const client = settingsClient({ - hooks: [hook({ key: "hook-1", command: "node hook.js", currentHash: "abc123", enabled: true })], - }); - useShortLivedClients(client); - const hookUpdate = deferred(); - client.requestHandlers["config/batchWrite"]?.mockReturnValue(hookUpdate.promise); - const tab = newSettingsTab({ archivedThreads: [panelThread({ id: "thread-archived", preview: "Archived thread", archived: true })] }); - - tab.display(); - await flushPromises(); - - const helperSection = dynamicSection(tab, "codex-panel-settings__helper-section"); - const archivedSection = dynamicSection(tab, "codex-panel-settings__archived-section"); - const hookSection = dynamicSection(tab, "codex-panel-settings__hook-section"); - const archiveToggle = inputForSetting(tab, "Save note by default"); - if (!archiveToggle) throw new Error("Missing archive toggle"); - - archiveToggle.checked = true; - archiveToggle.dispatchEvent(new Event("change")); - await flushPromises(); - - expect(dynamicSection(tab, "codex-panel-settings__helper-section")).toBe(helperSection); - expect(dynamicSection(tab, "codex-panel-settings__hook-section")).toBe(hookSection); - - clickButtonByLabel(tab, "Delete thread"); - - expect(dynamicSection(tab, "codex-panel-settings__helper-section")).toBe(helperSection); - expect(dynamicSection(tab, "codex-panel-settings__hook-section")).toBe(hookSection); - - clickButtonByText(tab, "Disable"); - await flushPromises(); - - expectRequestTimes(client, "config/batchWrite", 1); - expect(dynamicSection(tab, "codex-panel-settings__hook-section")).not.toBeNull(); - expect(tab.containerEl.querySelector(".codex-panel-settings__hook-list")).not.toBeNull(); - expect(dynamicSection(tab, "codex-panel-settings__helper-section")).toBe(helperSection); - expect(dynamicSection(tab, "codex-panel-settings__archived-section")).toBe(archivedSection); - - hookUpdate.resolve(undefined); - await flushPromises(); + tab.containerEl.remove(); }); it("permanently deletes an archived thread from the confirmed settings row", async () => { @@ -996,256 +654,10 @@ describe("settings tab", () => { }); }); -function panelThread(overrides: Partial = {}): Thread { - return { - id: "019e0182-cb70-7a72-ab48-8bc9d0b0d781", - preview: "Preview", - createdAt: 1, - updatedAt: 1, - name: null, - archived: false, - provenance: { kind: "interactive" }, - ...overrides, - }; -} - -function appServerThread(overrides: Partial = {}): ThreadRecord { - return { - id: "019e0182-cb70-7a72-ab48-8bc9d0b0d781", - sessionId: "019e0182-cb70-7a72-ab48-8bc9d0b0d781", - forkedFromId: null, - parentThreadId: null, - preview: "Preview", - ephemeral: false, - modelProvider: "openai", - createdAt: 1, - updatedAt: 1, - status: { type: "idle" }, - path: null, - cwd: "/tmp", - cliVersion: "codex-cli 0.0.0", - source: "unknown", - threadSource: null, - agentNickname: null, - agentRole: null, - gitInfo: null, - name: null, - turns: [], - ...overrides, - }; -} - -function model(modelId: string, isDefault = false, hidden = false, efforts: ReasoningEffort[] = ["medium"]): CatalogModel { - return { - id: `${modelId}-id`, - model: modelId, - upgrade: null, - upgradeInfo: null, - availabilityNux: null, - displayName: `Display ${modelId}`, - description: "", - isDefault, - hidden, - supportedReasoningEfforts: efforts.map((reasoningEffort) => ({ reasoningEffort, description: reasoningEffort })), - defaultReasoningEffort: "medium", - inputModalities: ["text"], - supportsPersonality: false, - serviceTiers: [], - defaultServiceTier: null, - } satisfies CatalogModel; -} - -function hook(overrides: Partial = {}): CatalogHookMetadata { - return { - key: "hook-key", - eventName: "postToolUse", - handlerType: "command", - matcher: "apply_patch", - command: "node hook.js", - timeoutSec: 10n, - statusMessage: null, - sourcePath: "/vault/.codex/hooks.json", - source: "project", - pluginId: null, - displayOrder: 0n, - enabled: true, - isManaged: false, - currentHash: "hash", - trustStatus: "trusted", - ...overrides, - }; -} - -function settingsClient( - options: { models?: CatalogModel[]; hooks?: CatalogHookMetadata[]; hooksError?: Error; threads?: ThreadRecord[] } = {}, -) { - return settingsRequestClient({ - "model/list": vi.fn().mockResolvedValue({ data: options.models ?? [model("gpt-5.4")] }), - "hooks/list": vi.fn().mockImplementation(() => { - if (options.hooksError) return Promise.reject(options.hooksError); - return Promise.resolve({ - data: [ - { - cwd: "/vault", - hooks: options.hooks ?? [], - warnings: [], - errors: [], - }, - ], - }); - }), - "thread/list": vi.fn().mockResolvedValue({ data: options.threads ?? [appServerThread({ preview: "Archived" })] }), - "config/batchWrite": vi.fn().mockResolvedValue({}), - "thread/unarchive": vi.fn().mockResolvedValue({ thread: appServerThread({ preview: "Restored" }) }), - "thread/delete": vi.fn().mockResolvedValue({}), - }); -} - -type SettingsRequestClient = AppServerClient & { - request: ReturnType unknown>>; - requestHandlers: Record unknown>>>; -}; - -function useShortLivedClients(...clients: SettingsRequestClient[]): void { - const runWithClient = (client: SettingsRequestClient, operation: (client: AppServerClient) => Promise) => operation(client); - if (clients.length === 1) { - const [client] = clients; - if (!client) throw new Error("Expected a short-lived client."); - withShortLivedAppServerClientMock.mockImplementation( - (_codexPath: string, _cwd: string, operation: (client: AppServerClient) => Promise) => runWithClient(client, operation), - ); - return; - } - for (const client of clients) { - withShortLivedAppServerClientMock.mockImplementationOnce( - (_codexPath: string, _cwd: string, operation: (client: AppServerClient) => Promise) => runWithClient(client, operation), - ); - } -} - -function settingsRequestClient( - handlers: Record unknown>>>, -): SettingsRequestClient { - return { - requestHandlers: handlers, - request: vi.fn((method: string, params: unknown) => { - const handler = handlers[method]; - if (!handler) throw new Error(`Unexpected app-server request: ${method}`); - return handler(params); - }), - } as unknown as SettingsRequestClient; -} - -function requestMethods(client: SettingsRequestClient): string[] { - return client.request.mock.calls.map(([method]) => method); -} - -function expectRequestTimes(client: SettingsRequestClient, method: string, times: number): void { - expect(requestMethods(client).filter((calledMethod) => calledMethod === method)).toHaveLength(times); -} - -function newSettingsTab( - options: { - saveSettings?: (settings: CodexPanelSettings) => Promise; - prepareAppServerContextChange?: () => void; - sendShortcut?: "enter" | "mod-enter"; - modelsSnapshot?: ModelMetadata[]; - fetchModels?: () => Promise; - refreshModels?: () => Promise; - observeModels?: SettingsDynamicDataAccess["observeModelsResult"]; - refreshOpenViews?: () => void; - archivedThreads?: Thread[]; - archivedSnapshot?: Thread[] | null; - refreshArchived?: () => Promise; - observeArchived?: SettingsDynamicDataAccess["observeArchivedThreadsResult"]; - applyThreadCatalogEvent?: (event: ThreadCatalogEvent) => void; - dynamicData?: SettingsDynamicDataAccess; - settings?: Partial<{ - threadNamingModel: string | null; - threadNamingEffort: string | null; - rewriteSelectionModel: string | null; - rewriteSelectionEffort: string | null; - }>; - } = {}, -): CodexPanelSettingTab { +function newSettingsTab(options: SettingsTabHostOptions = {}): CodexPanelSettingTab { return new CodexPanelSettingTab({} as never, {} as never, settingsTabHost(options)); } -function settingsTabHost( - options: { - saveSettings?: (settings: CodexPanelSettings) => Promise; - prepareAppServerContextChange?: () => void; - sendShortcut?: "enter" | "mod-enter"; - modelsSnapshot?: ModelMetadata[]; - fetchModels?: () => Promise; - refreshModels?: () => Promise; - observeModels?: SettingsDynamicDataAccess["observeModelsResult"]; - refreshOpenViews?: () => void; - archivedThreads?: Thread[]; - archivedSnapshot?: Thread[] | null; - refreshArchived?: () => Promise; - observeArchived?: SettingsDynamicDataAccess["observeArchivedThreadsResult"]; - applyThreadCatalogEvent?: (event: ThreadCatalogEvent) => void; - dynamicData?: SettingsDynamicDataAccess; - settings?: Partial<{ - threadNamingModel: string | null; - threadNamingEffort: string | null; - rewriteSelectionModel: string | null; - rewriteSelectionEffort: string | null; - }>; - } = {}, -): CodexPanelSettingTabHost { - const defaultArchivedThreads = [panelThread({ id: "thread-archived", preview: "Archived thread", archived: true })]; - const settings = { - ...DEFAULT_SETTINGS, - threadNamingModel: options.settings?.threadNamingModel ?? null, - threadNamingEffort: options.settings?.threadNamingEffort ?? null, - rewriteSelectionModel: options.settings?.rewriteSelectionModel ?? null, - rewriteSelectionEffort: options.settings?.rewriteSelectionEffort ?? null, - sendShortcut: options.sendShortcut ?? "enter", - }; - const appServerQueries = { - modelsSnapshot: vi.fn(() => options.modelsSnapshot ?? []), - fetchModels: options.fetchModels ?? vi.fn().mockResolvedValue(options.modelsSnapshot ?? []), - refreshModels: options.refreshModels ?? vi.fn().mockResolvedValue(options.modelsSnapshot ?? []), - observeModelsResult: options.observeModels ?? vi.fn(() => () => undefined), - }; - const threadCatalog = { - archivedSnapshot: vi.fn(() => options.archivedSnapshot ?? null), - refreshArchived: options.refreshArchived ?? vi.fn().mockResolvedValue(options.archivedThreads ?? defaultArchivedThreads), - observeArchived: options.observeArchived ?? vi.fn(() => () => undefined), - apply: options.applyThreadCatalogEvent ?? vi.fn(), - }; - return { - settings, - dynamicData: - options.dynamicData ?? - createSettingsAppServerDynamicData({ - vaultPath: "/vault", - clientAccess: { - withClient: (operation: (client: AppServerClient) => Promise, clientOptions?: AppServerClientAccessOptions) => - withShortLivedAppServerClientMock(settings.codexPath, "/vault", operation, clientOptions) as Promise, - }, - appServerQueries, - threadCatalog, - }), - publishSettings: async (nextSettings) => { - const previousSettings = { ...settings }; - await (options.saveSettings ?? vi.fn().mockResolvedValue(undefined))(nextSettings); - const appServerContextReplaced = previousSettings.codexPath !== nextSettings.codexPath; - if (appServerContextReplaced) options.prepareAppServerContextChange?.(); - Object.assign(settings, nextSettings); - if (appServerContextReplaced || previousSettings.showToolbar !== nextSettings.showToolbar) options.refreshOpenViews?.(); - return { appServerContextReplaced }; - }, - }; -} - -async function flushPromises(): Promise { - await new Promise((resolve) => setImmediate(resolve)); - await new Promise((resolve) => setImmediate(resolve)); -} - function settingNames(tab: CodexPanelSettingTab): string[] { return Array.from(settingsSectionRoots(tab)).flatMap((element) => { if (element.classList.contains("codex-panel-settings__header")) return []; @@ -1299,12 +711,6 @@ function clickButtonByLabel(tab: CodexPanelSettingTab, label: string): void { buttonByLabel(tab, label).click(); } -function clickButtonByText(tab: CodexPanelSettingTab, text: string): void { - const button = Array.from(tab.containerEl.querySelectorAll("button")).find((element) => element.textContent === text); - if (!button) throw new Error(`Could not find button text: ${text}`); - button.click(); -} - function pointerDownButtonByLabel(tab: CodexPanelSettingTab, label: string): void { buttonByLabel(tab, label).dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); } @@ -1321,12 +727,6 @@ function inputForSetting(tab: CodexPanelSettingTab, name: string): HTMLInputElem return settingElement(tab, name)?.querySelector("input") ?? null; } -function dynamicSection(tab: CodexPanelSettingTab, className: string): Element { - const section = tab.containerEl.querySelector(`.${className}`); - if (!section) throw new Error(`Missing dynamic section: ${className}`); - return section; -} - function settingElement(tab: CodexPanelSettingTab, name: string): Element | null { return ( Array.from(tab.containerEl.querySelectorAll(".setting-item")).find( diff --git a/tests/settings/test-support.ts b/tests/settings/test-support.ts new file mode 100644 index 00000000..3bae17f7 --- /dev/null +++ b/tests/settings/test-support.ts @@ -0,0 +1,256 @@ +import { expect, vi } from "vitest"; + +import type { AppServerClient } from "../../src/app-server/connection/client"; +import type { AppServerClientAccessOptions } from "../../src/app-server/connection/client-access"; +import type { CatalogHookMetadata, CatalogModel } from "../../src/app-server/protocol/catalog"; +import type { ThreadRecord } from "../../src/app-server/protocol/thread"; +import type { ModelMetadata, ReasoningEffort } from "../../src/domain/catalog/metadata"; +import type { Thread } from "../../src/domain/threads/model"; +import type { ThreadCatalogEvent } from "../../src/features/threads/catalog/thread-catalog"; +import { createSettingsAppServerDynamicData } from "../../src/settings/app-server-dynamic-data"; +import type { SettingsDynamicDataAccess } from "../../src/settings/dynamic-data"; +import type { CodexPanelSettingTabHost } from "../../src/settings/host"; +import { type CodexPanelSettings, DEFAULT_SETTINGS } from "../../src/settings/model"; + +type ShortLivedClientOperation = ( + codexPath: string, + cwd: string, + operation: (client: AppServerClient) => Promise, + options?: AppServerClientAccessOptions, +) => Promise; +type ShortLivedClientMock = ReturnType>; + +let shortLivedClientMock: ShortLivedClientMock | null = null; + +export function setSettingsShortLivedClientMock(mock: unknown): void { + shortLivedClientMock = mock as ShortLivedClientMock; +} + +function currentShortLivedClientMock(): ShortLivedClientMock { + if (!shortLivedClientMock) throw new Error("Expected settings short-lived client mock"); + return shortLivedClientMock; +} + +export function panelThread(overrides: Partial = {}): Thread { + return { + id: "019e0182-cb70-7a72-ab48-8bc9d0b0d781", + preview: "Preview", + createdAt: 1, + updatedAt: 1, + name: null, + archived: false, + provenance: { kind: "interactive" }, + ...overrides, + }; +} + +export function appServerThread(overrides: Partial = {}): ThreadRecord { + return { + id: "019e0182-cb70-7a72-ab48-8bc9d0b0d781", + sessionId: "019e0182-cb70-7a72-ab48-8bc9d0b0d781", + forkedFromId: null, + parentThreadId: null, + preview: "Preview", + ephemeral: false, + modelProvider: "openai", + createdAt: 1, + updatedAt: 1, + status: { type: "idle" }, + path: null, + cwd: "/tmp", + cliVersion: "codex-cli 0.0.0", + source: "unknown", + threadSource: null, + agentNickname: null, + agentRole: null, + gitInfo: null, + name: null, + turns: [], + ...overrides, + }; +} + +export function model(modelId: string, isDefault = false, hidden = false, efforts: ReasoningEffort[] = ["medium"]): CatalogModel { + return { + id: `${modelId}-id`, + model: modelId, + upgrade: null, + upgradeInfo: null, + availabilityNux: null, + displayName: `Display ${modelId}`, + description: "", + isDefault, + hidden, + supportedReasoningEfforts: efforts.map((reasoningEffort) => ({ reasoningEffort, description: reasoningEffort })), + defaultReasoningEffort: "medium", + inputModalities: ["text"], + supportsPersonality: false, + serviceTiers: [], + defaultServiceTier: null, + } satisfies CatalogModel; +} + +export function hook(overrides: Partial = {}): CatalogHookMetadata { + return { + key: "hook-key", + eventName: "postToolUse", + handlerType: "command", + matcher: "apply_patch", + command: "node hook.js", + timeoutSec: 10n, + statusMessage: null, + sourcePath: "/vault/.codex/hooks.json", + source: "project", + pluginId: null, + displayOrder: 0n, + enabled: true, + isManaged: false, + currentHash: "hash", + trustStatus: "trusted", + ...overrides, + }; +} + +export function settingsClient( + options: { models?: CatalogModel[]; hooks?: CatalogHookMetadata[]; hooksError?: Error; threads?: ThreadRecord[] } = {}, +): SettingsRequestClient { + return settingsRequestClient({ + "model/list": vi.fn().mockResolvedValue({ data: options.models ?? [model("gpt-5.4")] }), + "hooks/list": vi.fn().mockImplementation(() => { + if (options.hooksError) return Promise.reject(options.hooksError); + return Promise.resolve({ + data: [ + { + cwd: "/vault", + hooks: options.hooks ?? [], + warnings: [], + errors: [], + }, + ], + }); + }), + "thread/list": vi.fn().mockResolvedValue({ data: options.threads ?? [appServerThread({ preview: "Archived" })] }), + "config/batchWrite": vi.fn().mockResolvedValue({}), + "thread/unarchive": vi.fn().mockResolvedValue({ thread: appServerThread({ preview: "Restored" }) }), + "thread/delete": vi.fn().mockResolvedValue({}), + }); +} + +export type SettingsRequestClient = AppServerClient & { + request: ReturnType unknown>>; + requestHandlers: Record unknown>>>; +}; + +export function useShortLivedClients(...clients: SettingsRequestClient[]): void { + const mock = currentShortLivedClientMock(); + const runWithClient = (client: SettingsRequestClient, operation: (client: AppServerClient) => Promise) => operation(client); + if (clients.length === 1) { + const [client] = clients; + if (!client) throw new Error("Expected a short-lived client."); + mock.mockImplementation((_codexPath: string, _cwd: string, operation: (client: AppServerClient) => Promise) => + runWithClient(client, operation), + ); + return; + } + for (const client of clients) { + mock.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: AppServerClient) => Promise) => + runWithClient(client, operation), + ); + } +} + +export function settingsRequestClient( + handlers: Record unknown>>>, +): SettingsRequestClient { + return { + requestHandlers: handlers, + request: vi.fn((method: string, params: unknown) => { + const handler = handlers[method]; + if (!handler) throw new Error(`Unexpected app-server request: ${method}`); + return handler(params); + }), + } as unknown as SettingsRequestClient; +} + +export function requestMethods(client: SettingsRequestClient): string[] { + return client.request.mock.calls.map(([method]) => method); +} + +export function expectRequestTimes(client: SettingsRequestClient, method: string, times: number): void { + expect(requestMethods(client).filter((calledMethod) => calledMethod === method)).toHaveLength(times); +} + +export interface SettingsTabHostOptions { + saveSettings?: (settings: CodexPanelSettings) => Promise; + prepareAppServerContextChange?: () => void; + sendShortcut?: "enter" | "mod-enter"; + modelsSnapshot?: ModelMetadata[]; + fetchModels?: () => Promise; + refreshModels?: () => Promise; + observeModels?: SettingsDynamicDataAccess["observeModelsResult"]; + refreshOpenViews?: () => void; + archivedThreads?: Thread[]; + archivedSnapshot?: Thread[] | null; + refreshArchived?: () => Promise; + observeArchived?: SettingsDynamicDataAccess["observeArchivedThreadsResult"]; + applyThreadCatalogEvent?: (event: ThreadCatalogEvent) => void; + dynamicData?: SettingsDynamicDataAccess; + settings?: Partial<{ + threadNamingModel: string | null; + threadNamingEffort: string | null; + rewriteSelectionModel: string | null; + rewriteSelectionEffort: string | null; + }>; +} + +export function settingsTabHost(options: SettingsTabHostOptions = {}): CodexPanelSettingTabHost { + const defaultArchivedThreads = [panelThread({ id: "thread-archived", preview: "Archived thread", archived: true })]; + const settings = { + ...DEFAULT_SETTINGS, + threadNamingModel: options.settings?.threadNamingModel ?? null, + threadNamingEffort: options.settings?.threadNamingEffort ?? null, + rewriteSelectionModel: options.settings?.rewriteSelectionModel ?? null, + rewriteSelectionEffort: options.settings?.rewriteSelectionEffort ?? null, + sendShortcut: options.sendShortcut ?? "enter", + }; + const appServerQueries = { + modelsSnapshot: vi.fn(() => options.modelsSnapshot ?? []), + fetchModels: options.fetchModels ?? vi.fn().mockResolvedValue(options.modelsSnapshot ?? []), + refreshModels: options.refreshModels ?? vi.fn().mockResolvedValue(options.modelsSnapshot ?? []), + observeModelsResult: options.observeModels ?? vi.fn(() => () => undefined), + }; + const threadCatalog = { + archivedSnapshot: vi.fn(() => options.archivedSnapshot ?? null), + refreshArchived: options.refreshArchived ?? vi.fn().mockResolvedValue(options.archivedThreads ?? defaultArchivedThreads), + observeArchived: options.observeArchived ?? vi.fn(() => () => undefined), + apply: options.applyThreadCatalogEvent ?? vi.fn(), + }; + return { + settings, + dynamicData: + options.dynamicData ?? + createSettingsAppServerDynamicData({ + vaultPath: "/vault", + clientAccess: { + withClient: (operation: (client: AppServerClient) => Promise, clientOptions?: AppServerClientAccessOptions) => + currentShortLivedClientMock()(settings.codexPath, "/vault", operation, clientOptions) as Promise, + }, + appServerQueries, + threadCatalog, + }), + publishSettings: async (nextSettings) => { + const previousSettings = { ...settings }; + await (options.saveSettings ?? vi.fn().mockResolvedValue(undefined))(nextSettings); + const appServerContextReplaced = previousSettings.codexPath !== nextSettings.codexPath; + if (appServerContextReplaced) options.prepareAppServerContextChange?.(); + Object.assign(settings, nextSettings); + if (appServerContextReplaced || previousSettings.showToolbar !== nextSettings.showToolbar) options.refreshOpenViews?.(); + return { appServerContextReplaced }; + }, + }; +} + +export async function flushPromises(): Promise { + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); +} diff --git a/tests/shared/obsidian/components.test.tsx b/tests/shared/obsidian/components.test.tsx new file mode 100644 index 00000000..c7239b6e --- /dev/null +++ b/tests/shared/obsidian/components.test.tsx @@ -0,0 +1,52 @@ +// @vitest-environment jsdom + +import { h } from "preact"; +import { describe, expect, it, vi } from "vitest"; +import { renderUiRoot } from "../../../src/shared/dom/preact-root.dom"; +import { ObsidianToolbarAction } from "../../../src/shared/obsidian/components.obsidian"; +import { installObsidianDomShims } from "../../support/dom"; + +installObsidianDomShims(); + +describe("Obsidian UI components", () => { + it("renders toolbar actions with Obsidian native structure and interaction semantics", () => { + const parent = document.createElement("div"); + const onClick = vi.fn(); + + renderUiRoot( + parent, + h(ObsidianToolbarAction, { + icon: "refresh-cw", + label: "Refresh threads", + className: "clickable-icon nav-action-button", + onClick, + }), + ); + + const action = parent.querySelector('[aria-label="Refresh threads"]'); + if (!action) throw new Error("Expected toolbar action"); + expect(action.tagName).toBe("DIV"); + expect(action.getAttribute("role")).toBeNull(); + expect(action.classList.contains("clickable-icon")).toBe(true); + expect(action.classList.contains("nav-action-button")).toBe(true); + action.click(); + expect(onClick).toHaveBeenCalledOnce(); + + renderUiRoot( + parent, + h(ObsidianToolbarAction, { + icon: "refresh-cw", + label: "Refresh threads", + className: "clickable-icon nav-action-button", + disabled: true, + onClick, + }), + ); + + const disabledAction = parent.querySelector('[aria-label="Refresh threads"]'); + if (!disabledAction) throw new Error("Expected disabled toolbar action"); + expect(disabledAction.classList.contains("is-disabled")).toBe(true); + disabledAction.click(); + expect(onClick).toHaveBeenCalledOnce(); + }); +});