From 47c5ce8e0dc5e2feb088dcf8d58995a4a1cbd59d Mon Sep 17 00:00:00 2001 From: murashit Date: Sat, 30 May 2026 09:09:43 +0900 Subject: [PATCH] Organize test structure --- .../chat/chat-message-renderer.test.ts | 2 +- .../features/chat/ui/message-stream.test.tsx | 2881 ----------------- .../blocks-and-messages.test.tsx | 1242 +++++++ .../message-stream/pending-requests.test.tsx | 530 +++ .../features/chat/ui/message-stream/setup.ts | 5 + .../chat/ui/message-stream/test-helpers.tsx | 167 + .../chat/ui/message-stream/work-log.test.tsx | 994 ++++++ tests/features/chat/ui/react-test-helpers.tsx | 24 - .../chat/ui/renderers/composer.test.ts | 194 ++ .../chat/ui/renderers/toolbar.test.ts | 382 +++ .../chat/ui/renderers/turn-diff.test.ts | 174 + tests/features/chat/ui/shell.test.tsx | 2 +- tests/features/chat/ui/view-dom.test.ts | 2 +- tests/features/chat/ui/view-renderers.test.ts | 1000 ------ tests/features/chat/ui/view-scroll.test.ts | 2 +- tests/features/chat/view-connection.test.ts | 2 +- .../selection-rewrite.test.ts | 2 +- tests/features/threads-view/renderer.test.ts | 251 ++ tests/features/threads-view/view.test.ts | 2 +- tests/main.test.ts | 2 +- tests/settings/settings-tab.test.ts | 17 +- tests/shared/diff/unified.test.ts | 29 + .../ui/dom-test-helpers.ts => support/dom.ts} | 0 23 files changed, 3980 insertions(+), 3926 deletions(-) delete mode 100644 tests/features/chat/ui/message-stream.test.tsx create mode 100644 tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx create mode 100644 tests/features/chat/ui/message-stream/pending-requests.test.tsx create mode 100644 tests/features/chat/ui/message-stream/setup.ts create mode 100644 tests/features/chat/ui/message-stream/test-helpers.tsx create mode 100644 tests/features/chat/ui/message-stream/work-log.test.tsx delete mode 100644 tests/features/chat/ui/react-test-helpers.tsx create mode 100644 tests/features/chat/ui/renderers/composer.test.ts create mode 100644 tests/features/chat/ui/renderers/toolbar.test.ts create mode 100644 tests/features/chat/ui/renderers/turn-diff.test.ts delete mode 100644 tests/features/chat/ui/view-renderers.test.ts create mode 100644 tests/features/threads-view/renderer.test.ts create mode 100644 tests/shared/diff/unified.test.ts rename tests/{features/chat/ui/dom-test-helpers.ts => support/dom.ts} (100%) diff --git a/tests/features/chat/chat-message-renderer.test.ts b/tests/features/chat/chat-message-renderer.test.ts index 3c1351b7..86cb5347 100644 --- a/tests/features/chat/chat-message-renderer.test.ts +++ b/tests/features/chat/chat-message-renderer.test.ts @@ -6,7 +6,7 @@ import { TFile } from "obsidian"; import { ChatMessageRenderer } from "../../../src/features/chat/chat-message-renderer"; import { chatReducer, createChatState, type ChatAction, type ChatState, type ChatStateStore } from "../../../src/features/chat/chat-state"; import { bindRenderedWikiLinks, type RenderedMarkdownLinkContext } from "../../../src/features/chat/markdown-message-renderer"; -import { installObsidianDomShims } from "./ui/dom-test-helpers"; +import { installObsidianDomShims } from "../../support/dom"; import { notices } from "../../mocks/obsidian"; installObsidianDomShims(); diff --git a/tests/features/chat/ui/message-stream.test.tsx b/tests/features/chat/ui/message-stream.test.tsx deleted file mode 100644 index cac64f76..00000000 --- a/tests/features/chat/ui/message-stream.test.tsx +++ /dev/null @@ -1,2881 +0,0 @@ -// @vitest-environment jsdom - -import { describe, expect, it, vi } from "vitest"; -import { act, createElement, type ReactNode } from "react"; - -import type { PendingApproval } from "../../../../src/features/chat/approvals/model"; -import type { PendingUserInput } from "../../../../src/features/chat/user-input/model"; -import { pendingRequestMessageNode, type PendingRequestMessageActions } from "../../../../src/features/chat/ui/pending-request-message"; -import type { DisplayItem } from "../../../../src/features/chat/display/types"; -import { implementPlanCandidateFromState } from "../../../../src/features/chat/chat-message-renderer"; -import type { ChatTurnLifecycleState } from "../../../../src/features/chat/chat-state"; -import { messageStreamBlocks as rawMessageStreamBlocks, renderMessageStreamBlocks } from "../../../../src/features/chat/ui/message-stream"; -import { changeInputValue, installObsidianDomShims, topLevelDetailsSummaries } from "./dom-test-helpers"; -import { renderReactRoot, unmountReactRoot } from "../../../../src/shared/ui/react-root"; - -(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; - -installObsidianDomShims(); - -function messageStreamBlocks( - ...args: Parameters -): [ReturnType[number], ...ReturnType] { - const blocks = rawMessageStreamBlocks(...args); - if (blocks.length === 0) throw new Error("Expected at least one message stream block."); - return blocks as [ReturnType[number], ...ReturnType]; -} - -function expectPresent(value: T | null | undefined): T { - if (value === null || value === undefined) throw new Error("Expected value to be present"); - return value; -} - -function setNativeInputValue(input: HTMLInputElement, value: string): void { - const valueDescriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value"); - if (!valueDescriptor?.set) throw new Error("Missing input value setter"); - valueDescriptor.set.call(input, value); -} - -function dispatchComposingInputValue(input: HTMLInputElement, value: string): void { - setNativeInputValue(input, value); - const event = new Event("input", { bubbles: true }); - Object.defineProperty(event, "isComposing", { value: true }); - input.dispatchEvent(event); -} - -function testMessageStreamBlock(key: string, node: ReactNode): ReturnType[number] { - return { key, node }; -} - -function renderMessageBlockElement(block: ReturnType[number]): HTMLElement { - const parent = document.createElement("div"); - act(() => { - renderMessageStreamBlocks(parent, [block]); - }); - const host = expectPresent(parent.querySelector(`[data-codex-panel-block-key="${block.key}"]`)); - return expectPresent(host.firstElementChild as HTMLElement | null); -} - -function renderPendingRequestNode(parent: HTMLElement, ...args: Parameters): void { - renderReactRoot(parent, pendingRequestMessageNode(...args)); -} - -function pendingRequestActions(overrides: Partial = {}): PendingRequestMessageActions { - return { - resolveApproval: vi.fn(), - resolveUserInput: vi.fn(), - cancelUserInput: vi.fn(), - setUserInputDraft: vi.fn(), - ...overrides, - }; -} - -function idleTurnLifecycle(): ChatTurnLifecycleState { - return { kind: "idle" }; -} - -function runningTurnLifecycle(turnId = "turn"): ChatTurnLifecycleState { - return { kind: "running", turnId }; -} - -function startingTurnLifecycle(): ChatTurnLifecycleState { - return { kind: "starting", pendingTurnStart: { anchorItemId: "local-user", promptSubmitHookItemIds: [] } }; -} - -function withMessageContentScrollHeight(scrollHeight: number, fn: () => T): T { - const descriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight"); - Object.defineProperty(HTMLElement.prototype, "scrollHeight", { - configurable: true, - get() { - return this.classList.contains("codex-panel__message-content") ? scrollHeight : 0; - }, - }); - try { - return fn(); - } finally { - if (descriptor) { - Object.defineProperty(HTMLElement.prototype, "scrollHeight", descriptor); - } else { - Reflect.deleteProperty(HTMLElement.prototype, "scrollHeight"); - } - } -} - -describe("message stream block identity and message actions", () => { - it("reuses keyed React message block hosts across rerenders", () => { - const parent = document.createElement("div"); - renderMessageStreamBlocks(parent, [testMessageStreamBlock("one", createElement("section", null, "first"))]); - - const host = expectPresent(parent.querySelector('[data-codex-panel-block-key="one"]')); - expect(host.classList.contains("codex-panel__message-block")).toBe(true); - expect(host.firstElementChild?.tagName).toBe("SECTION"); - expect(host.textContent).toBe("first"); - - renderMessageStreamBlocks(parent, [testMessageStreamBlock("one", createElement("section", null, "still first"))]); - - expect(parent.querySelector('[data-codex-panel-block-key="one"]')).toBe(host); - expect(host.firstElementChild?.tagName).toBe("SECTION"); - expect(host.textContent).toBe("still first"); - - renderMessageStreamBlocks(parent, [testMessageStreamBlock("one", createElement("article", null, "replacement"))]); - - expect(parent.querySelector('[data-codex-panel-block-key="one"]')).toBe(host); - expect(host.firstElementChild?.tagName).toBe("ARTICLE"); - renderMessageStreamBlocks(parent, []); - expect(parent.querySelector('[data-codex-panel-block-key="one"]')).toBeNull(); - unmountReactRoot(parent); - }); - - it("leaves stable ordered React message block hosts in place during repeated renders", () => { - const parent = document.createElement("div"); - renderMessageStreamBlocks(parent, [ - testMessageStreamBlock("one", createElement("section", null, "one")), - testMessageStreamBlock("two", createElement("article", null, "two")), - ]); - const firstHost = expectPresent(parent.querySelector('[data-codex-panel-block-key="one"]')); - const secondHost = expectPresent(parent.querySelector('[data-codex-panel-block-key="two"]')); - - const insertBefore = vi.spyOn(parent, "insertBefore"); - renderMessageStreamBlocks(parent, [ - testMessageStreamBlock("one", createElement("section", null, "one again")), - testMessageStreamBlock("two", createElement("article", null, "two again")), - ]); - - expect(insertBefore).not.toHaveBeenCalled(); - expect([...parent.children]).toEqual([firstHost, secondHost]); - expect(firstHost.firstElementChild?.tagName).toBe("SECTION"); - expect(secondHost.firstElementChild?.tagName).toBe("ARTICLE"); - unmountReactRoot(parent); - }); - - it("inserts completed-turn activity groups without replacing stable conversation nodes", () => { - const parent = document.createElement("div"); - const baseContext = { - activeThreadId: "thread", - turnLifecycle: idleTurnLifecycle(), - historyCursor: null, - loadingHistory: false, - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (element: HTMLElement, text: string) => element.createDiv({ text }), - renderTextWithWikiLinks: (element: HTMLElement, text: string) => element.createDiv({ text }), - }; - - renderMessageStreamBlocks( - parent, - messageStreamBlocks({ - ...baseContext, - displayItems: [ - { id: "u1", kind: "message", role: "user", text: "do it", turnId: "t1", markdown: true }, - { id: "a1", kind: "message", role: "assistant", text: "done", turnId: "t1", markdown: true }, - ], - }), - ); - const userNode = parent.querySelector('[data-codex-panel-block-key="item:u1"]'); - const assistantNode = parent.querySelector('[data-codex-panel-block-key="item:a1"]'); - - renderMessageStreamBlocks( - parent, - messageStreamBlocks({ - ...baseContext, - displayItems: [ - { id: "u1", kind: "message", role: "user", text: "do it", turnId: "t1", markdown: true }, - { - id: "hook-1", - kind: "hook", - role: "tool", - text: "userPromptSubmit: Saving jj baseline", - toolLabel: "hook", - turnId: "t1", - status: "completed", - }, - { id: "a1", kind: "message", role: "assistant", text: "done", turnId: "t1", markdown: true }, - ], - }), - ); - - expect(parent.querySelector('[data-codex-panel-block-key="item:u1"]')).toBe(userNode); - expect(parent.querySelector('[data-codex-panel-block-key="item:a1"]')).toBe(assistantNode); - expect([...parent.children].map((element) => element.getAttribute("data-codex-panel-block-key"))).toEqual([ - "item:u1", - "activity:turn-t1-activity", - "item:a1", - ]); - expect(parent.querySelector('[data-codex-panel-block-key="activity:turn-t1-activity"] summary')?.textContent).toBe( - "Work details: hook", - ); - unmountReactRoot(parent); - }); - - it("renders the history bar as a React block", () => { - const loadOlderTurns = vi.fn(); - const [historyBlock] = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: idleTurnLifecycle(), - historyCursor: "cursor", - loadingHistory: false, - displayItems: [], - openDetails: new Set(), - loadOlderTurns, - renderMarkdown: (element, text) => element.createDiv({ text }), - renderTextWithWikiLinks: (element, text) => element.createDiv({ text }), - }); - const parent = document.createElement("div"); - - expect(historyBlock.key).toBe("history-bar"); - expect(historyBlock.node).not.toBeUndefined(); - renderReactRoot(parent, historyBlock.node); - - const button = expectPresent(parent.querySelector("button")); - expect(parent.querySelector(".codex-panel__history-bar")).not.toBeNull(); - expect(button.textContent).toBe("Load older"); - expect(button.disabled).toBe(false); - - button.click(); - - expect(loadOlderTurns).toHaveBeenCalledOnce(); - unmountReactRoot(parent); - }); - - it("renders the empty message stream state as a React block", () => { - const [emptyBlock] = messageStreamBlocks({ - activeThreadId: null, - turnLifecycle: idleTurnLifecycle(), - historyCursor: null, - loadingHistory: false, - displayItems: [], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (element, text) => element.createDiv({ text }), - renderTextWithWikiLinks: (element, text) => element.createDiv({ text }), - }); - const parent = document.createElement("div"); - - expect(emptyBlock.key).toBe("empty"); - expect(emptyBlock.node).not.toBeUndefined(); - renderReactRoot(parent, emptyBlock.node); - - const empty = expectPresent(parent.querySelector(".codex-panel__message--system")); - expect(empty.classList.contains("codex-panel__message")).toBe(true); - expect(empty.textContent).toBe("Send a message to start a conversation."); - unmountReactRoot(parent); - }); - - it("renders review result items as compact auto-review tool rows", () => { - const block = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: idleTurnLifecycle(), - historyCursor: null, - loadingHistory: false, - displayItems: [{ id: "review-1", kind: "reviewResult", role: "tool", text: "Auto-review denied this command.", markdown: false }], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - })[0]; - - const element = renderMessageBlockElement(block); - - expect(element.classList.contains("codex-panel__message--review-result")).toBe(true); - expect(element.classList.contains("codex-panel__tool-result--plain")).toBe(true); - expect(element.querySelector(".codex-panel__message-role")?.textContent).toBe("auto-review"); - expect(element.textContent).toContain("Auto-review denied this command."); - expect(element.querySelector("details")).toBeNull(); - }); - - it("renders review result details inside one auto-review details block", () => { - const block = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: idleTurnLifecycle(), - historyCursor: null, - loadingHistory: false, - displayItems: [ - { - id: "review-1", - kind: "reviewResult", - role: "tool", - text: "Auto-review approved: npm test", - turnId: "turn", - markdown: false, - state: "completed", - details: [ - { - title: "Review", - rows: [ - { key: "status", value: "approved" }, - { key: "action", value: "apply patch" }, - { key: "files", value: "src/display/tool-view.ts\nsrc/ui/message-stream.ts" }, - ], - }, - ], - }, - ], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - })[0]; - - const element = renderMessageBlockElement(block); - - expect(element.classList.contains("codex-panel__execution--completed")).toBe(true); - expect(topLevelDetailsSummaries(element)).toEqual(["auto-review"]); - expect([...element.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["auto-review"]); - expect(element.textContent).not.toContain("Details"); - expect(element.textContent).not.toContain("▶Review"); - expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("statusapproved"); - expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("actionapply patch"); - expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain( - "filessrc/display/tool-view.ts\nsrc/ui/message-stream.ts", - ); - expect([...element.querySelectorAll(".codex-panel__output-title")].map((title) => title.textContent)).toEqual([]); - }); - - it("keeps tool result React details mounted in the message stream host", () => { - const parent = document.createElement("div"); - const onDetailsToggle = vi.fn(); - const renderTextWithWikiLinks = vi.fn((element: HTMLElement, text: string) => { - element.createDiv({ text: `linked:${text}` }); - }); - - renderMessageStreamBlocks( - parent, - messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: idleTurnLifecycle(), - historyCursor: null, - loadingHistory: false, - displayItems: [ - { - id: "cmd-1", - kind: "command", - role: "tool", - text: "npm test", - command: "npm test", - cwd: "/vault", - status: "completed", - output: "ok", - state: "completed", - }, - ], - openDetails: new Set(), - onDetailsToggle, - loadOlderTurns: vi.fn(), - renderMarkdown: (element, text) => element.createDiv({ text }), - renderTextWithWikiLinks, - }), - ); - - const block = expectPresent(parent.querySelector('[data-codex-panel-block-key="item:cmd-1"]')); - const result = expectPresent(block.querySelector(".codex-panel__tool-result")); - expect(result.classList.contains("codex-panel__execution--completed")).toBe(true); - expect(result.querySelector(".codex-panel__tool-result-header")?.textContent).toBe("command"); - expect(result.querySelector(":scope > .codex-panel__tool-summary")?.textContent).toBe("linked:npm test"); - expect(result.querySelector(".codex-panel__tool-summary")?.textContent).toBe("linked:npm test"); - expect(result.querySelector(".codex-panel__meta-grid")?.textContent).toContain("commandnpm test"); - expect(result.querySelector(".codex-panel__output-title")?.textContent).toBe("Output"); - expect(renderTextWithWikiLinks).toHaveBeenCalledWith(expect.any(HTMLElement), "npm test"); - - const details = expectPresent(result.querySelector("details")); - details.open = true; - details.dispatchEvent(new Event("toggle", { bubbles: false })); - - expect(onDetailsToggle).toHaveBeenCalledWith("cmd-1:command-details", true); - unmountReactRoot(parent); - }); - - it("renders file change diffs through the React tool result adapter", () => { - const parent = document.createElement("div"); - - renderMessageStreamBlocks( - parent, - messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: idleTurnLifecycle(), - historyCursor: null, - loadingHistory: false, - workspaceRoot: "/vault", - displayItems: [ - { - id: "file-1", - kind: "fileChange", - role: "tool", - text: "Changed 1 file", - status: "completed", - changes: [{ kind: "modified", path: "/vault/src/app.ts", diff: "-old\n+new" }], - }, - ], - openDetails: new Set(["file-1:file-change-details"]), - loadOlderTurns: vi.fn(), - renderMarkdown: (element, text) => element.createDiv({ text }), - renderTextWithWikiLinks: (element, text) => element.createDiv({ text }), - }), - ); - - expect(parent.querySelector(".codex-panel__tool-summary")?.textContent).toBe("src/app.ts"); - expect(parent.querySelector(".codex-panel-diff-file .codex-panel__output-title")?.textContent).toBe("modified src/app.ts"); - expect([...parent.querySelectorAll(".codex-panel-diff__line")].map((line) => line.textContent)).toEqual(["old", "new"]); - unmountReactRoot(parent); - }); - - it("renders structured system result details as visible selectable meta rows", () => { - const block = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: idleTurnLifecycle(), - historyCursor: null, - loadingHistory: false, - displayItems: [ - { - id: "system-help", - kind: "system", - role: "system", - text: "Available slash commands", - markdown: false, - details: [ - { - rows: [ - { key: "/help", value: "Show available Codex slash commands." }, - { key: "/resume [thread]", value: "Resume a recent Codex thread." }, - ], - }, - ], - }, - ], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - })[0]; - - const element = renderMessageBlockElement(block); - - expect(element.classList.contains("codex-panel__message--system")).toBe(true); - expect(element.querySelector(".codex-panel__message-content")?.textContent).toBe("Available slash commands"); - expect(element.querySelector("details")).toBeNull(); - expect(element.querySelector(".codex-panel__output-title")).toBeNull(); - expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("/helpShow available Codex slash commands."); - expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("/resume [thread]Resume a recent Codex thread."); - }); - - it("renders rollback action only for the eligible user message", () => { - const onRollbackItem = vi.fn(); - const items = [ - { id: "u1", kind: "message", role: "user", text: "older", turnId: "turn-1", markdown: true }, - { id: "a1", kind: "message", role: "assistant", text: "older answer", turnId: "turn-1", markdown: true }, - { id: "u2", kind: "message", role: "user", text: "latest", turnId: "turn-2", markdown: true }, - ] as const; - const blocks = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: idleTurnLifecycle(), - historyCursor: null, - loadingHistory: false, - displayItems: [...items], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - canRollbackItem: (item) => item.id === "u2", - onRollbackItem, - }); - - const rendered = blocks.map((block) => renderMessageBlockElement(block)); - - expect(expectPresent(rendered[0]).querySelector(".codex-panel__rollback-turn")).toBeNull(); - expect(expectPresent(rendered[1]).querySelector(".codex-panel__rollback-turn")).toBeNull(); - const button = expectPresent(rendered[2]).querySelector(".codex-panel__rollback-turn"); - expect(button?.getAttribute("aria-label")).toBe("Rollback last turn"); - button?.click(); - expect(onRollbackItem).toHaveBeenCalledWith(expect.objectContaining({ id: "u2" })); - }); - - it("renders copy actions for copyable messages", () => { - const copyText = vi.fn(); - const blocks = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: idleTurnLifecycle(), - historyCursor: null, - loadingHistory: false, - displayItems: [ - { id: "u1", kind: "message", role: "user", text: "rendered user", copyText: "**user**", turnId: "turn-1", markdown: true }, - { id: "a1", kind: "message", role: "assistant", text: "rendered answer", copyText: "# Answer", turnId: "turn-1", markdown: true }, - ], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - copyText, - }); - - const rendered = blocks.map((block) => renderMessageBlockElement(block)); - const userButton = expectPresent(rendered[0]).querySelector(".codex-panel__copy-message"); - const assistantButton = expectPresent(rendered[1]).querySelector(".codex-panel__copy-message"); - - expect(userButton?.getAttribute("aria-label")).toBe("Copy message"); - expect(assistantButton?.getAttribute("aria-label")).toBe("Copy message"); - userButton?.click(); - assistantButton?.click(); - expect(copyText).toHaveBeenNthCalledWith(1, "**user**"); - expect(copyText).toHaveBeenNthCalledWith(2, "# Answer"); - }); - - it("expands assistant fork actions in the copy slot and defaults repeat clicks to plain fork", () => { - const onDetailsToggle = vi.fn(); - const onForkItem = vi.fn(); - const item: DisplayItem = { - id: "a1", - kind: "message", - role: "assistant", - text: "answer", - copyText: "answer", - turnId: "turn-1", - markdown: true, - }; - - const closedBlock = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: idleTurnLifecycle(), - historyCursor: null, - loadingHistory: false, - displayItems: [item], - openDetails: new Set(), - onDetailsToggle, - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - copyText: vi.fn(), - canForkItem: () => true, - onForkItem, - })[0]; - - const closedElement = renderMessageBlockElement(closedBlock); - expect(closedElement.querySelector(".codex-panel__copy-message")).not.toBeNull(); - const initialFork = expectPresent(closedElement.querySelector(".codex-panel__fork-message")); - expect(initialFork.getAttribute("aria-label")).toBe("Fork from here"); - initialFork.click(); - expect(onDetailsToggle).toHaveBeenCalledWith("message:fork-actions:a1", true); - - const openBlock = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: idleTurnLifecycle(), - historyCursor: null, - loadingHistory: false, - displayItems: [item], - openDetails: new Set(["message:fork-actions:a1"]), - onDetailsToggle, - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - copyText: vi.fn(), - canForkItem: () => true, - onForkItem, - })[0]; - - const openElement = renderMessageBlockElement(openBlock); - expect(openElement.querySelector(".codex-panel__copy-message")).toBeNull(); - expect(openElement.querySelector(".codex-panel__fork-and-archive-message")?.getAttribute("aria-label")).toBe( - "Fork and archive", - ); - const openFork = expectPresent(openElement.querySelector(".codex-panel__fork-message")); - expect(openFork.getAttribute("aria-label")).toBe("Fork"); - openFork.click(); - expect(onForkItem).toHaveBeenCalledWith(expect.objectContaining({ id: "a1" }), false); - }); - - it("runs fork and archive from the expanded assistant fork actions", () => { - const onForkItem = vi.fn(); - const item: DisplayItem = { - id: "a1", - kind: "message", - role: "assistant", - text: "answer", - copyText: "answer", - turnId: "turn-1", - markdown: true, - }; - const block = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: idleTurnLifecycle(), - historyCursor: null, - loadingHistory: false, - displayItems: [item], - openDetails: new Set(["message:fork-actions:a1"]), - onDetailsToggle: vi.fn(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - copyText: vi.fn(), - canForkItem: () => true, - onForkItem, - })[0]; - - const element = renderMessageBlockElement(block); - expectPresent(element.querySelector(".codex-panel__fork-and-archive-message")).click(); - - expect(onForkItem).toHaveBeenCalledWith(expect.objectContaining({ id: "a1" }), true); - }); - - it("keeps message React actions mounted in the message stream host", () => { - const parent = document.createElement("div"); - const copyText = vi.fn(); - const onImplementPlanItem = vi.fn(); - - renderMessageStreamBlocks( - parent, - messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: idleTurnLifecycle(), - historyCursor: null, - loadingHistory: false, - displayItems: [ - { - id: "p1", - kind: "message", - role: "assistant", - text: "Plan", - copyText: "Plan", - turnId: "turn-1", - markdown: false, - proposedPlan: true, - }, - ], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (element, text) => element.createDiv({ text }), - renderTextWithWikiLinks: (element, text) => element.createDiv({ text }), - copyText, - canImplementPlanItem: () => true, - onImplementPlanItem, - }), - ); - - parent.querySelector(".codex-panel__copy-message")?.click(); - parent.querySelector(".codex-panel__implement-plan")?.click(); - - expect(copyText).toHaveBeenCalledWith("Plan"); - expect(onImplementPlanItem).toHaveBeenCalledWith(expect.objectContaining({ id: "p1" })); - expect(parent.querySelector('[data-codex-panel-block-key="item:p1"] .codex-panel__message--assistant')).not.toBeNull(); - unmountReactRoot(parent); - }); - - it("renders message markdown through the React content adapter", () => { - const parent = document.createElement("div"); - const renderMarkdown = vi.fn((element: HTMLElement, text: string) => { - element.createDiv({ text: `rendered:${text}` }); - }); - - renderMessageStreamBlocks( - parent, - messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: idleTurnLifecycle(), - historyCursor: null, - loadingHistory: false, - displayItems: [{ id: "a1", kind: "message", role: "assistant", text: "**answer**", turnId: "turn-1", markdown: true }], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown, - renderTextWithWikiLinks: (element, text) => element.createDiv({ text }), - }), - ); - - expect(renderMarkdown).toHaveBeenCalledWith(expect.any(HTMLElement), "**answer**"); - expect(parent.querySelector(".codex-panel__message-content")?.textContent).toBe("rendered:**answer**"); - unmountReactRoot(parent); - }); - - it("updates message content when the markdown mode changes", () => { - const parent = document.createElement("div"); - const baseContext = { - activeThreadId: "thread", - turnLifecycle: idleTurnLifecycle(), - historyCursor: null, - loadingHistory: false, - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (element: HTMLElement, text: string) => element.createDiv({ text: `markdown:${text}` }), - renderTextWithWikiLinks: (element: HTMLElement, text: string) => element.createDiv({ text: `text:${text}` }), - }; - - renderMessageStreamBlocks( - parent, - messageStreamBlocks({ - ...baseContext, - displayItems: [{ id: "a1", kind: "message", role: "assistant", text: "**answer**", turnId: "turn-1", markdown: false }], - }), - ); - expect(parent.querySelector(".codex-panel__message-content")?.textContent).toBe("text:**answer**"); - - renderMessageStreamBlocks( - parent, - messageStreamBlocks({ - ...baseContext, - displayItems: [{ id: "a1", kind: "message", role: "assistant", text: "**answer**", turnId: "turn-1", markdown: true }], - }), - ); - expect(parent.querySelector(".codex-panel__message-content")?.textContent).toBe("markdown:**answer**"); - unmountReactRoot(parent); - }); - - it("updates keyed message content without replacing the stream block host", () => { - const parent = document.createElement("div"); - const renderMarkdown = vi.fn((element: HTMLElement, text: string) => { - element.createDiv({ text: `markdown:${text}` }); - }); - const baseContext = { - activeThreadId: "thread", - turnLifecycle: idleTurnLifecycle(), - historyCursor: null, - loadingHistory: false, - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown, - renderTextWithWikiLinks: (element: HTMLElement, text: string) => element.createDiv({ text }), - }; - - renderMessageStreamBlocks( - parent, - messageStreamBlocks({ - ...baseContext, - displayItems: [{ id: "a1", kind: "message", role: "assistant", text: "first", turnId: "turn-1", markdown: true }], - }), - ); - const host = expectPresent(parent.querySelector('[data-codex-panel-block-key="item:a1"]')); - expect(host.querySelector(".codex-panel__message-content")?.textContent).toBe("markdown:first"); - - renderMessageStreamBlocks( - parent, - messageStreamBlocks({ - ...baseContext, - displayItems: [{ id: "a1", kind: "message", role: "assistant", text: "second", turnId: "turn-1", markdown: true }], - }), - ); - - expect(parent.querySelector('[data-codex-panel-block-key="item:a1"]')).toBe(host); - expect(host.querySelector(".codex-panel__message-content")?.textContent).toBe("markdown:second"); - expect(renderMarkdown).toHaveBeenCalledTimes(2); - unmountReactRoot(parent); - }); - - it("does not rerender unchanged message markdown when the stream rerenders", () => { - const parent = document.createElement("div"); - const renderMarkdown = vi.fn((element: HTMLElement, text: string) => { - element.createDiv({ text }); - }); - const context = { - activeThreadId: "thread", - turnLifecycle: idleTurnLifecycle(), - historyCursor: null, - loadingHistory: false, - displayItems: [ - { id: "a1", kind: "message", role: "assistant", text: "**answer**", turnId: "turn-1", markdown: true }, - ] satisfies DisplayItem[], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown, - renderTextWithWikiLinks: (element: HTMLElement, text: string) => element.createDiv({ text }), - }; - - renderMessageStreamBlocks(parent, messageStreamBlocks(context)); - renderMessageStreamBlocks(parent, messageStreamBlocks({ ...context, loadingHistory: true })); - - expect(renderMarkdown).toHaveBeenCalledOnce(); - unmountReactRoot(parent); - }); - - it("hides copy action for the active assistant message while a turn is running", () => { - const item = { - id: "a-running", - itemId: "a-running", - kind: "message", - role: "assistant", - text: "partial", - copyText: "partial", - turnId: "turn-1", - markdown: false, - } as const; - const context = { - activeThreadId: "thread", - turnLifecycle: runningTurnLifecycle("turn-1"), - historyCursor: null, - loadingHistory: false, - displayItems: [item], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent: HTMLElement, text: string) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent: HTMLElement, text: string) => parent.createDiv({ text }), - copyText: vi.fn(), - }; - - const runningBlock = messageStreamBlocks(context)[0]; - const completedBlock = messageStreamBlocks({ ...context, turnLifecycle: idleTurnLifecycle() })[0]; - - expect(renderMessageBlockElement(runningBlock).querySelector(".codex-panel__copy-message")).toBeNull(); - expect(renderMessageBlockElement(completedBlock).querySelector(".codex-panel__copy-message")).not.toBeNull(); - }); - - it("renders implement plan action for eligible proposed plans", () => { - const onImplementPlanItem = vi.fn(); - const block = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: idleTurnLifecycle(), - historyCursor: null, - loadingHistory: false, - displayItems: [ - { - id: "p1", - kind: "message", - role: "assistant", - text: "# Plan", - copyText: "# Plan", - turnId: "turn-1", - markdown: true, - proposedPlan: true, - }, - ], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - copyText: vi.fn(), - canImplementPlanItem: () => true, - onImplementPlanItem, - })[0]; - - const element = renderMessageBlockElement(block); - const button = element.querySelector(".codex-panel__implement-plan"); - - expect(button?.getAttribute("aria-label")).toBe("Implement plan"); - button?.click(); - expect(onImplementPlanItem).toHaveBeenCalledWith(expect.objectContaining({ id: "p1" })); - }); - - it("selects only the latest proposed plan as an implement candidate", () => { - const firstPlan = { - id: "p1", - kind: "message", - role: "assistant", - text: "# First plan", - turnId: "turn-1", - proposedPlan: true, - } as const; - const secondPlan = { - id: "p2", - kind: "message", - role: "assistant", - text: "# Second plan", - turnId: "turn-2", - proposedPlan: true, - } as const; - const baseState = { - activeThreadId: "thread", - turnLifecycle: { kind: "idle" as const }, - composerDraft: "", - requestedCollaborationMode: "plan" as const, - displayItems: [firstPlan, { id: "a1", kind: "message", role: "assistant", text: "answer" } as const, secondPlan], - }; - - expect(implementPlanCandidateFromState(baseState)).toBe(secondPlan); - expect(implementPlanCandidateFromState({ ...baseState, requestedCollaborationMode: "default" })).toBeNull(); - expect(implementPlanCandidateFromState({ ...baseState, composerDraft: "edit first" })).toBeNull(); - expect(implementPlanCandidateFromState({ ...baseState, turnLifecycle: { kind: "running", turnId: "turn-2" } })).toBeNull(); - }); - - it("does not render copy actions for tool items", () => { - const block = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: idleTurnLifecycle(), - historyCursor: null, - loadingHistory: false, - displayItems: [ - { - id: "tool-1", - kind: "tool", - role: "tool", - text: "tool summary", - turnId: "turn", - toolLabel: "web search", - }, - ], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - copyText: vi.fn(), - })[0]; - - expect(renderMessageBlockElement(block).querySelector(".codex-panel__copy-message")).toBeNull(); - }); - - it("renders copy and rollback actions together when both apply", () => { - const copyText = vi.fn(); - const onRollbackItem = vi.fn(); - const block = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: idleTurnLifecycle(), - historyCursor: null, - loadingHistory: false, - displayItems: [{ id: "u1", kind: "message", role: "user", text: "latest", copyText: "latest", turnId: "turn-1", markdown: true }], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - copyText, - canRollbackItem: () => true, - onRollbackItem, - })[0]; - - const element = renderMessageBlockElement(block); - element.querySelector(".codex-panel__copy-message")?.click(); - element.querySelector(".codex-panel__rollback-turn")?.click(); - - expect(copyText).toHaveBeenCalledWith("latest"); - expect(onRollbackItem).toHaveBeenCalledWith(expect.objectContaining({ id: "u1" })); - }); - - it("collapses tall user messages without changing the copy payload", () => { - withMessageContentScrollHeight(500, () => { - const copyText = vi.fn(); - const openDetails = new Set(); - const onDetailsToggle = vi.fn((key: string, open: boolean) => { - if (open) { - openDetails.add(key); - } else { - openDetails.delete(key); - } - }); - const block = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: idleTurnLifecycle(), - historyCursor: null, - loadingHistory: false, - displayItems: [ - { id: "u1", kind: "message", role: "user", text: "visible text", copyText: "full copied text", turnId: "turn-1", markdown: true }, - ], - openDetails, - onDetailsToggle, - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - copyText, - })[0]; - - const element = renderMessageBlockElement(block); - const content = element.querySelector(".codex-panel__message-content"); - const details = element.querySelector(".codex-panel__message-collapse-details"); - - expect(content?.classList.contains("codex-panel__message-content--collapsed")).toBe(true); - expect(details?.hidden).toBe(false); - expect(details?.querySelector("summary")?.textContent).toBe("Show more"); - - if (details) { - details.open = true; - act(() => { - details.dispatchEvent(new Event("toggle")); - }); - } - expect(openDetails.has("message:u1:expanded")).toBe(true); - expect(content?.classList.contains("codex-panel__message-content--collapsed")).toBe(false); - expect(details?.hidden).toBe(true); - expect(onDetailsToggle).toHaveBeenCalled(); - - element.querySelector(".codex-panel__copy-message")?.click(); - expect(copyText).toHaveBeenCalledWith("full copied text"); - }); - }); - - it("does not show the collapse control for short user messages or assistant messages", () => { - withMessageContentScrollHeight(120, () => { - const shortUserBlock = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: idleTurnLifecycle(), - historyCursor: null, - loadingHistory: false, - displayItems: [{ id: "u1", kind: "message", role: "user", text: "short", turnId: "turn-1", markdown: true }], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - })[0]; - const shortUser = renderMessageBlockElement(shortUserBlock); - - expect(shortUser.querySelector(".codex-panel__message-collapse-details")?.hidden).toBe(true); - }); - - withMessageContentScrollHeight(500, () => { - const assistantBlock = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: idleTurnLifecycle(), - historyCursor: null, - loadingHistory: false, - displayItems: [{ id: "a1", kind: "message", role: "assistant", text: "long", turnId: "turn-1", markdown: true }], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - })[0]; - const assistant = renderMessageBlockElement(assistantBlock); - - expect(assistant.querySelector(".codex-panel__message-collapse-details")).toBeNull(); - }); - }); - - it("does not render rollback action when no item is eligible", () => { - const block = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: startingTurnLifecycle(), - historyCursor: null, - loadingHistory: false, - displayItems: [{ id: "u1", kind: "message", role: "user", text: "running", turnId: "turn-1", markdown: true }], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - canRollbackItem: () => false, - onRollbackItem: vi.fn(), - })[0]; - - expect(renderMessageBlockElement(block).querySelector(".codex-panel__rollback-turn")).toBeNull(); - }); - - it("renders command items as a compact summary with output behind details", () => { - const block = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: runningTurnLifecycle("turn"), - historyCursor: null, - loadingHistory: false, - displayItems: [ - { - id: "cmd-1", - kind: "command", - role: "tool", - text: "npm run check (exit 1)", - turnId: "turn", - command: "npm run check", - cwd: "/vault", - status: "failed", - exitCode: 1, - output: "stderr details", - }, - ], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - })[0]; - - const element = renderMessageBlockElement(block); - - expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("npm run check (exit 1)"); - expect(element.querySelector(".codex-panel__tool-summary")?.getAttribute("title")).toBeNull(); - expect(topLevelDetailsSummaries(element)).toEqual(["command"]); - expect([...element.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["command"]); - expect(element.textContent).not.toContain("Details"); - expect([...element.querySelectorAll(".codex-panel__output-title")].map((title) => title.textContent)).toEqual(["Output"]); - expect(element.querySelector(".codex-panel__output pre")?.textContent).toBe("stderr details"); - expect(element.querySelector("details")?.hasAttribute("open")).toBe(false); - }); - - it("omits command exit and duration rows while they are unavailable", () => { - const block = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: runningTurnLifecycle("turn"), - historyCursor: null, - loadingHistory: false, - displayItems: [ - { - id: "cmd-1", - kind: "command", - role: "tool", - text: "npm run check", - turnId: "turn", - command: "npm run check", - cwd: "/vault", - status: "inProgress", - output: "", - }, - ], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - })[0]; - - const element = renderMessageBlockElement(block); - const metaText = element.querySelector(".codex-panel__meta-grid")?.textContent ?? ""; - - expect(metaText).toContain("commandnpm run check"); - expect(metaText).toContain("statusinProgress"); - expect(metaText).not.toContain("exit"); - expect(metaText).not.toContain("duration"); - expect(metaText).not.toContain("undefined"); - }); - - it("uses read as the command header for parsed file reads", () => { - const block = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: runningTurnLifecycle("turn"), - historyCursor: null, - loadingHistory: false, - displayItems: [ - { - id: "cmd-1", - kind: "command", - role: "tool", - actionLabel: "read", - text: "sed /vault/src/main.ts", - turnId: "turn", - command: "sed -n '1,20p' src/main.ts", - cwd: "/vault", - status: "completed", - exitCode: 0, - output: "contents", - }, - ], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - })[0]; - - const element = renderMessageBlockElement(block); - - expect([...element.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["read"]); - }); - - it("renders file diffs inside a single file change details block", () => { - const block = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: runningTurnLifecycle("turn"), - historyCursor: null, - loadingHistory: false, - workspaceRoot: "/vault/project", - displayItems: [ - { - id: "patch-1", - kind: "fileChange", - role: "tool", - text: "/vault/project/src/main.ts", - turnId: "turn", - status: "completed", - changes: [{ kind: "update", path: "/vault/project/src/main.ts", diff: "@@\n-old\n+new" }], - output: "patch applied", - }, - ], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - })[0]; - - const element = renderMessageBlockElement(block); - - expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("src/main.ts"); - expect(topLevelDetailsSummaries(element)).toEqual(["file change"]); - expect([...element.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["file change"]); - expect(element.textContent).not.toContain("Details"); - expect([...element.querySelectorAll(".codex-panel__output-title")].map((title) => title.textContent)).toEqual([ - "update src/main.ts", - "Patch output", - ]); - }); - - it("renders the edited files footer with an open diff action when aggregated turn diff exists", () => { - const openTurnDiff = vi.fn(); - const blocks = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: idleTurnLifecycle(), - historyCursor: null, - loadingHistory: false, - workspaceRoot: "/vault/project", - displayItems: [ - { - id: "patch-1", - kind: "fileChange", - role: "tool", - text: "File change completed", - turnId: "turn", - status: "completed", - changes: [{ kind: "update", path: "/vault/project/src/main.ts", diff: "@@\n-old\n+new" }], - }, - { id: "a1", kind: "message", role: "assistant", text: "Done", turnId: "turn", markdown: true }, - ], - turnDiffs: new Map([["turn", "diff --git a/src/main.ts b/src/main.ts\n@@\n-old\n+new"]]), - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - openTurnDiff, - }); - - const assistant = renderMessageBlockElement(expectPresent(blocks.find((block) => block.key === "item:a1"))); - const button = assistant.querySelector(".codex-panel__open-turn-diff"); - - expect(assistant.querySelector(".codex-panel__edited-files")?.textContent).toContain("Edited 1 file"); - expect(button?.getAttribute("aria-label")).toBe("View diff"); - expect(button?.textContent).toContain("View diff"); - button?.click(); - expect(openTurnDiff).toHaveBeenCalledWith({ - threadId: "thread", - turnId: "turn", - cwd: "/vault/project", - files: ["src/main.ts"], - diff: "diff --git a/src/main.ts b/src/main.ts\n@@\n-old\n+new", - }); - }); - - it("renders referenced thread metadata without exposing hidden context", () => { - const blocks = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: idleTurnLifecycle(), - historyCursor: null, - loadingHistory: false, - displayItems: [ - { - id: "u1", - kind: "message", - role: "user", - text: "この続きです", - copyText: "この続きです", - markdown: true, - referencedThread: { - threadId: "thread-reference", - title: "参照元", - includedTurns: 2, - turnLimit: 20, - }, - }, - ], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - }); - - const user = renderMessageBlockElement(expectPresent(blocks.find((block) => block.key === "item:u1"))); - - expect(user.querySelector(".codex-panel__message-content")?.textContent).toBe("この続きです"); - expect(user.querySelector(".codex-panel__referenced-thread")?.textContent).toContain("Referenced 参照元"); - expect(user.querySelector(".codex-panel__referenced-thread")?.textContent).toContain("2/20 turns"); - expect(user.querySelector(".codex-panel__referenced-thread")?.getAttribute("title")).toBeNull(); - }); - - it("renders resolved file mentions as a collapsed user message attachment", () => { - const blocks = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: idleTurnLifecycle(), - historyCursor: null, - loadingHistory: false, - displayItems: [ - { - id: "u1", - kind: "message", - role: "user", - text: "Read [[Alpha]].", - copyText: "Read [[Alpha]].", - markdown: true, - mentionedFiles: [{ name: "Alpha", path: "thoughts/Alpha.md" }], - }, - ], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - }); - - const user = renderMessageBlockElement(expectPresent(blocks.find((block) => block.key === "item:u1"))); - - expect(user.querySelector(".codex-panel__message-content")?.textContent).toBe("Read [[Alpha]]."); - expect(user.querySelector(".codex-panel__mentioned-files summary")?.textContent).toBe("Mentioned 1 file"); - expect(user.querySelector(".codex-panel__mentioned-files")?.textContent).toContain("Alpha"); - expect(user.querySelector(".codex-panel__mentioned-files")?.textContent).toContain("thoughts/Alpha.md"); - }); - - it("does not render the open diff action without aggregated turn diff", () => { - const blocks = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: idleTurnLifecycle(), - historyCursor: null, - loadingHistory: false, - displayItems: [ - { - id: "patch-1", - kind: "fileChange", - role: "tool", - text: "File change completed", - turnId: "turn", - status: "completed", - changes: [{ kind: "update", path: "src/main.ts", diff: "@@\n-old\n+new" }], - }, - { id: "a1", kind: "message", role: "assistant", text: "Done", turnId: "turn", markdown: true }, - ], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - openTurnDiff: vi.fn(), - }); - - const assistant = renderMessageBlockElement(expectPresent(blocks.find((block) => block.key === "item:a1"))); - - expect(assistant.querySelector(".codex-panel__edited-files")?.textContent).toContain("Edited 1 file"); - expect(assistant.querySelector(".codex-panel__open-turn-diff")).toBeNull(); - }); -}); - -describe("work log renderer decisions", () => { - it("renders generic tool details as visible sections inside one details block", () => { - const block = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: runningTurnLifecycle("turn"), - historyCursor: null, - loadingHistory: false, - displayItems: [ - { - id: "tool-1", - kind: "tool", - role: "tool", - text: "123", - toolLabel: "github.pull_request_read", - turnId: "turn", - status: "completed", - details: [ - { title: "Arguments JSON", body: '{\n "id": 123\n}' }, - { title: "Result JSON", body: '{\n "ok": true\n}' }, - ], - }, - ], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - })[0]; - - const element = renderMessageBlockElement(block); - - expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("123"); - expect(topLevelDetailsSummaries(element)).toEqual(["github.pull_request_read"]); - expect([...element.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["github.pull_request_read"]); - expect(element.textContent).not.toContain("Details"); - expect([...element.querySelectorAll(".codex-panel__output-title")].map((title) => title.textContent)).toEqual([ - "Arguments JSON", - "Result JSON", - ]); - }); - - it("keeps the tool summary as a separate row when details are open", () => { - const block = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: runningTurnLifecycle("turn"), - historyCursor: null, - loadingHistory: false, - displayItems: [ - { - id: "cmd-1", - kind: "command", - role: "tool", - text: "npm run check", - turnId: "turn", - command: "npm run check", - cwd: "/vault", - status: "completed", - exitCode: 0, - output: "ok", - }, - ], - openDetails: new Set(["cmd-1:command-details"]), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - })[0]; - - const element = renderMessageBlockElement(block); - - expect(element.classList.contains("is-open")).toBe(true); - expect(element.querySelector("details")?.hasAttribute("open")).toBe(true); - expect(topLevelDetailsSummaries(element)).toEqual(["command"]); - expect(element.querySelector(":scope > .codex-panel__tool-summary")?.textContent).toBe("npm run check"); - }); - - it("renders generic tools without details as two compact rows", () => { - const block = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: runningTurnLifecycle("turn"), - historyCursor: null, - loadingHistory: false, - displayItems: [ - { - id: "tool-plain", - kind: "tool", - role: "tool", - text: "https://example.com", - toolLabel: "web search", - turnId: "turn", - }, - ], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - })[0]; - - const element = renderMessageBlockElement(block); - - expect(element.classList.contains("codex-panel__tool-result--plain")).toBe(true); - expect(element.querySelector(".codex-panel__tool-result-header")?.textContent).toBe("web search"); - expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("https://example.com"); - }); - - it("renders path summary tools relative to the workspace root", () => { - const block = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: runningTurnLifecycle("turn"), - historyCursor: null, - loadingHistory: false, - workspaceRoot: "/vault/project", - displayItems: [ - { - id: "tool-path", - kind: "tool", - role: "tool", - text: "/vault/project/assets/image.png", - toolLabel: "imageView", - summaryPath: true, - turnId: "turn", - }, - ], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - })[0]; - - const element = renderMessageBlockElement(block); - - expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("assets/image.png"); - expect(element.querySelector(".codex-panel__tool-summary")?.getAttribute("title")).toBeNull(); - }); - - it("updates path summary tools when the workspace root changes", () => { - const parent = document.createElement("div"); - const item = { - id: "tool-path", - kind: "tool", - role: "tool", - text: "/vault/project/assets/image.png", - toolLabel: "imageView", - summaryPath: true, - turnId: "turn", - } as const; - const baseContext = { - activeThreadId: "thread", - turnLifecycle: runningTurnLifecycle("turn"), - historyCursor: null, - loadingHistory: false, - displayItems: [item] satisfies DisplayItem[], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (element: HTMLElement, text: string) => element.createDiv({ text }), - renderTextWithWikiLinks: (element: HTMLElement, text: string) => element.createDiv({ text }), - }; - - renderMessageStreamBlocks(parent, messageStreamBlocks({ ...baseContext, workspaceRoot: "/vault" })); - expect(parent.querySelector(".codex-panel__tool-summary")?.textContent).toBe("project/assets/image.png"); - - renderMessageStreamBlocks(parent, messageStreamBlocks({ ...baseContext, workspaceRoot: "/vault/project" })); - expect(parent.querySelector(".codex-panel__tool-summary")?.textContent).toBe("assets/image.png"); - unmountReactRoot(parent); - }); - - it("keeps path summary tools absolute outside the workspace root", () => { - const block = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: runningTurnLifecycle("turn"), - historyCursor: null, - loadingHistory: false, - workspaceRoot: "/vault/project", - displayItems: [ - { - id: "tool-path", - kind: "tool", - role: "tool", - text: "/tmp/image.png", - toolLabel: "imageView", - summaryPath: true, - turnId: "turn", - }, - ], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - })[0]; - - const element = renderMessageBlockElement(block); - - expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("/tmp/image.png"); - }); - - it("does not treat generic tool summaries as paths without an explicit marker", () => { - const block = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: runningTurnLifecycle("turn"), - historyCursor: null, - loadingHistory: false, - workspaceRoot: "/vault/project", - displayItems: [ - { - id: "tool-path-like", - kind: "tool", - role: "tool", - text: "/vault/project", - toolLabel: "example.tool", - turnId: "turn", - }, - ], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - })[0]; - - const element = renderMessageBlockElement(block); - - expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("/vault/project"); - }); - - it("renders hook metadata as rows inside one details block", () => { - const block = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: runningTurnLifecycle("turn"), - historyCursor: null, - loadingHistory: false, - displayItems: [ - { - id: "hook-1", - kind: "hook", - role: "tool", - text: "postToolUse: Formatted 1 file.", - toolLabel: "hook", - turnId: "turn", - status: "completed", - details: [ - { - rows: [ - { key: "status", value: "completed" }, - { key: "event", value: "postToolUse" }, - { key: "message", value: "Formatted 1 file." }, - { key: "duration", value: "1ms" }, - ], - }, - { title: "Hook output", body: "feedback: ok" }, - ], - }, - ], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - })[0]; - - const element = renderMessageBlockElement(block); - - expect(topLevelDetailsSummaries(element)).toEqual(["hook"]); - expect([...element.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["hook"]); - expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("postToolUse: Formatted 1 file."); - expect(element.textContent).not.toContain("Details"); - expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("statuscompleted"); - expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("eventpostToolUse"); - expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("messageFormatted 1 file."); - expect([...element.querySelectorAll(".codex-panel__output-title")].map((title) => title.textContent)).toEqual(["Hook output"]); - expect(element.querySelector(".codex-panel__output pre")?.textContent).toBe("feedback: ok"); - }); - - it("renders hook metadata when the hook is inside a completed-turn activity group", () => { - const blocks = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: idleTurnLifecycle(), - historyCursor: null, - loadingHistory: false, - displayItems: [ - { id: "u1", kind: "message", role: "user", text: "do it", turnId: "turn", markdown: true }, - { - id: "hook-1", - kind: "hook", - role: "tool", - text: "postToolUse: Formatted 1 file.", - toolLabel: "hook", - turnId: "turn", - status: "completed", - details: [ - { - rows: [ - { key: "status", value: "completed" }, - { key: "event", value: "postToolUse" }, - { key: "message", value: "Formatted 1 file." }, - ], - }, - { title: "Hook output", body: "feedback: ok" }, - ], - }, - { id: "a1", kind: "message", role: "assistant", text: "done", turnId: "turn", markdown: true }, - ], - openDetails: new Set(["turn:turn:activity", "hook-1"]), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - }); - - const element = renderMessageBlockElement(expectPresent(blocks.find((block) => block.key === "activity:turn-turn-activity"))); - - expect(element).toBeDefined(); - expect(element.querySelector(":scope > summary")?.textContent).toBe("Work details: hook"); - expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("postToolUse: Formatted 1 file."); - expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("statuscompleted"); - expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("eventpostToolUse"); - expect(element.querySelector(".codex-panel__output-title")?.textContent).toBe("Hook output"); - expect(element.querySelector(".codex-panel__output pre")?.textContent).toBe("feedback: ok"); - }); - - it("keeps completed-turn activity group items mounted through React", () => { - const parent = document.createElement("div"); - const onDetailsToggle = vi.fn(); - - renderMessageStreamBlocks( - parent, - messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: idleTurnLifecycle(), - historyCursor: null, - loadingHistory: false, - displayItems: [ - { id: "u1", kind: "message", role: "user", text: "do it", turnId: "turn", markdown: true }, - { - id: "hook-1", - kind: "hook", - role: "tool", - text: "postToolUse: Formatted 1 file.", - toolLabel: "hook", - turnId: "turn", - status: "completed", - details: [ - { - rows: [ - { key: "status", value: "completed" }, - { key: "event", value: "postToolUse" }, - ], - }, - { title: "Hook output", body: "feedback: ok" }, - ], - }, - { - id: "agent-1", - kind: "agent", - role: "tool", - text: "Spawn agent", - turnId: "turn", - tool: "spawnAgent", - status: "completed", - senderThreadId: "parent", - receiverThreadIds: ["child"], - prompt: null, - model: null, - reasoningEffort: null, - agents: [{ threadId: "child", status: "completed", message: "Done" }], - }, - { id: "a1", kind: "message", role: "assistant", text: "done", turnId: "turn", markdown: true }, - ], - openDetails: new Set(["turn:turn:activity", "hook-1:details"]), - onDetailsToggle, - loadOlderTurns: vi.fn(), - renderMarkdown: (element, text) => element.createDiv({ text }), - renderTextWithWikiLinks: (element, text) => element.createDiv({ text }), - }), - ); - - const group = expectPresent(parent.querySelector('[data-codex-panel-block-key="activity:turn-turn-activity"]')); - expect(group.querySelector(":scope > .codex-panel__activity-group > summary")?.textContent).toBe("Work details: agent, hook"); - expect(group.querySelector(".codex-panel__tool-result .codex-panel__tool-result-header")?.textContent).toBe("hook"); - expect(group.querySelector(".codex-panel__tool-result > .codex-panel__tool-summary")?.textContent).toBe( - "postToolUse: Formatted 1 file.", - ); - expect(group.querySelector(".codex-panel__agent-activity .codex-panel__tool-summary")?.textContent).toBe("spawn child (completed)"); - - const details = expectPresent(group.querySelector(".codex-panel__activity-group")); - details.open = false; - details.dispatchEvent(new Event("toggle", { bubbles: false })); - - expect(onDetailsToggle).toHaveBeenCalledWith("turn:turn:activity", false); - unmountReactRoot(parent); - }); - - it("renders task progress items as a dedicated task list", () => { - const block = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: runningTurnLifecycle("turn"), - historyCursor: null, - loadingHistory: false, - displayItems: [ - { - id: "plan-progress-turn", - kind: "taskProgress", - role: "tool", - text: "Plan\n[>] Patch UI", - turnId: "turn", - explanation: "Plan", - steps: [ - { step: "Inspect code", status: "completed" }, - { step: "Patch UI", status: "inProgress" }, - ], - status: "inProgress", - }, - ], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - })[0]; - - const element = renderMessageBlockElement(block); - - expect(element.classList.contains("codex-panel__work-message")).toBe(true); - expect(element.classList.contains("codex-panel__task-progress")).toBe(true); - expect(element.querySelector(".codex-panel__message-role")?.textContent).toBe("tasks"); - expect(element.textContent).toContain("[x]Inspect code"); - expect(element.textContent).toContain("[>]Patch UI"); - }); - - it("keeps task progress React items mounted in the message stream host", () => { - const parent = document.createElement("div"); - - renderMessageStreamBlocks( - parent, - messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: runningTurnLifecycle("turn"), - historyCursor: null, - loadingHistory: false, - displayItems: [ - { - id: "plan-progress-turn", - kind: "taskProgress", - role: "tool", - text: "Plan\n[>] Patch UI", - turnId: "turn", - explanation: "Plan", - steps: [ - { step: "Inspect code", status: "completed" }, - { step: "Patch UI", status: "inProgress" }, - ], - status: "inProgress", - }, - ], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (element, text) => element.createDiv({ text }), - renderTextWithWikiLinks: (element, text) => element.createDiv({ text }), - }), - ); - - const block = expectPresent(parent.querySelector('[data-codex-panel-block-key="live-task:plan-progress-turn"]')); - expect(block.querySelector(".codex-panel__task-progress .codex-panel__message-role")?.textContent).toBe("tasks"); - expect(block.textContent).toContain("[x]Inspect code"); - expect(block.textContent).toContain("[>]Patch UI"); - unmountReactRoot(parent); - }); - - it("renders active task progress with the shared bottom live blocks", () => { - const blocks = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: runningTurnLifecycle("turn"), - historyCursor: null, - loadingHistory: false, - displayItems: [ - { id: "u1", kind: "message", role: "user", text: "Do it", turnId: "turn", markdown: true }, - { - 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: "message", role: "assistant", text: "Working", turnId: "turn", markdown: true }, - { - 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", message: "Inspecting renderer" }], - }, - ], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - pendingRequestsSignature: "request:1", - renderPendingRequests: () => "Request", - }); - - 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 = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: runningTurnLifecycle("turn"), - historyCursor: null, - loadingHistory: false, - displayItems: [ - { id: "u1", kind: "message", role: "user", text: "Do it", turnId: "turn", markdown: true }, - { - 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", 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", - }, - ], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - }); - - 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 = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: runningTurnLifecycle("turn"), - historyCursor: null, - loadingHistory: false, - displayItems: [ - { id: "u1", kind: "message", role: "user", text: "Do it", turnId: "turn", markdown: true }, - { - 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", 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", message: "Inspecting renderer" }], - }, - ], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - }); - - 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 = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: runningTurnLifecycle("turn"), - historyCursor: null, - loadingHistory: false, - displayItems: [ - { - id: "agent-1", - kind: "agent", - role: "tool", - text: "Spawn agent", - turnId: "turn", - tool: "spawnAgent", - status: "completed", - senderThreadId: "parent", - receiverThreadIds: ["child"], - prompt: "Inspect the renderer.", - model: "gpt-5.5", - reasoningEffort: "high", - agents: [{ threadId: "child", status: "completed", message: "Done" }], - }, - ], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - })[0]; - - const element = renderMessageBlockElement(block); - - expect(element.classList.contains("codex-panel__work-message")).toBe(true); - expect(element.classList.contains("codex-panel__agent-activity")).toBe(true); - expect(element.querySelector(".codex-panel__message-role")?.textContent).toBe("agent"); - const summary = expectPresent(element.querySelector(".codex-panel__tool-summary")); - expect(summary.textContent).toBe("spawn child: Inspect the renderer. (completed)"); - expect(summary.classList.contains("codex-panel__agent-activity-summary")).toBe(true); - expect([...element.querySelectorAll("details summary")].map((detailsSummary) => detailsSummary.textContent)).toEqual(["Details"]); - expect(element.textContent).toContain("targetchild"); - expect(element.textContent).toContain("PromptInspect the renderer."); - expect(element.textContent).toContain("childcompleted: Done"); - }); - - it("keeps agent React details mounted in the message stream host", () => { - const parent = document.createElement("div"); - const onDetailsToggle = vi.fn(); - - renderMessageStreamBlocks( - parent, - messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: runningTurnLifecycle("turn"), - historyCursor: null, - loadingHistory: false, - displayItems: [ - { - id: "agent-1", - kind: "agent", - role: "tool", - text: "Spawn agent", - turnId: "turn", - tool: "spawnAgent", - status: "completed", - senderThreadId: "parent", - receiverThreadIds: ["child"], - prompt: "Inspect the renderer.", - model: "gpt-5.5", - reasoningEffort: "high", - agents: [{ threadId: "child", status: "completed", message: "Done" }], - }, - ], - openDetails: new Set(), - onDetailsToggle, - loadOlderTurns: vi.fn(), - renderMarkdown: (element, text) => element.createDiv({ text }), - renderTextWithWikiLinks: (element, text) => element.createDiv({ text }), - }), - ); - - const block = expectPresent(parent.querySelector('[data-codex-panel-block-key="item:agent-1"]')); - expect(block.querySelector(".codex-panel__agent-activity .codex-panel__message-role")?.textContent).toBe("agent"); - expect(block.querySelector(".codex-panel__tool-summary")?.textContent).toBe("spawn child: Inspect the renderer. (completed)"); - expect([...block.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["Details"]); - - const details = expectPresent(block.querySelector("details")); - act(() => { - details.open = true; - details.dispatchEvent(new Event("toggle", { bubbles: false })); - }); - - expect(onDetailsToggle).toHaveBeenCalledWith("agent-1:agent-details", true); - expect(expectPresent(block.querySelector(".codex-panel__agent-activity")).classList.contains("is-open")).toBe(true); - unmountReactRoot(parent); - }); - - it("keeps agent activity prompt previews visually constrained to one line", () => { - const block = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: runningTurnLifecycle("turn"), - historyCursor: null, - loadingHistory: false, - displayItems: [ - { - id: "agent-1", - kind: "agent", - role: "tool", - text: "Spawn agent", - turnId: "turn", - tool: "spawnAgent", - status: "running", - senderThreadId: "parent", - receiverThreadIds: ["child"], - prompt: `Inspect the renderer.\n${"a".repeat(180)}`, - model: null, - reasoningEffort: null, - agents: [], - }, - ], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - })[0]; - - const element = renderMessageBlockElement(block); - const summary = expectPresent(element.querySelector(".codex-panel__agent-activity-summary")); - - expect(summary.textContent).toBe(`spawn child: Inspect the renderer. ${"a".repeat(73)}... (running)`); - }); - - it("collapses long agent output away from the agent status row", () => { - const longMessage = `Done\n${"a".repeat(180)}`; - const threadId = "019e061e-0046-7653-a362-86de9a47cb5c"; - const onDetailsToggle = vi.fn(); - const block = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: runningTurnLifecycle("turn"), - historyCursor: null, - loadingHistory: false, - displayItems: [ - { - id: "agent-1", - kind: "agent", - role: "tool", - text: "Wait for agent", - turnId: "turn", - tool: "wait", - status: "completed", - senderThreadId: "parent", - receiverThreadIds: [threadId], - prompt: null, - model: null, - reasoningEffort: null, - agents: [{ threadId, status: "completed", message: longMessage }], - }, - ], - openDetails: new Set(), - onDetailsToggle, - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - })[0]; - - const element = renderMessageBlockElement(block); - - expect(element.querySelector(".codex-panel__agent-thread")?.textContent).toBe("019e061e"); - expect(element.querySelector(".codex-panel__agent-status")?.textContent).toBe("completed: Done"); - expect(element.querySelector(".codex-panel__agent-status")?.textContent).not.toContain("a".repeat(180)); - expect([...element.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["Details"]); - expect(element.textContent).toContain("Agent output 019e061e"); - expect(element.textContent).toContain(longMessage); - const details = element.querySelector("details"); - expect(details?.hasAttribute("open")).toBe(false); - details?.dispatchEvent(new Event("toggle")); - expect(onDetailsToggle).toHaveBeenCalled(); - }); - - it("renders a compact live agent summary while subagents are running", () => { - const blocks = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: runningTurnLifecycle("turn"), - historyCursor: null, - loadingHistory: false, - displayItems: [ - { - id: "agent-1", - kind: "agent", - role: "tool", - text: "Wait for agent", - turnId: "turn", - tool: "wait", - status: "running", - senderThreadId: "parent", - receiverThreadIds: ["done", "running"], - prompt: null, - model: null, - reasoningEffort: null, - agents: [ - { threadId: "done", status: "completed", message: null }, - { threadId: "running", status: "running", message: "Inspecting renderer" }, - ], - }, - ], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - }); - - const summary = renderMessageBlockElement(expectPresent(blocks.at(-1))); - - expect(summary.classList.contains("codex-panel__work-message")).toBe(true); - expect(summary.classList.contains("codex-panel__agent-summary")).toBe(true); - expect(summary.textContent).toContain("agents"); - expect(summary.textContent).toContain("Agents 1 running, 1 done"); - expect(summary.textContent).toContain("runningrunning: Inspecting renderer"); - expect(summary.textContent).not.toContain("donecompleted"); - }); - - it("renders the compact live agent summary as a React block", () => { - const parent = document.createElement("div"); - - renderMessageStreamBlocks( - parent, - messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: runningTurnLifecycle("turn"), - historyCursor: null, - loadingHistory: false, - displayItems: [ - { - id: "agent-1", - kind: "agent", - role: "tool", - text: "Wait for agent", - turnId: "turn", - tool: "wait", - status: "running", - senderThreadId: "parent", - receiverThreadIds: ["done", "running"], - prompt: null, - model: null, - reasoningEffort: null, - agents: [ - { threadId: "done", status: "completed", message: null }, - { threadId: "running", status: "running", message: "Inspecting renderer" }, - ], - }, - ], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (element, text) => element.createDiv({ text }), - renderTextWithWikiLinks: (element, text) => element.createDiv({ text }), - }), - ); - - const summary = expectPresent(parent.querySelector('[data-codex-panel-block-key="live-agents:turn"]')); - expect(summary.querySelector(".codex-panel__agent-summary")?.textContent).toContain("Agents 1 running, 1 done"); - expect(summary.textContent).toContain("runningrunning: Inspecting renderer"); - unmountReactRoot(parent); - }); - - it("renders active reasoning as a React message stream block", () => { - const parent = document.createElement("div"); - - renderMessageStreamBlocks( - parent, - messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: runningTurnLifecycle("turn"), - historyCursor: null, - loadingHistory: false, - displayItems: [{ id: "reasoning-1", kind: "reasoning", role: "tool", text: "", turnId: "turn", status: "running" }], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (element, text) => element.createDiv({ text }), - renderTextWithWikiLinks: (element, text) => element.createDiv({ text }), - }), - ); - - const reasoning = expectPresent( - parent.querySelector('[data-codex-panel-block-key="item:reasoning-1"] .codex-panel__reasoning'), - ); - expect(reasoning.classList.contains("is-active")).toBe(true); - expect(reasoning.querySelector(".codex-panel__reasoning-role")?.textContent).toBe("reasoning"); - expect(reasoning.querySelector(".codex-panel__reasoning-content")?.textContent).toBe("Reasoning..."); - unmountReactRoot(parent); - }); - - it("hides the live agent summary once all subagents are complete", () => { - const blocks = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: runningTurnLifecycle("turn"), - historyCursor: null, - loadingHistory: false, - displayItems: [ - { - id: "agent-1", - kind: "agent", - role: "tool", - text: "Wait for agent", - turnId: "turn", - tool: "wait", - status: "completed", - senderThreadId: "parent", - receiverThreadIds: ["done"], - prompt: null, - model: null, - reasoningEffort: null, - agents: [{ threadId: "done", status: "completed", message: null }], - }, - ], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - }); - - expect(blocks.some((block) => block.key.startsWith("live-agents:"))).toBe(false); - }); - - it("marks the live agent summary failed when any subagent fails", () => { - const blocks = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: runningTurnLifecycle("turn"), - historyCursor: null, - loadingHistory: false, - displayItems: [ - { - id: "agent-1", - kind: "agent", - role: "tool", - text: "Wait for agent", - turnId: "turn", - tool: "wait", - status: "completed", - senderThreadId: "parent", - receiverThreadIds: ["failed", "running"], - prompt: null, - model: null, - reasoningEffort: null, - agents: [ - { threadId: "failed", status: "errored", message: "Failed" }, - { threadId: "running", status: "running", message: null }, - ], - }, - ], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - }); - - const summary = renderMessageBlockElement(expectPresent(blocks.at(-1))); - - expect(summary.classList.contains("codex-panel__execution--failed")).toBe(true); - expect(summary.textContent).toContain("Agents 1 failed, 1 running"); - expect(summary.textContent).toContain("runningrunning"); - expect(summary.textContent).not.toContain("failederrored: Failed"); - }); -}); - -describe("pending request renderer decisions", () => { - it("renders pending requests as one message-stream block and keeps user input drafts live", () => { - const parent = document.createElement("div"); - const drafts = new Map(); - const resolveUserInput = vi.fn(); - const input = pendingUserInput(); - - renderPendingRequestNode( - parent, - [], - [input], - { - values: drafts, - draftKey: (requestId, questionId) => `${String(requestId)}:${questionId}`, - otherDraftKey: (requestId, questionId) => `${String(requestId)}:${questionId}:other`, - }, - new Set(), - pendingRequestActions({ resolveUserInput }), - ); - - expect(parent.querySelectorAll(".codex-panel__pending-request-message")).toHaveLength(1); - expect(parent.querySelector(".codex-panel__pending-request-message")?.classList.contains("codex-panel__work-message")).toBe(true); - expect(parent.querySelector(".codex-panel__pending-request-message")?.classList.contains("codex-panel__work-message--warning")).toBe( - true, - ); - expect(parent.querySelector(".codex-panel__pending-request-button.mod-cta")?.textContent).toBe("Submit"); - expect(parent.querySelector(".codex-panel__user-input-answer .codex-panel__user-input-radio")).not.toBeNull(); - expect(parent.querySelector(".codex-panel__user-input-radio")?.checked).toBe(true); - expect(parent.querySelector(".codex-panel__user-input-radio")?.type).toBe("radio"); - expect(parent.querySelector(".codex-panel__user-input-marker")).toBeNull(); - parent.querySelector(".mod-cta")?.click(); - expect(resolveUserInput).toHaveBeenCalledWith(input); - }); - - it("selects the other Plan mode answer from controlled drafts", () => { - const parent = document.createElement("div"); - const drafts = new Map(); - const input = pendingOtherUserInput(); - const draftKey = (requestId: PendingUserInput["requestId"], questionId: string) => `${String(requestId)}:${questionId}`; - const otherDraftKey = (requestId: PendingUserInput["requestId"], questionId: string) => `${String(requestId)}:${questionId}:other`; - const actions = pendingRequestActions({ - setUserInputDraft: vi.fn((key: string, value: string) => { - drafts.set(key, value); - }), - }); - const render = () => { - renderPendingRequestNode(parent, [], [input], { values: drafts, draftKey, otherDraftKey }, new Set(), actions); - }; - - render(); - changeInputValue(expectPresent(parent.querySelector(".codex-panel__user-input-other-text")), "Custom scope"); - - expect(actions.setUserInputDraft).toHaveBeenCalledWith("99:scope:other", "Custom scope"); - expect(actions.setUserInputDraft).toHaveBeenCalledWith("99:scope", "Custom scope"); - - render(); - const radios = [...parent.querySelectorAll(".codex-panel__user-input-radio")]; - expect(radios.map((radio) => radio.checked)).toEqual([false, true]); - }); - - it("keeps the other Plan mode radio selected when the custom answer is empty", () => { - const parent = document.createElement("div"); - const drafts = new Map(); - const input = pendingOtherUserInput(); - const draftKey = (requestId: PendingUserInput["requestId"], questionId: string) => `${String(requestId)}:${questionId}`; - const otherDraftKey = (requestId: PendingUserInput["requestId"], questionId: string) => `${String(requestId)}:${questionId}:other`; - const actions = pendingRequestActions({ - setUserInputDraft: vi.fn((key: string, value: string) => { - drafts.set(key, value); - }), - }); - const render = () => { - renderPendingRequestNode(parent, [], [input], { values: drafts, draftKey, otherDraftKey }, new Set(), actions); - }; - - render(); - const radios = [...parent.querySelectorAll(".codex-panel__user-input-radio")]; - expect(radios.map((radio) => radio.checked)).toEqual([true, false]); - - expectPresent(radios.at(1)).click(); - - expect(actions.setUserInputDraft).toHaveBeenCalledWith("99:scope", ""); - - render(); - const rerenderedRadios = [...parent.querySelectorAll(".codex-panel__user-input-radio")]; - expect(rerenderedRadios.map((radio) => radio.checked)).toEqual([false, true]); - }); - - it("keeps unselected other Plan mode text out of tab order", () => { - const parent = document.createElement("div"); - const drafts = new Map(); - const input = pendingOtherUserInput(); - const draftKey = (requestId: PendingUserInput["requestId"], questionId: string) => `${String(requestId)}:${questionId}`; - const otherDraftKey = (requestId: PendingUserInput["requestId"], questionId: string) => `${String(requestId)}:${questionId}:other`; - const actions = pendingRequestActions({ - setUserInputDraft: vi.fn((key: string, value: string) => { - drafts.set(key, value); - }), - }); - const render = () => { - renderPendingRequestNode(parent, [], [input], { values: drafts, draftKey, otherDraftKey }, new Set(), actions); - }; - - render(); - - expect(parent.querySelector(".codex-panel__user-input-other-text")?.tabIndex).toBe(-1); - - expectPresent(parent.querySelectorAll(".codex-panel__user-input-radio").item(1)).click(); - render(); - - expect(parent.querySelector(".codex-panel__user-input-other-text")?.tabIndex).toBe(0); - }); - - it("does not commit other Plan mode IME preedit text as the answer", () => { - const parent = document.createElement("div"); - const drafts = new Map(); - const input = pendingOtherUserInput(); - const draftKey = (requestId: PendingUserInput["requestId"], questionId: string) => `${String(requestId)}:${questionId}`; - const otherDraftKey = (requestId: PendingUserInput["requestId"], questionId: string) => `${String(requestId)}:${questionId}:other`; - const actions = pendingRequestActions({ - setUserInputDraft: vi.fn((key: string, value: string) => { - drafts.set(key, value); - }), - }); - - renderPendingRequestNode(parent, [], [input], { values: drafts, draftKey, otherDraftKey }, new Set(), actions); - const otherInput = expectPresent(parent.querySelector(".codex-panel__user-input-other-text")); - - otherInput.dispatchEvent(new Event("compositionstart", { bubbles: true })); - dispatchComposingInputValue(otherInput, "にほん"); - - expect(actions.setUserInputDraft).toHaveBeenCalledWith("99:scope", ""); - expect(actions.setUserInputDraft).not.toHaveBeenCalledWith("99:scope:other", "にほん"); - expect(actions.setUserInputDraft).not.toHaveBeenCalledWith("99:scope", "にほん"); - - setNativeInputValue(otherInput, "日本"); - otherInput.dispatchEvent(new Event("compositionend", { bubbles: true })); - - expect(actions.setUserInputDraft).toHaveBeenCalledWith("99:scope:other", "日本"); - expect(actions.setUserInputDraft).toHaveBeenCalledWith("99:scope", "日本"); - }); - - it("renders pending approvals and Plan mode questions in the same request block", () => { - const parent = document.createElement("div"); - const approval = pendingApproval(); - const input = pendingUserInput(); - - renderPendingRequestNode( - parent, - [approval], - [input], - { - values: new Map(), - draftKey: (requestId, questionId) => `${String(requestId)}:${questionId}`, - otherDraftKey: (requestId, questionId) => `${String(requestId)}:${questionId}:other`, - }, - new Set(), - pendingRequestActions(), - ); - - expect(parent.querySelectorAll(".codex-panel__pending-request-message")).toHaveLength(1); - expect(parent.querySelectorAll(".codex-panel__pending-request-card")).toHaveLength(2); - expect(parent.querySelectorAll(".codex-panel__pending-request-body")).toHaveLength(2); - expect(parent.querySelector(".codex-panel__approval-body")).toBeNull(); - expect(parent.querySelector(".codex-panel__approval .codex-panel__pending-request-title")?.textContent).toBe("Permission approval"); - expect(parent.querySelector(".codex-panel__approval .codex-panel__pending-request-body")?.textContent).toContain("Need network"); - expect(parent.querySelector(".codex-panel__approval-details summary")?.textContent).toBe("Request details"); - expect(parent.querySelector(".codex-panel__user-input .codex-panel__pending-request-title")?.textContent).toBe("Codex needs input"); - expect([...parent.querySelectorAll(".codex-panel__pending-request-button")].map((button) => button.textContent)).toEqual([ - "Allow", - "Allow session", - "Deny", - "Cancel", - "Submit", - "Cancel", - ]); - }); - - it("focuses Plan mode input when pending requests ask for autofocus", () => { - const parent = document.createElement("div"); - document.body.appendChild(parent); - const input = pendingFreeformUserInput(); - - try { - renderPendingRequestNode( - parent, - [pendingApproval()], - [input], - { - values: new Map(), - draftKey: (requestId, questionId) => `${String(requestId)}:${questionId}`, - otherDraftKey: (requestId, questionId) => `${String(requestId)}:${questionId}:other`, - }, - new Set(), - pendingRequestActions(), - true, - ); - - expect(document.activeElement).toBe(parent.querySelector(".codex-panel__user-input-text")); - } finally { - unmountReactRoot(parent); - parent.remove(); - } - }); - - it("focuses the selected Plan mode option before the other text field", () => { - const parent = document.createElement("div"); - document.body.appendChild(parent); - const input = pendingOtherUserInput(); - - try { - renderPendingRequestNode( - parent, - [], - [input], - { - values: new Map(), - draftKey: (requestId, questionId) => `${String(requestId)}:${questionId}`, - otherDraftKey: (requestId, questionId) => `${String(requestId)}:${questionId}:other`, - }, - new Set(), - pendingRequestActions(), - true, - ); - - expect(document.activeElement).toBe(parent.querySelector(".codex-panel__user-input-radio:checked")); - expect(document.activeElement).not.toBe(parent.querySelector(".codex-panel__user-input-other-text")); - } finally { - unmountReactRoot(parent); - parent.remove(); - } - }); - - it("focuses the approval action when pending approval asks for autofocus", () => { - const parent = document.createElement("div"); - document.body.appendChild(parent); - - try { - renderPendingRequestNode( - parent, - [pendingApproval()], - [], - { - values: new Map(), - draftKey: (requestId, questionId) => `${String(requestId)}:${questionId}`, - otherDraftKey: (requestId, questionId) => `${String(requestId)}:${questionId}:other`, - }, - new Set(), - pendingRequestActions(), - true, - ); - - expect(document.activeElement).toBe(parent.querySelector(".codex-panel__pending-request-button.mod-cta")); - } finally { - unmountReactRoot(parent); - parent.remove(); - } - }); - - it("renders command approval buttons from app-server available decisions", () => { - const parent = document.createElement("div"); - const approval: PendingApproval = { - requestId: 43, - method: "item/commandExecution/requestApproval", - params: { - threadId: "thread", - turnId: "turn", - itemId: "command", - startedAtMs: 1, - reason: "Needs network", - networkApprovalContext: { host: "registry.npmjs.org", protocol: "https" }, - command: null, - cwd: "/vault", - commandActions: [], - proposedExecpolicyAmendment: null, - proposedNetworkPolicyAmendments: [], - availableDecisions: [ - { applyNetworkPolicyAmendment: { network_policy_amendment: { host: "registry.npmjs.org", action: "allow" } } }, - "decline", - ], - }, - }; - const resolveApproval = vi.fn(); - - renderPendingRequestNode( - parent, - [approval], - [], - { - values: new Map(), - draftKey: (requestId, questionId) => `${String(requestId)}:${questionId}`, - otherDraftKey: (requestId, questionId) => `${String(requestId)}:${questionId}:other`, - }, - new Set(), - pendingRequestActions({ resolveApproval }), - ); - - const buttons = [...parent.querySelectorAll(".codex-panel__pending-request-button")]; - expect(buttons.map((button) => button.textContent)).toEqual(["Allow network rule", "Deny"]); - const allowButton = buttons.at(0); - if (!allowButton) throw new Error("Missing allow button"); - allowButton.click(); - expect(resolveApproval).toHaveBeenCalledWith(approval, { - kind: "command-decision", - decision: { applyNetworkPolicyAmendment: { network_policy_amendment: { host: "registry.npmjs.org", action: "allow" } } }, - }); - }); - - it("renders submitted user input separately from approvals", () => { - const block = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: idleTurnLifecycle(), - historyCursor: null, - loadingHistory: false, - displayItems: [ - { - id: "user-input-submitted-1", - kind: "userInputResult", - role: "tool", - text: "Input submitted for 1 question.", - turnId: "turn", - markdown: false, - state: "completed", - details: [{ title: "Question: Scope", rows: [{ key: "Answer", value: "Narrow" }] }], - }, - ], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - })[0]; - - const element = renderMessageBlockElement(block); - - expect(element.querySelector(".codex-panel__message-role")?.textContent).toBe("Input"); - expect(element.textContent).not.toContain("Approval"); - expect(element.querySelector("details summary")?.textContent).toBe("Question: Scope"); - expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("AnswerNarrow"); - }); - - it("renders manual approval results with completion state and details", () => { - const block = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: idleTurnLifecycle(), - historyCursor: null, - loadingHistory: false, - displayItems: [ - { - id: "approval-1", - kind: "approvalResult", - role: "tool", - text: "Allowed for this session: Need access", - turnId: "turn", - markdown: false, - state: "completed", - details: [ - { - title: "Approval", - rows: [ - { key: "status", value: "allowed for session" }, - { key: "scope", value: "session" }, - ], - }, - ], - }, - ], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - })[0]; - - const element = renderMessageBlockElement(block); - - expect(element.classList.contains("codex-panel__message--approval-result")).toBe(true); - expect(element.classList.contains("codex-panel__tool-result")).toBe(true); - expect(element.classList.contains("codex-panel__execution--completed")).toBe(true); - expect(element.querySelector(".codex-panel__message-content")).toBeNull(); - expect(element.querySelector(".codex-panel__tool-result-header")?.textContent).toBe("approval"); - expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("Allowed for this session: Need access"); - expect(element.querySelector("details summary")?.textContent).toBe("approval"); - expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("statusallowed for session"); - expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("scopesession"); - }); - - it("renders auto-review summaries under the final assistant message", () => { - const block = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: idleTurnLifecycle(), - historyCursor: null, - loadingHistory: false, - displayItems: [ - { - id: "assistant-1", - kind: "message", - role: "assistant", - text: "Done", - turnId: "turn", - markdown: true, - autoReviewSummaries: ["Auto-review approved: npm test", "Auto-review approved: npm test"], - }, - ], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - })[0]; - - const element = renderMessageBlockElement(block); - - expect(element.querySelector(".codex-panel__auto-reviews summary")?.textContent).toBe("Auto-reviewed 2 requests"); - expect(element.querySelector(".codex-panel__auto-reviews")?.textContent).toContain("Auto-review approved: npm test"); - }); - - it("adds pending requests to the bottom of message stream blocks", () => { - const blocks = messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: idleTurnLifecycle(), - historyCursor: null, - loadingHistory: false, - displayItems: [{ id: "a1", kind: "message", role: "assistant", text: "Done", markdown: true }], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (parent, text) => parent.createDiv({ text }), - renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), - pendingRequestsSignature: "request:1", - renderPendingRequests: () => "Request", - }); - - expect(blocks.map((block) => block.key)).toEqual(["item:a1", "pending-requests"]); - expect(expectPresent(blocks[1]).node).not.toBeUndefined(); - }); - - it("keeps pending request React events mounted in the message stream host", () => { - const parent = document.createElement("div"); - const approval = pendingApproval(); - const resolveApproval = vi.fn(); - - renderMessageStreamBlocks( - parent, - messageStreamBlocks({ - activeThreadId: "thread", - turnLifecycle: idleTurnLifecycle(), - historyCursor: null, - loadingHistory: false, - displayItems: [{ id: "a1", kind: "message", role: "assistant", text: "Waiting", markdown: true }], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (element, text) => element.createDiv({ text }), - renderTextWithWikiLinks: (element, text) => element.createDiv({ text }), - pendingRequestsSignature: "approval:1", - renderPendingRequests: () => - pendingRequestMessageNode( - [approval], - [], - { - values: new Map(), - draftKey: (requestId, questionId) => `${String(requestId)}:${questionId}`, - otherDraftKey: (requestId, questionId) => `${String(requestId)}:${questionId}:other`, - }, - new Set(), - pendingRequestActions({ resolveApproval }), - ), - }), - ); - - parent.querySelector(".codex-panel__pending-request-button")?.click(); - - expect(resolveApproval).toHaveBeenCalledWith(approval, "accept"); - unmountReactRoot(parent); - }); - - it("removes pending request blocks when the signature clears", () => { - const parent = document.createElement("div"); - const baseContext = { - activeThreadId: "thread", - turnLifecycle: idleTurnLifecycle(), - historyCursor: null, - loadingHistory: false, - displayItems: [{ id: "a1", kind: "message", role: "assistant", text: "Done", markdown: true }] satisfies DisplayItem[], - openDetails: new Set(), - loadOlderTurns: vi.fn(), - renderMarkdown: (element: HTMLElement, text: string) => element.createDiv({ text }), - renderTextWithWikiLinks: (element: HTMLElement, text: string) => element.createDiv({ text }), - }; - - renderMessageStreamBlocks( - parent, - messageStreamBlocks({ - ...baseContext, - pendingRequestsSignature: "request:1", - renderPendingRequests: () => , - }), - ); - expect(parent.querySelector('[data-codex-panel-block-key="pending-requests"]')).not.toBeNull(); - - renderMessageStreamBlocks(parent, messageStreamBlocks(baseContext)); - - expect(parent.querySelector('[data-codex-panel-block-key="pending-requests"]')).toBeNull(); - expect(parent.querySelector('[data-codex-panel-block-key="item:a1"]')).not.toBeNull(); - unmountReactRoot(parent); - }); -}); - -function pendingUserInput(): PendingUserInput { - return { - requestId: 99, - method: "item/tool/requestUserInput", - params: { - threadId: "thread", - turnId: "turn", - itemId: "input", - questions: [ - { - id: "scope", - header: "Scope", - question: "How broad?", - isOther: false, - isSecret: false, - options: [{ label: "Narrow", description: "Small change" }], - }, - ], - }, - }; -} - -function pendingOtherUserInput(): PendingUserInput { - const input = pendingUserInput(); - return { - ...input, - params: { - ...input.params, - questions: [ - { - ...expectPresent(input.params.questions[0]), - isOther: true, - options: [{ label: "Narrow", description: "Small change" }], - }, - ], - }, - }; -} - -function pendingFreeformUserInput(): PendingUserInput { - const input = pendingUserInput(); - return { - ...input, - params: { - ...input.params, - questions: [ - { - ...expectPresent(input.params.questions[0]), - options: null, - }, - ], - }, - }; -} - -function pendingApproval(): PendingApproval { - return { - requestId: 42, - method: "item/permissions/requestApproval", - params: { - threadId: "thread", - turnId: "turn", - itemId: "permission", - startedAtMs: 1, - cwd: "/vault", - reason: "Need network", - permissions: { network: { enabled: true }, fileSystem: null }, - }, - }; -} diff --git a/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx b/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx new file mode 100644 index 00000000..6a3662f6 --- /dev/null +++ b/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx @@ -0,0 +1,1242 @@ +// @vitest-environment jsdom + +import { describe, expect, it, vi } from "vitest"; +import { act, createElement } from "react"; + +import type { DisplayItem } from "../../../../../src/features/chat/display/types"; +import { implementPlanCandidateFromState } from "../../../../../src/features/chat/chat-message-renderer"; +import { renderMessageStreamBlocks } from "../../../../../src/features/chat/ui/message-stream"; +import { topLevelDetailsSummaries } from "../../../../support/dom"; +import { renderReactRoot, unmountReactRoot } from "../../../../../src/shared/ui/react-root"; +import "./setup"; +import { + expectPresent, + idleTurnLifecycle, + messageStreamBlocks, + renderMessageBlockElement, + runningTurnLifecycle, + startingTurnLifecycle, + testMessageStreamBlock, + withMessageContentScrollHeight, +} from "./test-helpers"; + +describe("message stream block identity and message actions", () => { + it("reuses keyed React message block hosts across rerenders", () => { + const parent = document.createElement("div"); + renderMessageStreamBlocks(parent, [testMessageStreamBlock("one", createElement("section", null, "first"))]); + + const host = expectPresent(parent.querySelector('[data-codex-panel-block-key="one"]')); + expect(host.classList.contains("codex-panel__message-block")).toBe(true); + expect(host.firstElementChild?.tagName).toBe("SECTION"); + expect(host.textContent).toBe("first"); + + renderMessageStreamBlocks(parent, [testMessageStreamBlock("one", createElement("section", null, "still first"))]); + + expect(parent.querySelector('[data-codex-panel-block-key="one"]')).toBe(host); + expect(host.firstElementChild?.tagName).toBe("SECTION"); + expect(host.textContent).toBe("still first"); + + renderMessageStreamBlocks(parent, [testMessageStreamBlock("one", createElement("article", null, "replacement"))]); + + expect(parent.querySelector('[data-codex-panel-block-key="one"]')).toBe(host); + expect(host.firstElementChild?.tagName).toBe("ARTICLE"); + renderMessageStreamBlocks(parent, []); + expect(parent.querySelector('[data-codex-panel-block-key="one"]')).toBeNull(); + unmountReactRoot(parent); + }); + + it("leaves stable ordered React message block hosts in place during repeated renders", () => { + const parent = document.createElement("div"); + renderMessageStreamBlocks(parent, [ + testMessageStreamBlock("one", createElement("section", null, "one")), + testMessageStreamBlock("two", createElement("article", null, "two")), + ]); + const firstHost = expectPresent(parent.querySelector('[data-codex-panel-block-key="one"]')); + const secondHost = expectPresent(parent.querySelector('[data-codex-panel-block-key="two"]')); + + const insertBefore = vi.spyOn(parent, "insertBefore"); + renderMessageStreamBlocks(parent, [ + testMessageStreamBlock("one", createElement("section", null, "one again")), + testMessageStreamBlock("two", createElement("article", null, "two again")), + ]); + + expect(insertBefore).not.toHaveBeenCalled(); + expect([...parent.children]).toEqual([firstHost, secondHost]); + expect(firstHost.firstElementChild?.tagName).toBe("SECTION"); + expect(secondHost.firstElementChild?.tagName).toBe("ARTICLE"); + unmountReactRoot(parent); + }); + + it("inserts completed-turn activity groups without replacing stable conversation nodes", () => { + const parent = document.createElement("div"); + const baseContext = { + activeThreadId: "thread", + turnLifecycle: idleTurnLifecycle(), + historyCursor: null, + loadingHistory: false, + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (element: HTMLElement, text: string) => element.createDiv({ text }), + renderTextWithWikiLinks: (element: HTMLElement, text: string) => element.createDiv({ text }), + }; + + renderMessageStreamBlocks( + parent, + messageStreamBlocks({ + ...baseContext, + displayItems: [ + { id: "u1", kind: "message", role: "user", text: "do it", turnId: "t1", markdown: true }, + { id: "a1", kind: "message", role: "assistant", text: "done", turnId: "t1", markdown: true }, + ], + }), + ); + const userNode = parent.querySelector('[data-codex-panel-block-key="item:u1"]'); + const assistantNode = parent.querySelector('[data-codex-panel-block-key="item:a1"]'); + + renderMessageStreamBlocks( + parent, + messageStreamBlocks({ + ...baseContext, + displayItems: [ + { id: "u1", kind: "message", role: "user", text: "do it", turnId: "t1", markdown: true }, + { + id: "hook-1", + kind: "hook", + role: "tool", + text: "userPromptSubmit: Saving jj baseline", + toolLabel: "hook", + turnId: "t1", + status: "completed", + }, + { id: "a1", kind: "message", role: "assistant", text: "done", turnId: "t1", markdown: true }, + ], + }), + ); + + expect(parent.querySelector('[data-codex-panel-block-key="item:u1"]')).toBe(userNode); + expect(parent.querySelector('[data-codex-panel-block-key="item:a1"]')).toBe(assistantNode); + expect([...parent.children].map((element) => element.getAttribute("data-codex-panel-block-key"))).toEqual([ + "item:u1", + "activity:turn-t1-activity", + "item:a1", + ]); + expect(parent.querySelector('[data-codex-panel-block-key="activity:turn-t1-activity"] summary')?.textContent).toBe( + "Work details: hook", + ); + unmountReactRoot(parent); + }); + + it("renders the history bar as a React block", () => { + const loadOlderTurns = vi.fn(); + const [historyBlock] = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: idleTurnLifecycle(), + historyCursor: "cursor", + loadingHistory: false, + displayItems: [], + openDetails: new Set(), + loadOlderTurns, + renderMarkdown: (element, text) => element.createDiv({ text }), + renderTextWithWikiLinks: (element, text) => element.createDiv({ text }), + }); + const parent = document.createElement("div"); + + expect(historyBlock.key).toBe("history-bar"); + expect(historyBlock.node).not.toBeUndefined(); + renderReactRoot(parent, historyBlock.node); + + const button = expectPresent(parent.querySelector("button")); + expect(parent.querySelector(".codex-panel__history-bar")).not.toBeNull(); + expect(button.textContent).toBe("Load older"); + expect(button.disabled).toBe(false); + + button.click(); + + expect(loadOlderTurns).toHaveBeenCalledOnce(); + unmountReactRoot(parent); + }); + + it("renders the empty message stream state as a React block", () => { + const [emptyBlock] = messageStreamBlocks({ + activeThreadId: null, + turnLifecycle: idleTurnLifecycle(), + historyCursor: null, + loadingHistory: false, + displayItems: [], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (element, text) => element.createDiv({ text }), + renderTextWithWikiLinks: (element, text) => element.createDiv({ text }), + }); + const parent = document.createElement("div"); + + expect(emptyBlock.key).toBe("empty"); + expect(emptyBlock.node).not.toBeUndefined(); + renderReactRoot(parent, emptyBlock.node); + + const empty = expectPresent(parent.querySelector(".codex-panel__message--system")); + expect(empty.classList.contains("codex-panel__message")).toBe(true); + expect(empty.textContent).toBe("Send a message to start a conversation."); + unmountReactRoot(parent); + }); + + it("renders review result items as compact auto-review tool rows", () => { + const block = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: idleTurnLifecycle(), + historyCursor: null, + loadingHistory: false, + displayItems: [{ id: "review-1", kind: "reviewResult", role: "tool", text: "Auto-review denied this command.", markdown: false }], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + })[0]; + + const element = renderMessageBlockElement(block); + + expect(element.classList.contains("codex-panel__message--review-result")).toBe(true); + expect(element.classList.contains("codex-panel__tool-result--plain")).toBe(true); + expect(element.querySelector(".codex-panel__message-role")?.textContent).toBe("auto-review"); + expect(element.textContent).toContain("Auto-review denied this command."); + expect(element.querySelector("details")).toBeNull(); + }); + + it("renders review result details inside one auto-review details block", () => { + const block = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: idleTurnLifecycle(), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { + id: "review-1", + kind: "reviewResult", + role: "tool", + text: "Auto-review approved: npm test", + turnId: "turn", + markdown: false, + state: "completed", + details: [ + { + title: "Review", + rows: [ + { key: "status", value: "approved" }, + { key: "action", value: "apply patch" }, + { key: "files", value: "src/display/tool-view.ts\nsrc/ui/message-stream.ts" }, + ], + }, + ], + }, + ], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + })[0]; + + const element = renderMessageBlockElement(block); + + expect(element.classList.contains("codex-panel__execution--completed")).toBe(true); + expect(topLevelDetailsSummaries(element)).toEqual(["auto-review"]); + expect([...element.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["auto-review"]); + expect(element.textContent).not.toContain("Details"); + expect(element.textContent).not.toContain("▶Review"); + expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("statusapproved"); + expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("actionapply patch"); + expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain( + "filessrc/display/tool-view.ts\nsrc/ui/message-stream.ts", + ); + expect([...element.querySelectorAll(".codex-panel__output-title")].map((title) => title.textContent)).toEqual([]); + }); + + it("keeps tool result React details mounted in the message stream host", () => { + const parent = document.createElement("div"); + const onDetailsToggle = vi.fn(); + const renderTextWithWikiLinks = vi.fn((element: HTMLElement, text: string) => { + element.createDiv({ text: `linked:${text}` }); + }); + + renderMessageStreamBlocks( + parent, + messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: idleTurnLifecycle(), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { + id: "cmd-1", + kind: "command", + role: "tool", + text: "npm test", + command: "npm test", + cwd: "/vault", + status: "completed", + output: "ok", + state: "completed", + }, + ], + openDetails: new Set(), + onDetailsToggle, + loadOlderTurns: vi.fn(), + renderMarkdown: (element, text) => element.createDiv({ text }), + renderTextWithWikiLinks, + }), + ); + + const block = expectPresent(parent.querySelector('[data-codex-panel-block-key="item:cmd-1"]')); + const result = expectPresent(block.querySelector(".codex-panel__tool-result")); + expect(result.classList.contains("codex-panel__execution--completed")).toBe(true); + expect(result.querySelector(".codex-panel__tool-result-header")?.textContent).toBe("command"); + expect(result.querySelector(":scope > .codex-panel__tool-summary")?.textContent).toBe("linked:npm test"); + expect(result.querySelector(".codex-panel__tool-summary")?.textContent).toBe("linked:npm test"); + expect(result.querySelector(".codex-panel__meta-grid")?.textContent).toContain("commandnpm test"); + expect(result.querySelector(".codex-panel__output-title")?.textContent).toBe("Output"); + expect(renderTextWithWikiLinks).toHaveBeenCalledWith(expect.any(HTMLElement), "npm test"); + + const details = expectPresent(result.querySelector("details")); + details.open = true; + details.dispatchEvent(new Event("toggle", { bubbles: false })); + + expect(onDetailsToggle).toHaveBeenCalledWith("cmd-1:command-details", true); + unmountReactRoot(parent); + }); + + it("renders file change diffs through the React tool result adapter", () => { + const parent = document.createElement("div"); + + renderMessageStreamBlocks( + parent, + messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: idleTurnLifecycle(), + historyCursor: null, + loadingHistory: false, + workspaceRoot: "/vault", + displayItems: [ + { + id: "file-1", + kind: "fileChange", + role: "tool", + text: "Changed 1 file", + status: "completed", + changes: [{ kind: "modified", path: "/vault/src/app.ts", diff: "-old\n+new" }], + }, + ], + openDetails: new Set(["file-1:file-change-details"]), + loadOlderTurns: vi.fn(), + renderMarkdown: (element, text) => element.createDiv({ text }), + renderTextWithWikiLinks: (element, text) => element.createDiv({ text }), + }), + ); + + expect(parent.querySelector(".codex-panel__tool-summary")?.textContent).toBe("src/app.ts"); + expect(parent.querySelector(".codex-panel-diff-file .codex-panel__output-title")?.textContent).toBe("modified src/app.ts"); + expect([...parent.querySelectorAll(".codex-panel-diff__line")].map((line) => line.textContent)).toEqual(["old", "new"]); + unmountReactRoot(parent); + }); + + it("renders structured system result details as visible selectable meta rows", () => { + const block = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: idleTurnLifecycle(), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { + id: "system-help", + kind: "system", + role: "system", + text: "Available slash commands", + markdown: false, + details: [ + { + rows: [ + { key: "/help", value: "Show available Codex slash commands." }, + { key: "/resume [thread]", value: "Resume a recent Codex thread." }, + ], + }, + ], + }, + ], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + })[0]; + + const element = renderMessageBlockElement(block); + + expect(element.classList.contains("codex-panel__message--system")).toBe(true); + expect(element.querySelector(".codex-panel__message-content")?.textContent).toBe("Available slash commands"); + expect(element.querySelector("details")).toBeNull(); + expect(element.querySelector(".codex-panel__output-title")).toBeNull(); + expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("/helpShow available Codex slash commands."); + expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("/resume [thread]Resume a recent Codex thread."); + }); + + it("renders rollback action only for the eligible user message", () => { + const onRollbackItem = vi.fn(); + const items = [ + { id: "u1", kind: "message", role: "user", text: "older", turnId: "turn-1", markdown: true }, + { id: "a1", kind: "message", role: "assistant", text: "older answer", turnId: "turn-1", markdown: true }, + { id: "u2", kind: "message", role: "user", text: "latest", turnId: "turn-2", markdown: true }, + ] as const; + const blocks = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: idleTurnLifecycle(), + historyCursor: null, + loadingHistory: false, + displayItems: [...items], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + canRollbackItem: (item) => item.id === "u2", + onRollbackItem, + }); + + const rendered = blocks.map((block) => renderMessageBlockElement(block)); + + expect(expectPresent(rendered[0]).querySelector(".codex-panel__rollback-turn")).toBeNull(); + expect(expectPresent(rendered[1]).querySelector(".codex-panel__rollback-turn")).toBeNull(); + const button = expectPresent(rendered[2]).querySelector(".codex-panel__rollback-turn"); + expect(button?.getAttribute("aria-label")).toBe("Rollback last turn"); + button?.click(); + expect(onRollbackItem).toHaveBeenCalledWith(expect.objectContaining({ id: "u2" })); + }); + + it("renders copy actions for copyable messages", () => { + const copyText = vi.fn(); + const blocks = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: idleTurnLifecycle(), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { id: "u1", kind: "message", role: "user", text: "rendered user", copyText: "**user**", turnId: "turn-1", markdown: true }, + { id: "a1", kind: "message", role: "assistant", text: "rendered answer", copyText: "# Answer", turnId: "turn-1", markdown: true }, + ], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + copyText, + }); + + const rendered = blocks.map((block) => renderMessageBlockElement(block)); + const userButton = expectPresent(rendered[0]).querySelector(".codex-panel__copy-message"); + const assistantButton = expectPresent(rendered[1]).querySelector(".codex-panel__copy-message"); + + expect(userButton?.getAttribute("aria-label")).toBe("Copy message"); + expect(assistantButton?.getAttribute("aria-label")).toBe("Copy message"); + userButton?.click(); + assistantButton?.click(); + expect(copyText).toHaveBeenNthCalledWith(1, "**user**"); + expect(copyText).toHaveBeenNthCalledWith(2, "# Answer"); + }); + + it("expands assistant fork actions in the copy slot and defaults repeat clicks to plain fork", () => { + const onDetailsToggle = vi.fn(); + const onForkItem = vi.fn(); + const item: DisplayItem = { + id: "a1", + kind: "message", + role: "assistant", + text: "answer", + copyText: "answer", + turnId: "turn-1", + markdown: true, + }; + + const closedBlock = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: idleTurnLifecycle(), + historyCursor: null, + loadingHistory: false, + displayItems: [item], + openDetails: new Set(), + onDetailsToggle, + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + copyText: vi.fn(), + canForkItem: () => true, + onForkItem, + })[0]; + + const closedElement = renderMessageBlockElement(closedBlock); + expect(closedElement.querySelector(".codex-panel__copy-message")).not.toBeNull(); + const initialFork = expectPresent(closedElement.querySelector(".codex-panel__fork-message")); + expect(initialFork.getAttribute("aria-label")).toBe("Fork from here"); + initialFork.click(); + expect(onDetailsToggle).toHaveBeenCalledWith("message:fork-actions:a1", true); + + const openBlock = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: idleTurnLifecycle(), + historyCursor: null, + loadingHistory: false, + displayItems: [item], + openDetails: new Set(["message:fork-actions:a1"]), + onDetailsToggle, + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + copyText: vi.fn(), + canForkItem: () => true, + onForkItem, + })[0]; + + const openElement = renderMessageBlockElement(openBlock); + expect(openElement.querySelector(".codex-panel__copy-message")).toBeNull(); + expect(openElement.querySelector(".codex-panel__fork-and-archive-message")?.getAttribute("aria-label")).toBe( + "Fork and archive", + ); + const openFork = expectPresent(openElement.querySelector(".codex-panel__fork-message")); + expect(openFork.getAttribute("aria-label")).toBe("Fork"); + openFork.click(); + expect(onForkItem).toHaveBeenCalledWith(expect.objectContaining({ id: "a1" }), false); + }); + + it("runs fork and archive from the expanded assistant fork actions", () => { + const onForkItem = vi.fn(); + const item: DisplayItem = { + id: "a1", + kind: "message", + role: "assistant", + text: "answer", + copyText: "answer", + turnId: "turn-1", + markdown: true, + }; + const block = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: idleTurnLifecycle(), + historyCursor: null, + loadingHistory: false, + displayItems: [item], + openDetails: new Set(["message:fork-actions:a1"]), + onDetailsToggle: vi.fn(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + copyText: vi.fn(), + canForkItem: () => true, + onForkItem, + })[0]; + + const element = renderMessageBlockElement(block); + expectPresent(element.querySelector(".codex-panel__fork-and-archive-message")).click(); + + expect(onForkItem).toHaveBeenCalledWith(expect.objectContaining({ id: "a1" }), true); + }); + + it("keeps message React actions mounted in the message stream host", () => { + const parent = document.createElement("div"); + const copyText = vi.fn(); + const onImplementPlanItem = vi.fn(); + + renderMessageStreamBlocks( + parent, + messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: idleTurnLifecycle(), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { + id: "p1", + kind: "message", + role: "assistant", + text: "Plan", + copyText: "Plan", + turnId: "turn-1", + markdown: false, + proposedPlan: true, + }, + ], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (element, text) => element.createDiv({ text }), + renderTextWithWikiLinks: (element, text) => element.createDiv({ text }), + copyText, + canImplementPlanItem: () => true, + onImplementPlanItem, + }), + ); + + parent.querySelector(".codex-panel__copy-message")?.click(); + parent.querySelector(".codex-panel__implement-plan")?.click(); + + expect(copyText).toHaveBeenCalledWith("Plan"); + expect(onImplementPlanItem).toHaveBeenCalledWith(expect.objectContaining({ id: "p1" })); + expect(parent.querySelector('[data-codex-panel-block-key="item:p1"] .codex-panel__message--assistant')).not.toBeNull(); + unmountReactRoot(parent); + }); + + it("renders message markdown through the React content adapter", () => { + const parent = document.createElement("div"); + const renderMarkdown = vi.fn((element: HTMLElement, text: string) => { + element.createDiv({ text: `rendered:${text}` }); + }); + + renderMessageStreamBlocks( + parent, + messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: idleTurnLifecycle(), + historyCursor: null, + loadingHistory: false, + displayItems: [{ id: "a1", kind: "message", role: "assistant", text: "**answer**", turnId: "turn-1", markdown: true }], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown, + renderTextWithWikiLinks: (element, text) => element.createDiv({ text }), + }), + ); + + expect(renderMarkdown).toHaveBeenCalledWith(expect.any(HTMLElement), "**answer**"); + expect(parent.querySelector(".codex-panel__message-content")?.textContent).toBe("rendered:**answer**"); + unmountReactRoot(parent); + }); + + it("updates message content when the markdown mode changes", () => { + const parent = document.createElement("div"); + const baseContext = { + activeThreadId: "thread", + turnLifecycle: idleTurnLifecycle(), + historyCursor: null, + loadingHistory: false, + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (element: HTMLElement, text: string) => element.createDiv({ text: `markdown:${text}` }), + renderTextWithWikiLinks: (element: HTMLElement, text: string) => element.createDiv({ text: `text:${text}` }), + }; + + renderMessageStreamBlocks( + parent, + messageStreamBlocks({ + ...baseContext, + displayItems: [{ id: "a1", kind: "message", role: "assistant", text: "**answer**", turnId: "turn-1", markdown: false }], + }), + ); + expect(parent.querySelector(".codex-panel__message-content")?.textContent).toBe("text:**answer**"); + + renderMessageStreamBlocks( + parent, + messageStreamBlocks({ + ...baseContext, + displayItems: [{ id: "a1", kind: "message", role: "assistant", text: "**answer**", turnId: "turn-1", markdown: true }], + }), + ); + expect(parent.querySelector(".codex-panel__message-content")?.textContent).toBe("markdown:**answer**"); + unmountReactRoot(parent); + }); + + it("updates keyed message content without replacing the stream block host", () => { + const parent = document.createElement("div"); + const renderMarkdown = vi.fn((element: HTMLElement, text: string) => { + element.createDiv({ text: `markdown:${text}` }); + }); + const baseContext = { + activeThreadId: "thread", + turnLifecycle: idleTurnLifecycle(), + historyCursor: null, + loadingHistory: false, + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown, + renderTextWithWikiLinks: (element: HTMLElement, text: string) => element.createDiv({ text }), + }; + + renderMessageStreamBlocks( + parent, + messageStreamBlocks({ + ...baseContext, + displayItems: [{ id: "a1", kind: "message", role: "assistant", text: "first", turnId: "turn-1", markdown: true }], + }), + ); + const host = expectPresent(parent.querySelector('[data-codex-panel-block-key="item:a1"]')); + expect(host.querySelector(".codex-panel__message-content")?.textContent).toBe("markdown:first"); + + renderMessageStreamBlocks( + parent, + messageStreamBlocks({ + ...baseContext, + displayItems: [{ id: "a1", kind: "message", role: "assistant", text: "second", turnId: "turn-1", markdown: true }], + }), + ); + + expect(parent.querySelector('[data-codex-panel-block-key="item:a1"]')).toBe(host); + expect(host.querySelector(".codex-panel__message-content")?.textContent).toBe("markdown:second"); + expect(renderMarkdown).toHaveBeenCalledTimes(2); + unmountReactRoot(parent); + }); + + it("does not rerender unchanged message markdown when the stream rerenders", () => { + const parent = document.createElement("div"); + const renderMarkdown = vi.fn((element: HTMLElement, text: string) => { + element.createDiv({ text }); + }); + const context = { + activeThreadId: "thread", + turnLifecycle: idleTurnLifecycle(), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { id: "a1", kind: "message", role: "assistant", text: "**answer**", turnId: "turn-1", markdown: true }, + ] satisfies DisplayItem[], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown, + renderTextWithWikiLinks: (element: HTMLElement, text: string) => element.createDiv({ text }), + }; + + renderMessageStreamBlocks(parent, messageStreamBlocks(context)); + renderMessageStreamBlocks(parent, messageStreamBlocks({ ...context, loadingHistory: true })); + + expect(renderMarkdown).toHaveBeenCalledOnce(); + unmountReactRoot(parent); + }); + + it("hides copy action for the active assistant message while a turn is running", () => { + const item = { + id: "a-running", + itemId: "a-running", + kind: "message", + role: "assistant", + text: "partial", + copyText: "partial", + turnId: "turn-1", + markdown: false, + } as const; + const context = { + activeThreadId: "thread", + turnLifecycle: runningTurnLifecycle("turn-1"), + historyCursor: null, + loadingHistory: false, + displayItems: [item], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent: HTMLElement, text: string) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent: HTMLElement, text: string) => parent.createDiv({ text }), + copyText: vi.fn(), + }; + + const runningBlock = messageStreamBlocks(context)[0]; + const completedBlock = messageStreamBlocks({ ...context, turnLifecycle: idleTurnLifecycle() })[0]; + + expect(renderMessageBlockElement(runningBlock).querySelector(".codex-panel__copy-message")).toBeNull(); + expect(renderMessageBlockElement(completedBlock).querySelector(".codex-panel__copy-message")).not.toBeNull(); + }); + + it("renders implement plan action for eligible proposed plans", () => { + const onImplementPlanItem = vi.fn(); + const block = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: idleTurnLifecycle(), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { + id: "p1", + kind: "message", + role: "assistant", + text: "# Plan", + copyText: "# Plan", + turnId: "turn-1", + markdown: true, + proposedPlan: true, + }, + ], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + copyText: vi.fn(), + canImplementPlanItem: () => true, + onImplementPlanItem, + })[0]; + + const element = renderMessageBlockElement(block); + const button = element.querySelector(".codex-panel__implement-plan"); + + expect(button?.getAttribute("aria-label")).toBe("Implement plan"); + button?.click(); + expect(onImplementPlanItem).toHaveBeenCalledWith(expect.objectContaining({ id: "p1" })); + }); + + it("selects only the latest proposed plan as an implement candidate", () => { + const firstPlan = { + id: "p1", + kind: "message", + role: "assistant", + text: "# First plan", + turnId: "turn-1", + proposedPlan: true, + } as const; + const secondPlan = { + id: "p2", + kind: "message", + role: "assistant", + text: "# Second plan", + turnId: "turn-2", + proposedPlan: true, + } as const; + const baseState = { + activeThreadId: "thread", + turnLifecycle: { kind: "idle" as const }, + composerDraft: "", + requestedCollaborationMode: "plan" as const, + displayItems: [firstPlan, { id: "a1", kind: "message", role: "assistant", text: "answer" } as const, secondPlan], + }; + + expect(implementPlanCandidateFromState(baseState)).toBe(secondPlan); + expect(implementPlanCandidateFromState({ ...baseState, requestedCollaborationMode: "default" })).toBeNull(); + expect(implementPlanCandidateFromState({ ...baseState, composerDraft: "edit first" })).toBeNull(); + expect(implementPlanCandidateFromState({ ...baseState, turnLifecycle: { kind: "running", turnId: "turn-2" } })).toBeNull(); + }); + + it("does not render copy actions for tool items", () => { + const block = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: idleTurnLifecycle(), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { + id: "tool-1", + kind: "tool", + role: "tool", + text: "tool summary", + turnId: "turn", + toolLabel: "web search", + }, + ], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + copyText: vi.fn(), + })[0]; + + expect(renderMessageBlockElement(block).querySelector(".codex-panel__copy-message")).toBeNull(); + }); + + it("renders copy and rollback actions together when both apply", () => { + const copyText = vi.fn(); + const onRollbackItem = vi.fn(); + const block = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: idleTurnLifecycle(), + historyCursor: null, + loadingHistory: false, + displayItems: [{ id: "u1", kind: "message", role: "user", text: "latest", copyText: "latest", turnId: "turn-1", markdown: true }], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + copyText, + canRollbackItem: () => true, + onRollbackItem, + })[0]; + + const element = renderMessageBlockElement(block); + element.querySelector(".codex-panel__copy-message")?.click(); + element.querySelector(".codex-panel__rollback-turn")?.click(); + + expect(copyText).toHaveBeenCalledWith("latest"); + expect(onRollbackItem).toHaveBeenCalledWith(expect.objectContaining({ id: "u1" })); + }); + + it("collapses tall user messages without changing the copy payload", () => { + withMessageContentScrollHeight(500, () => { + const copyText = vi.fn(); + const openDetails = new Set(); + const onDetailsToggle = vi.fn((key: string, open: boolean) => { + if (open) { + openDetails.add(key); + } else { + openDetails.delete(key); + } + }); + const block = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: idleTurnLifecycle(), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { id: "u1", kind: "message", role: "user", text: "visible text", copyText: "full copied text", turnId: "turn-1", markdown: true }, + ], + openDetails, + onDetailsToggle, + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + copyText, + })[0]; + + const element = renderMessageBlockElement(block); + const content = element.querySelector(".codex-panel__message-content"); + const details = element.querySelector(".codex-panel__message-collapse-details"); + + expect(content?.classList.contains("codex-panel__message-content--collapsed")).toBe(true); + expect(details?.hidden).toBe(false); + expect(details?.querySelector("summary")?.textContent).toBe("Show more"); + + if (details) { + details.open = true; + act(() => { + details.dispatchEvent(new Event("toggle")); + }); + } + expect(openDetails.has("message:u1:expanded")).toBe(true); + expect(content?.classList.contains("codex-panel__message-content--collapsed")).toBe(false); + expect(details?.hidden).toBe(true); + expect(onDetailsToggle).toHaveBeenCalled(); + + element.querySelector(".codex-panel__copy-message")?.click(); + expect(copyText).toHaveBeenCalledWith("full copied text"); + }); + }); + + it("does not show the collapse control for short user messages or assistant messages", () => { + withMessageContentScrollHeight(120, () => { + const shortUserBlock = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: idleTurnLifecycle(), + historyCursor: null, + loadingHistory: false, + displayItems: [{ id: "u1", kind: "message", role: "user", text: "short", turnId: "turn-1", markdown: true }], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + })[0]; + const shortUser = renderMessageBlockElement(shortUserBlock); + + expect(shortUser.querySelector(".codex-panel__message-collapse-details")?.hidden).toBe(true); + }); + + withMessageContentScrollHeight(500, () => { + const assistantBlock = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: idleTurnLifecycle(), + historyCursor: null, + loadingHistory: false, + displayItems: [{ id: "a1", kind: "message", role: "assistant", text: "long", turnId: "turn-1", markdown: true }], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + })[0]; + const assistant = renderMessageBlockElement(assistantBlock); + + expect(assistant.querySelector(".codex-panel__message-collapse-details")).toBeNull(); + }); + }); + + it("does not render rollback action when no item is eligible", () => { + const block = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: startingTurnLifecycle(), + historyCursor: null, + loadingHistory: false, + displayItems: [{ id: "u1", kind: "message", role: "user", text: "running", turnId: "turn-1", markdown: true }], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + canRollbackItem: () => false, + onRollbackItem: vi.fn(), + })[0]; + + expect(renderMessageBlockElement(block).querySelector(".codex-panel__rollback-turn")).toBeNull(); + }); + + it("renders command items as a compact summary with output behind details", () => { + const block = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: runningTurnLifecycle("turn"), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { + id: "cmd-1", + kind: "command", + role: "tool", + text: "npm run check (exit 1)", + turnId: "turn", + command: "npm run check", + cwd: "/vault", + status: "failed", + exitCode: 1, + output: "stderr details", + }, + ], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + })[0]; + + const element = renderMessageBlockElement(block); + + expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("npm run check (exit 1)"); + expect(element.querySelector(".codex-panel__tool-summary")?.getAttribute("title")).toBeNull(); + expect(topLevelDetailsSummaries(element)).toEqual(["command"]); + expect([...element.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["command"]); + expect(element.textContent).not.toContain("Details"); + expect([...element.querySelectorAll(".codex-panel__output-title")].map((title) => title.textContent)).toEqual(["Output"]); + expect(element.querySelector(".codex-panel__output pre")?.textContent).toBe("stderr details"); + expect(element.querySelector("details")?.hasAttribute("open")).toBe(false); + }); + + it("omits command exit and duration rows while they are unavailable", () => { + const block = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: runningTurnLifecycle("turn"), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { + id: "cmd-1", + kind: "command", + role: "tool", + text: "npm run check", + turnId: "turn", + command: "npm run check", + cwd: "/vault", + status: "inProgress", + output: "", + }, + ], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + })[0]; + + const element = renderMessageBlockElement(block); + const metaText = element.querySelector(".codex-panel__meta-grid")?.textContent ?? ""; + + expect(metaText).toContain("commandnpm run check"); + expect(metaText).toContain("statusinProgress"); + expect(metaText).not.toContain("exit"); + expect(metaText).not.toContain("duration"); + expect(metaText).not.toContain("undefined"); + }); + + it("uses read as the command header for parsed file reads", () => { + const block = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: runningTurnLifecycle("turn"), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { + id: "cmd-1", + kind: "command", + role: "tool", + actionLabel: "read", + text: "sed /vault/src/main.ts", + turnId: "turn", + command: "sed -n '1,20p' src/main.ts", + cwd: "/vault", + status: "completed", + exitCode: 0, + output: "contents", + }, + ], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + })[0]; + + const element = renderMessageBlockElement(block); + + expect([...element.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["read"]); + }); + + it("renders file diffs inside a single file change details block", () => { + const block = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: runningTurnLifecycle("turn"), + historyCursor: null, + loadingHistory: false, + workspaceRoot: "/vault/project", + displayItems: [ + { + id: "patch-1", + kind: "fileChange", + role: "tool", + text: "/vault/project/src/main.ts", + turnId: "turn", + status: "completed", + changes: [{ kind: "update", path: "/vault/project/src/main.ts", diff: "@@\n-old\n+new" }], + output: "patch applied", + }, + ], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + })[0]; + + const element = renderMessageBlockElement(block); + + expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("src/main.ts"); + expect(topLevelDetailsSummaries(element)).toEqual(["file change"]); + expect([...element.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["file change"]); + expect(element.textContent).not.toContain("Details"); + expect([...element.querySelectorAll(".codex-panel__output-title")].map((title) => title.textContent)).toEqual([ + "update src/main.ts", + "Patch output", + ]); + }); + + it("renders the edited files footer with an open diff action when aggregated turn diff exists", () => { + const openTurnDiff = vi.fn(); + const blocks = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: idleTurnLifecycle(), + historyCursor: null, + loadingHistory: false, + workspaceRoot: "/vault/project", + displayItems: [ + { + id: "patch-1", + kind: "fileChange", + role: "tool", + text: "File change completed", + turnId: "turn", + status: "completed", + changes: [{ kind: "update", path: "/vault/project/src/main.ts", diff: "@@\n-old\n+new" }], + }, + { id: "a1", kind: "message", role: "assistant", text: "Done", turnId: "turn", markdown: true }, + ], + turnDiffs: new Map([["turn", "diff --git a/src/main.ts b/src/main.ts\n@@\n-old\n+new"]]), + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + openTurnDiff, + }); + + const assistant = renderMessageBlockElement(expectPresent(blocks.find((block) => block.key === "item:a1"))); + const button = assistant.querySelector(".codex-panel__open-turn-diff"); + + expect(assistant.querySelector(".codex-panel__edited-files")?.textContent).toContain("Edited 1 file"); + expect(button?.getAttribute("aria-label")).toBe("View diff"); + expect(button?.textContent).toContain("View diff"); + button?.click(); + expect(openTurnDiff).toHaveBeenCalledWith({ + threadId: "thread", + turnId: "turn", + cwd: "/vault/project", + files: ["src/main.ts"], + diff: "diff --git a/src/main.ts b/src/main.ts\n@@\n-old\n+new", + }); + }); + + it("renders referenced thread metadata without exposing hidden context", () => { + const blocks = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: idleTurnLifecycle(), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { + id: "u1", + kind: "message", + role: "user", + text: "この続きです", + copyText: "この続きです", + markdown: true, + referencedThread: { + threadId: "thread-reference", + title: "参照元", + includedTurns: 2, + turnLimit: 20, + }, + }, + ], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + }); + + const user = renderMessageBlockElement(expectPresent(blocks.find((block) => block.key === "item:u1"))); + + expect(user.querySelector(".codex-panel__message-content")?.textContent).toBe("この続きです"); + expect(user.querySelector(".codex-panel__referenced-thread")?.textContent).toContain("Referenced 参照元"); + expect(user.querySelector(".codex-panel__referenced-thread")?.textContent).toContain("2/20 turns"); + expect(user.querySelector(".codex-panel__referenced-thread")?.getAttribute("title")).toBeNull(); + }); + + it("renders resolved file mentions as a collapsed user message attachment", () => { + const blocks = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: idleTurnLifecycle(), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { + id: "u1", + kind: "message", + role: "user", + text: "Read [[Alpha]].", + copyText: "Read [[Alpha]].", + markdown: true, + mentionedFiles: [{ name: "Alpha", path: "thoughts/Alpha.md" }], + }, + ], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + }); + + const user = renderMessageBlockElement(expectPresent(blocks.find((block) => block.key === "item:u1"))); + + expect(user.querySelector(".codex-panel__message-content")?.textContent).toBe("Read [[Alpha]]."); + expect(user.querySelector(".codex-panel__mentioned-files summary")?.textContent).toBe("Mentioned 1 file"); + expect(user.querySelector(".codex-panel__mentioned-files")?.textContent).toContain("Alpha"); + expect(user.querySelector(".codex-panel__mentioned-files")?.textContent).toContain("thoughts/Alpha.md"); + }); + + it("does not render the open diff action without aggregated turn diff", () => { + const blocks = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: idleTurnLifecycle(), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { + id: "patch-1", + kind: "fileChange", + role: "tool", + text: "File change completed", + turnId: "turn", + status: "completed", + changes: [{ kind: "update", path: "src/main.ts", diff: "@@\n-old\n+new" }], + }, + { id: "a1", kind: "message", role: "assistant", text: "Done", turnId: "turn", markdown: true }, + ], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + openTurnDiff: vi.fn(), + }); + + const assistant = renderMessageBlockElement(expectPresent(blocks.find((block) => block.key === "item:a1"))); + + expect(assistant.querySelector(".codex-panel__edited-files")?.textContent).toContain("Edited 1 file"); + expect(assistant.querySelector(".codex-panel__open-turn-diff")).toBeNull(); + }); +}); diff --git a/tests/features/chat/ui/message-stream/pending-requests.test.tsx b/tests/features/chat/ui/message-stream/pending-requests.test.tsx new file mode 100644 index 00000000..39ffdd35 --- /dev/null +++ b/tests/features/chat/ui/message-stream/pending-requests.test.tsx @@ -0,0 +1,530 @@ +// @vitest-environment jsdom + +import { describe, expect, it, vi } from "vitest"; + +import type { PendingApproval } from "../../../../../src/features/chat/approvals/model"; +import type { PendingUserInput } from "../../../../../src/features/chat/user-input/model"; +import { pendingRequestMessageNode } from "../../../../../src/features/chat/ui/pending-request-message"; +import type { DisplayItem } from "../../../../../src/features/chat/display/types"; +import { renderMessageStreamBlocks } from "../../../../../src/features/chat/ui/message-stream"; +import { changeInputValue } from "../../../../support/dom"; +import { unmountReactRoot } from "../../../../../src/shared/ui/react-root"; +import "./setup"; +import { + dispatchComposingInputValue, + expectPresent, + idleTurnLifecycle, + messageStreamBlocks, + pendingApproval, + pendingFreeformUserInput, + pendingOtherUserInput, + pendingRequestActions, + pendingUserInput, + renderMessageBlockElement, + renderPendingRequestNode, + setNativeInputValue, +} from "./test-helpers"; + +describe("pending request renderer decisions", () => { + it("renders pending requests as one message-stream block and keeps user input drafts live", () => { + const parent = document.createElement("div"); + const drafts = new Map(); + const resolveUserInput = vi.fn(); + const input = pendingUserInput(); + + renderPendingRequestNode( + parent, + [], + [input], + { + values: drafts, + draftKey: (requestId, questionId) => `${String(requestId)}:${questionId}`, + otherDraftKey: (requestId, questionId) => `${String(requestId)}:${questionId}:other`, + }, + new Set(), + pendingRequestActions({ resolveUserInput }), + ); + + expect(parent.querySelectorAll(".codex-panel__pending-request-message")).toHaveLength(1); + expect(parent.querySelector(".codex-panel__pending-request-message")?.classList.contains("codex-panel__work-message")).toBe(true); + expect(parent.querySelector(".codex-panel__pending-request-message")?.classList.contains("codex-panel__work-message--warning")).toBe( + true, + ); + expect(parent.querySelector(".codex-panel__pending-request-button.mod-cta")?.textContent).toBe("Submit"); + expect(parent.querySelector(".codex-panel__user-input-answer .codex-panel__user-input-radio")).not.toBeNull(); + expect(parent.querySelector(".codex-panel__user-input-radio")?.checked).toBe(true); + expect(parent.querySelector(".codex-panel__user-input-radio")?.type).toBe("radio"); + expect(parent.querySelector(".codex-panel__user-input-marker")).toBeNull(); + parent.querySelector(".mod-cta")?.click(); + expect(resolveUserInput).toHaveBeenCalledWith(input); + }); + + it("selects the other Plan mode answer from controlled drafts", () => { + const parent = document.createElement("div"); + const drafts = new Map(); + const input = pendingOtherUserInput(); + const draftKey = (requestId: PendingUserInput["requestId"], questionId: string) => `${String(requestId)}:${questionId}`; + const otherDraftKey = (requestId: PendingUserInput["requestId"], questionId: string) => `${String(requestId)}:${questionId}:other`; + const actions = pendingRequestActions({ + setUserInputDraft: vi.fn((key: string, value: string) => { + drafts.set(key, value); + }), + }); + const render = () => { + renderPendingRequestNode(parent, [], [input], { values: drafts, draftKey, otherDraftKey }, new Set(), actions); + }; + + render(); + changeInputValue(expectPresent(parent.querySelector(".codex-panel__user-input-other-text")), "Custom scope"); + + expect(actions.setUserInputDraft).toHaveBeenCalledWith("99:scope:other", "Custom scope"); + expect(actions.setUserInputDraft).toHaveBeenCalledWith("99:scope", "Custom scope"); + + render(); + const radios = [...parent.querySelectorAll(".codex-panel__user-input-radio")]; + expect(radios.map((radio) => radio.checked)).toEqual([false, true]); + }); + + it("keeps the other Plan mode radio selected when the custom answer is empty", () => { + const parent = document.createElement("div"); + const drafts = new Map(); + const input = pendingOtherUserInput(); + const draftKey = (requestId: PendingUserInput["requestId"], questionId: string) => `${String(requestId)}:${questionId}`; + const otherDraftKey = (requestId: PendingUserInput["requestId"], questionId: string) => `${String(requestId)}:${questionId}:other`; + const actions = pendingRequestActions({ + setUserInputDraft: vi.fn((key: string, value: string) => { + drafts.set(key, value); + }), + }); + const render = () => { + renderPendingRequestNode(parent, [], [input], { values: drafts, draftKey, otherDraftKey }, new Set(), actions); + }; + + render(); + const radios = [...parent.querySelectorAll(".codex-panel__user-input-radio")]; + expect(radios.map((radio) => radio.checked)).toEqual([true, false]); + + expectPresent(radios.at(1)).click(); + + expect(actions.setUserInputDraft).toHaveBeenCalledWith("99:scope", ""); + + render(); + const rerenderedRadios = [...parent.querySelectorAll(".codex-panel__user-input-radio")]; + expect(rerenderedRadios.map((radio) => radio.checked)).toEqual([false, true]); + }); + + it("keeps unselected other Plan mode text out of tab order", () => { + const parent = document.createElement("div"); + const drafts = new Map(); + const input = pendingOtherUserInput(); + const draftKey = (requestId: PendingUserInput["requestId"], questionId: string) => `${String(requestId)}:${questionId}`; + const otherDraftKey = (requestId: PendingUserInput["requestId"], questionId: string) => `${String(requestId)}:${questionId}:other`; + const actions = pendingRequestActions({ + setUserInputDraft: vi.fn((key: string, value: string) => { + drafts.set(key, value); + }), + }); + const render = () => { + renderPendingRequestNode(parent, [], [input], { values: drafts, draftKey, otherDraftKey }, new Set(), actions); + }; + + render(); + + expect(parent.querySelector(".codex-panel__user-input-other-text")?.tabIndex).toBe(-1); + + expectPresent(parent.querySelectorAll(".codex-panel__user-input-radio").item(1)).click(); + render(); + + expect(parent.querySelector(".codex-panel__user-input-other-text")?.tabIndex).toBe(0); + }); + + it("does not commit other Plan mode IME preedit text as the answer", () => { + const parent = document.createElement("div"); + const drafts = new Map(); + const input = pendingOtherUserInput(); + const draftKey = (requestId: PendingUserInput["requestId"], questionId: string) => `${String(requestId)}:${questionId}`; + const otherDraftKey = (requestId: PendingUserInput["requestId"], questionId: string) => `${String(requestId)}:${questionId}:other`; + const actions = pendingRequestActions({ + setUserInputDraft: vi.fn((key: string, value: string) => { + drafts.set(key, value); + }), + }); + + renderPendingRequestNode(parent, [], [input], { values: drafts, draftKey, otherDraftKey }, new Set(), actions); + const otherInput = expectPresent(parent.querySelector(".codex-panel__user-input-other-text")); + + otherInput.dispatchEvent(new Event("compositionstart", { bubbles: true })); + dispatchComposingInputValue(otherInput, "にほん"); + + expect(actions.setUserInputDraft).toHaveBeenCalledWith("99:scope", ""); + expect(actions.setUserInputDraft).not.toHaveBeenCalledWith("99:scope:other", "にほん"); + expect(actions.setUserInputDraft).not.toHaveBeenCalledWith("99:scope", "にほん"); + + setNativeInputValue(otherInput, "日本"); + otherInput.dispatchEvent(new Event("compositionend", { bubbles: true })); + + expect(actions.setUserInputDraft).toHaveBeenCalledWith("99:scope:other", "日本"); + expect(actions.setUserInputDraft).toHaveBeenCalledWith("99:scope", "日本"); + }); + + it("renders pending approvals and Plan mode questions in the same request block", () => { + const parent = document.createElement("div"); + const approval = pendingApproval(); + const input = pendingUserInput(); + + renderPendingRequestNode( + parent, + [approval], + [input], + { + values: new Map(), + draftKey: (requestId, questionId) => `${String(requestId)}:${questionId}`, + otherDraftKey: (requestId, questionId) => `${String(requestId)}:${questionId}:other`, + }, + new Set(), + pendingRequestActions(), + ); + + expect(parent.querySelectorAll(".codex-panel__pending-request-message")).toHaveLength(1); + expect(parent.querySelectorAll(".codex-panel__pending-request-card")).toHaveLength(2); + expect(parent.querySelectorAll(".codex-panel__pending-request-body")).toHaveLength(2); + expect(parent.querySelector(".codex-panel__approval-body")).toBeNull(); + expect(parent.querySelector(".codex-panel__approval .codex-panel__pending-request-title")?.textContent).toBe("Permission approval"); + expect(parent.querySelector(".codex-panel__approval .codex-panel__pending-request-body")?.textContent).toContain("Need network"); + expect(parent.querySelector(".codex-panel__approval-details summary")?.textContent).toBe("Request details"); + expect(parent.querySelector(".codex-panel__user-input .codex-panel__pending-request-title")?.textContent).toBe("Codex needs input"); + expect([...parent.querySelectorAll(".codex-panel__pending-request-button")].map((button) => button.textContent)).toEqual([ + "Allow", + "Allow session", + "Deny", + "Cancel", + "Submit", + "Cancel", + ]); + }); + + it("focuses Plan mode input when pending requests ask for autofocus", () => { + const parent = document.createElement("div"); + document.body.appendChild(parent); + const input = pendingFreeformUserInput(); + + try { + renderPendingRequestNode( + parent, + [pendingApproval()], + [input], + { + values: new Map(), + draftKey: (requestId, questionId) => `${String(requestId)}:${questionId}`, + otherDraftKey: (requestId, questionId) => `${String(requestId)}:${questionId}:other`, + }, + new Set(), + pendingRequestActions(), + true, + ); + + expect(document.activeElement).toBe(parent.querySelector(".codex-panel__user-input-text")); + } finally { + unmountReactRoot(parent); + parent.remove(); + } + }); + + it("focuses the selected Plan mode option before the other text field", () => { + const parent = document.createElement("div"); + document.body.appendChild(parent); + const input = pendingOtherUserInput(); + + try { + renderPendingRequestNode( + parent, + [], + [input], + { + values: new Map(), + draftKey: (requestId, questionId) => `${String(requestId)}:${questionId}`, + otherDraftKey: (requestId, questionId) => `${String(requestId)}:${questionId}:other`, + }, + new Set(), + pendingRequestActions(), + true, + ); + + expect(document.activeElement).toBe(parent.querySelector(".codex-panel__user-input-radio:checked")); + expect(document.activeElement).not.toBe(parent.querySelector(".codex-panel__user-input-other-text")); + } finally { + unmountReactRoot(parent); + parent.remove(); + } + }); + + it("focuses the approval action when pending approval asks for autofocus", () => { + const parent = document.createElement("div"); + document.body.appendChild(parent); + + try { + renderPendingRequestNode( + parent, + [pendingApproval()], + [], + { + values: new Map(), + draftKey: (requestId, questionId) => `${String(requestId)}:${questionId}`, + otherDraftKey: (requestId, questionId) => `${String(requestId)}:${questionId}:other`, + }, + new Set(), + pendingRequestActions(), + true, + ); + + expect(document.activeElement).toBe(parent.querySelector(".codex-panel__pending-request-button.mod-cta")); + } finally { + unmountReactRoot(parent); + parent.remove(); + } + }); + + it("renders command approval buttons from app-server available decisions", () => { + const parent = document.createElement("div"); + const approval: PendingApproval = { + requestId: 43, + method: "item/commandExecution/requestApproval", + params: { + threadId: "thread", + turnId: "turn", + itemId: "command", + startedAtMs: 1, + reason: "Needs network", + networkApprovalContext: { host: "registry.npmjs.org", protocol: "https" }, + command: null, + cwd: "/vault", + commandActions: [], + proposedExecpolicyAmendment: null, + proposedNetworkPolicyAmendments: [], + availableDecisions: [ + { applyNetworkPolicyAmendment: { network_policy_amendment: { host: "registry.npmjs.org", action: "allow" } } }, + "decline", + ], + }, + }; + const resolveApproval = vi.fn(); + + renderPendingRequestNode( + parent, + [approval], + [], + { + values: new Map(), + draftKey: (requestId, questionId) => `${String(requestId)}:${questionId}`, + otherDraftKey: (requestId, questionId) => `${String(requestId)}:${questionId}:other`, + }, + new Set(), + pendingRequestActions({ resolveApproval }), + ); + + const buttons = [...parent.querySelectorAll(".codex-panel__pending-request-button")]; + expect(buttons.map((button) => button.textContent)).toEqual(["Allow network rule", "Deny"]); + const allowButton = buttons.at(0); + if (!allowButton) throw new Error("Missing allow button"); + allowButton.click(); + expect(resolveApproval).toHaveBeenCalledWith(approval, { + kind: "command-decision", + decision: { applyNetworkPolicyAmendment: { network_policy_amendment: { host: "registry.npmjs.org", action: "allow" } } }, + }); + }); + + it("renders submitted user input separately from approvals", () => { + const block = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: idleTurnLifecycle(), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { + id: "user-input-submitted-1", + kind: "userInputResult", + role: "tool", + text: "Input submitted for 1 question.", + turnId: "turn", + markdown: false, + state: "completed", + details: [{ title: "Question: Scope", rows: [{ key: "Answer", value: "Narrow" }] }], + }, + ], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + })[0]; + + const element = renderMessageBlockElement(block); + + expect(element.querySelector(".codex-panel__message-role")?.textContent).toBe("Input"); + expect(element.textContent).not.toContain("Approval"); + expect(element.querySelector("details summary")?.textContent).toBe("Question: Scope"); + expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("AnswerNarrow"); + }); + + it("renders manual approval results with completion state and details", () => { + const block = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: idleTurnLifecycle(), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { + id: "approval-1", + kind: "approvalResult", + role: "tool", + text: "Allowed for this session: Need access", + turnId: "turn", + markdown: false, + state: "completed", + details: [ + { + title: "Approval", + rows: [ + { key: "status", value: "allowed for session" }, + { key: "scope", value: "session" }, + ], + }, + ], + }, + ], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + })[0]; + + const element = renderMessageBlockElement(block); + + expect(element.classList.contains("codex-panel__message--approval-result")).toBe(true); + expect(element.classList.contains("codex-panel__tool-result")).toBe(true); + expect(element.classList.contains("codex-panel__execution--completed")).toBe(true); + expect(element.querySelector(".codex-panel__message-content")).toBeNull(); + expect(element.querySelector(".codex-panel__tool-result-header")?.textContent).toBe("approval"); + expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("Allowed for this session: Need access"); + expect(element.querySelector("details summary")?.textContent).toBe("approval"); + expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("statusallowed for session"); + expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("scopesession"); + }); + + it("renders auto-review summaries under the final assistant message", () => { + const block = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: idleTurnLifecycle(), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { + id: "assistant-1", + kind: "message", + role: "assistant", + text: "Done", + turnId: "turn", + markdown: true, + autoReviewSummaries: ["Auto-review approved: npm test", "Auto-review approved: npm test"], + }, + ], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + })[0]; + + const element = renderMessageBlockElement(block); + + expect(element.querySelector(".codex-panel__auto-reviews summary")?.textContent).toBe("Auto-reviewed 2 requests"); + expect(element.querySelector(".codex-panel__auto-reviews")?.textContent).toContain("Auto-review approved: npm test"); + }); + + it("adds pending requests to the bottom of message stream blocks", () => { + const blocks = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: idleTurnLifecycle(), + historyCursor: null, + loadingHistory: false, + displayItems: [{ id: "a1", kind: "message", role: "assistant", text: "Done", markdown: true }], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + pendingRequestsSignature: "request:1", + renderPendingRequests: () => "Request", + }); + + expect(blocks.map((block) => block.key)).toEqual(["item:a1", "pending-requests"]); + expect(expectPresent(blocks[1]).node).not.toBeUndefined(); + }); + + it("keeps pending request React events mounted in the message stream host", () => { + const parent = document.createElement("div"); + const approval = pendingApproval(); + const resolveApproval = vi.fn(); + + renderMessageStreamBlocks( + parent, + messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: idleTurnLifecycle(), + historyCursor: null, + loadingHistory: false, + displayItems: [{ id: "a1", kind: "message", role: "assistant", text: "Waiting", markdown: true }], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (element, text) => element.createDiv({ text }), + renderTextWithWikiLinks: (element, text) => element.createDiv({ text }), + pendingRequestsSignature: "approval:1", + renderPendingRequests: () => + pendingRequestMessageNode( + [approval], + [], + { + values: new Map(), + draftKey: (requestId, questionId) => `${String(requestId)}:${questionId}`, + otherDraftKey: (requestId, questionId) => `${String(requestId)}:${questionId}:other`, + }, + new Set(), + pendingRequestActions({ resolveApproval }), + ), + }), + ); + + parent.querySelector(".codex-panel__pending-request-button")?.click(); + + expect(resolveApproval).toHaveBeenCalledWith(approval, "accept"); + unmountReactRoot(parent); + }); + + it("removes pending request blocks when the signature clears", () => { + const parent = document.createElement("div"); + const baseContext = { + activeThreadId: "thread", + turnLifecycle: idleTurnLifecycle(), + historyCursor: null, + loadingHistory: false, + displayItems: [{ id: "a1", kind: "message", role: "assistant", text: "Done", markdown: true }] satisfies DisplayItem[], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (element: HTMLElement, text: string) => element.createDiv({ text }), + renderTextWithWikiLinks: (element: HTMLElement, text: string) => element.createDiv({ text }), + }; + + renderMessageStreamBlocks( + parent, + messageStreamBlocks({ + ...baseContext, + pendingRequestsSignature: "request:1", + renderPendingRequests: () => , + }), + ); + expect(parent.querySelector('[data-codex-panel-block-key="pending-requests"]')).not.toBeNull(); + + renderMessageStreamBlocks(parent, messageStreamBlocks(baseContext)); + + expect(parent.querySelector('[data-codex-panel-block-key="pending-requests"]')).toBeNull(); + expect(parent.querySelector('[data-codex-panel-block-key="item:a1"]')).not.toBeNull(); + unmountReactRoot(parent); + }); +}); diff --git a/tests/features/chat/ui/message-stream/setup.ts b/tests/features/chat/ui/message-stream/setup.ts new file mode 100644 index 00000000..aa6a4627 --- /dev/null +++ b/tests/features/chat/ui/message-stream/setup.ts @@ -0,0 +1,5 @@ +import { installObsidianDomShims } from "../../../../support/dom"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +installObsidianDomShims(); diff --git a/tests/features/chat/ui/message-stream/test-helpers.tsx b/tests/features/chat/ui/message-stream/test-helpers.tsx new file mode 100644 index 00000000..1b1ec705 --- /dev/null +++ b/tests/features/chat/ui/message-stream/test-helpers.tsx @@ -0,0 +1,167 @@ +import { vi } from "vitest"; +import { act, type ReactNode } from "react"; + +import type { PendingApproval } from "../../../../../src/features/chat/approvals/model"; +import type { PendingUserInput } from "../../../../../src/features/chat/user-input/model"; +import { pendingRequestMessageNode, type PendingRequestMessageActions } from "../../../../../src/features/chat/ui/pending-request-message"; +import type { ChatTurnLifecycleState } from "../../../../../src/features/chat/chat-state"; +import { + messageStreamBlocks as rawMessageStreamBlocks, + renderMessageStreamBlocks, +} from "../../../../../src/features/chat/ui/message-stream"; +import { renderReactRoot } from "../../../../../src/shared/ui/react-root"; + +export function messageStreamBlocks( + ...args: Parameters +): [ReturnType[number], ...ReturnType] { + const blocks = rawMessageStreamBlocks(...args); + if (blocks.length === 0) throw new Error("Expected at least one message stream block."); + return blocks as [ReturnType[number], ...ReturnType]; +} + +export function expectPresent(value: T | null | undefined): T { + if (value === null || value === undefined) throw new Error("Expected value to be present"); + return value; +} + +export function setNativeInputValue(input: HTMLInputElement, value: string): void { + const valueDescriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value"); + if (!valueDescriptor?.set) throw new Error("Missing input value setter"); + valueDescriptor.set.call(input, value); +} + +export function dispatchComposingInputValue(input: HTMLInputElement, value: string): void { + setNativeInputValue(input, value); + const event = new Event("input", { bubbles: true }); + Object.defineProperty(event, "isComposing", { value: true }); + input.dispatchEvent(event); +} + +export function testMessageStreamBlock(key: string, node: ReactNode): ReturnType[number] { + return { key, node }; +} + +export function renderMessageBlockElement(block: ReturnType[number]): HTMLElement { + const parent = document.createElement("div"); + act(() => { + renderMessageStreamBlocks(parent, [block]); + }); + const host = expectPresent(parent.querySelector(`[data-codex-panel-block-key="${block.key}"]`)); + return expectPresent(host.firstElementChild as HTMLElement | null); +} + +export function renderPendingRequestNode(parent: HTMLElement, ...args: Parameters): void { + renderReactRoot(parent, pendingRequestMessageNode(...args)); +} + +export function pendingRequestActions(overrides: Partial = {}): PendingRequestMessageActions { + return { + resolveApproval: vi.fn(), + resolveUserInput: vi.fn(), + cancelUserInput: vi.fn(), + setUserInputDraft: vi.fn(), + ...overrides, + }; +} + +export function idleTurnLifecycle(): ChatTurnLifecycleState { + return { kind: "idle" }; +} + +export function runningTurnLifecycle(turnId = "turn"): ChatTurnLifecycleState { + return { kind: "running", turnId }; +} + +export function startingTurnLifecycle(): ChatTurnLifecycleState { + return { kind: "starting", pendingTurnStart: { anchorItemId: "local-user", promptSubmitHookItemIds: [] } }; +} + +export function withMessageContentScrollHeight(scrollHeight: number, fn: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight"); + Object.defineProperty(HTMLElement.prototype, "scrollHeight", { + configurable: true, + get() { + return this.classList.contains("codex-panel__message-content") ? scrollHeight : 0; + }, + }); + try { + return fn(); + } finally { + if (descriptor) { + Object.defineProperty(HTMLElement.prototype, "scrollHeight", descriptor); + } else { + Reflect.deleteProperty(HTMLElement.prototype, "scrollHeight"); + } + } +} + +export function pendingUserInput(): PendingUserInput { + return { + requestId: 99, + method: "item/tool/requestUserInput", + params: { + threadId: "thread", + turnId: "turn", + itemId: "input", + questions: [ + { + id: "scope", + header: "Scope", + question: "How broad?", + isOther: false, + isSecret: false, + options: [{ label: "Narrow", description: "Small change" }], + }, + ], + }, + }; +} + +export function pendingOtherUserInput(): PendingUserInput { + const input = pendingUserInput(); + return { + ...input, + params: { + ...input.params, + questions: [ + { + ...expectPresent(input.params.questions[0]), + isOther: true, + options: [{ label: "Narrow", description: "Small change" }], + }, + ], + }, + }; +} + +export function pendingFreeformUserInput(): PendingUserInput { + const input = pendingUserInput(); + return { + ...input, + params: { + ...input.params, + questions: [ + { + ...expectPresent(input.params.questions[0]), + options: null, + }, + ], + }, + }; +} + +export function pendingApproval(): PendingApproval { + return { + requestId: 42, + method: "item/permissions/requestApproval", + params: { + threadId: "thread", + turnId: "turn", + itemId: "permission", + startedAtMs: 1, + cwd: "/vault", + reason: "Need network", + permissions: { network: { enabled: true }, fileSystem: null }, + }, + }; +} diff --git a/tests/features/chat/ui/message-stream/work-log.test.tsx b/tests/features/chat/ui/message-stream/work-log.test.tsx new file mode 100644 index 00000000..e2d3de52 --- /dev/null +++ b/tests/features/chat/ui/message-stream/work-log.test.tsx @@ -0,0 +1,994 @@ +// @vitest-environment jsdom + +import { describe, expect, it, vi } from "vitest"; +import { act } from "react"; + +import type { DisplayItem } from "../../../../../src/features/chat/display/types"; +import { renderMessageStreamBlocks } from "../../../../../src/features/chat/ui/message-stream"; +import { topLevelDetailsSummaries } from "../../../../support/dom"; +import { unmountReactRoot } from "../../../../../src/shared/ui/react-root"; +import "./setup"; +import { expectPresent, idleTurnLifecycle, messageStreamBlocks, renderMessageBlockElement, runningTurnLifecycle } from "./test-helpers"; + +describe("work log renderer decisions", () => { + it("renders generic tool details as visible sections inside one details block", () => { + const block = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: runningTurnLifecycle("turn"), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { + id: "tool-1", + kind: "tool", + role: "tool", + text: "123", + toolLabel: "github.pull_request_read", + turnId: "turn", + status: "completed", + details: [ + { title: "Arguments JSON", body: '{\n "id": 123\n}' }, + { title: "Result JSON", body: '{\n "ok": true\n}' }, + ], + }, + ], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + })[0]; + + const element = renderMessageBlockElement(block); + + expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("123"); + expect(topLevelDetailsSummaries(element)).toEqual(["github.pull_request_read"]); + expect([...element.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["github.pull_request_read"]); + expect(element.textContent).not.toContain("Details"); + expect([...element.querySelectorAll(".codex-panel__output-title")].map((title) => title.textContent)).toEqual([ + "Arguments JSON", + "Result JSON", + ]); + }); + + it("keeps the tool summary as a separate row when details are open", () => { + const block = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: runningTurnLifecycle("turn"), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { + id: "cmd-1", + kind: "command", + role: "tool", + text: "npm run check", + turnId: "turn", + command: "npm run check", + cwd: "/vault", + status: "completed", + exitCode: 0, + output: "ok", + }, + ], + openDetails: new Set(["cmd-1:command-details"]), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + })[0]; + + const element = renderMessageBlockElement(block); + + expect(element.classList.contains("is-open")).toBe(true); + expect(element.querySelector("details")?.hasAttribute("open")).toBe(true); + expect(topLevelDetailsSummaries(element)).toEqual(["command"]); + expect(element.querySelector(":scope > .codex-panel__tool-summary")?.textContent).toBe("npm run check"); + }); + + it("renders generic tools without details as two compact rows", () => { + const block = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: runningTurnLifecycle("turn"), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { + id: "tool-plain", + kind: "tool", + role: "tool", + text: "https://example.com", + toolLabel: "web search", + turnId: "turn", + }, + ], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + })[0]; + + const element = renderMessageBlockElement(block); + + expect(element.classList.contains("codex-panel__tool-result--plain")).toBe(true); + expect(element.querySelector(".codex-panel__tool-result-header")?.textContent).toBe("web search"); + expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("https://example.com"); + }); + + it("renders path summary tools relative to the workspace root", () => { + const block = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: runningTurnLifecycle("turn"), + historyCursor: null, + loadingHistory: false, + workspaceRoot: "/vault/project", + displayItems: [ + { + id: "tool-path", + kind: "tool", + role: "tool", + text: "/vault/project/assets/image.png", + toolLabel: "imageView", + summaryPath: true, + turnId: "turn", + }, + ], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + })[0]; + + const element = renderMessageBlockElement(block); + + expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("assets/image.png"); + expect(element.querySelector(".codex-panel__tool-summary")?.getAttribute("title")).toBeNull(); + }); + + it("updates path summary tools when the workspace root changes", () => { + const parent = document.createElement("div"); + const item = { + id: "tool-path", + kind: "tool", + role: "tool", + text: "/vault/project/assets/image.png", + toolLabel: "imageView", + summaryPath: true, + turnId: "turn", + } as const; + const baseContext = { + activeThreadId: "thread", + turnLifecycle: runningTurnLifecycle("turn"), + historyCursor: null, + loadingHistory: false, + displayItems: [item] satisfies DisplayItem[], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (element: HTMLElement, text: string) => element.createDiv({ text }), + renderTextWithWikiLinks: (element: HTMLElement, text: string) => element.createDiv({ text }), + }; + + renderMessageStreamBlocks(parent, messageStreamBlocks({ ...baseContext, workspaceRoot: "/vault" })); + expect(parent.querySelector(".codex-panel__tool-summary")?.textContent).toBe("project/assets/image.png"); + + renderMessageStreamBlocks(parent, messageStreamBlocks({ ...baseContext, workspaceRoot: "/vault/project" })); + expect(parent.querySelector(".codex-panel__tool-summary")?.textContent).toBe("assets/image.png"); + unmountReactRoot(parent); + }); + + it("keeps path summary tools absolute outside the workspace root", () => { + const block = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: runningTurnLifecycle("turn"), + historyCursor: null, + loadingHistory: false, + workspaceRoot: "/vault/project", + displayItems: [ + { + id: "tool-path", + kind: "tool", + role: "tool", + text: "/tmp/image.png", + toolLabel: "imageView", + summaryPath: true, + turnId: "turn", + }, + ], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + })[0]; + + const element = renderMessageBlockElement(block); + + expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("/tmp/image.png"); + }); + + it("does not treat generic tool summaries as paths without an explicit marker", () => { + const block = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: runningTurnLifecycle("turn"), + historyCursor: null, + loadingHistory: false, + workspaceRoot: "/vault/project", + displayItems: [ + { + id: "tool-path-like", + kind: "tool", + role: "tool", + text: "/vault/project", + toolLabel: "example.tool", + turnId: "turn", + }, + ], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + })[0]; + + const element = renderMessageBlockElement(block); + + expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("/vault/project"); + }); + + it("renders hook metadata as rows inside one details block", () => { + const block = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: runningTurnLifecycle("turn"), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { + id: "hook-1", + kind: "hook", + role: "tool", + text: "postToolUse: Formatted 1 file.", + toolLabel: "hook", + turnId: "turn", + status: "completed", + details: [ + { + rows: [ + { key: "status", value: "completed" }, + { key: "event", value: "postToolUse" }, + { key: "message", value: "Formatted 1 file." }, + { key: "duration", value: "1ms" }, + ], + }, + { title: "Hook output", body: "feedback: ok" }, + ], + }, + ], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + })[0]; + + const element = renderMessageBlockElement(block); + + expect(topLevelDetailsSummaries(element)).toEqual(["hook"]); + expect([...element.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["hook"]); + expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("postToolUse: Formatted 1 file."); + expect(element.textContent).not.toContain("Details"); + expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("statuscompleted"); + expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("eventpostToolUse"); + expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("messageFormatted 1 file."); + expect([...element.querySelectorAll(".codex-panel__output-title")].map((title) => title.textContent)).toEqual(["Hook output"]); + expect(element.querySelector(".codex-panel__output pre")?.textContent).toBe("feedback: ok"); + }); + + it("renders hook metadata when the hook is inside a completed-turn activity group", () => { + const blocks = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: idleTurnLifecycle(), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { id: "u1", kind: "message", role: "user", text: "do it", turnId: "turn", markdown: true }, + { + id: "hook-1", + kind: "hook", + role: "tool", + text: "postToolUse: Formatted 1 file.", + toolLabel: "hook", + turnId: "turn", + status: "completed", + details: [ + { + rows: [ + { key: "status", value: "completed" }, + { key: "event", value: "postToolUse" }, + { key: "message", value: "Formatted 1 file." }, + ], + }, + { title: "Hook output", body: "feedback: ok" }, + ], + }, + { id: "a1", kind: "message", role: "assistant", text: "done", turnId: "turn", markdown: true }, + ], + openDetails: new Set(["turn:turn:activity", "hook-1"]), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + }); + + const element = renderMessageBlockElement(expectPresent(blocks.find((block) => block.key === "activity:turn-turn-activity"))); + + expect(element).toBeDefined(); + expect(element.querySelector(":scope > summary")?.textContent).toBe("Work details: hook"); + expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("postToolUse: Formatted 1 file."); + expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("statuscompleted"); + expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("eventpostToolUse"); + expect(element.querySelector(".codex-panel__output-title")?.textContent).toBe("Hook output"); + expect(element.querySelector(".codex-panel__output pre")?.textContent).toBe("feedback: ok"); + }); + + it("keeps completed-turn activity group items mounted through React", () => { + const parent = document.createElement("div"); + const onDetailsToggle = vi.fn(); + + renderMessageStreamBlocks( + parent, + messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: idleTurnLifecycle(), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { id: "u1", kind: "message", role: "user", text: "do it", turnId: "turn", markdown: true }, + { + id: "hook-1", + kind: "hook", + role: "tool", + text: "postToolUse: Formatted 1 file.", + toolLabel: "hook", + turnId: "turn", + status: "completed", + details: [ + { + rows: [ + { key: "status", value: "completed" }, + { key: "event", value: "postToolUse" }, + ], + }, + { title: "Hook output", body: "feedback: ok" }, + ], + }, + { + id: "agent-1", + kind: "agent", + role: "tool", + text: "Spawn agent", + turnId: "turn", + tool: "spawnAgent", + status: "completed", + senderThreadId: "parent", + receiverThreadIds: ["child"], + prompt: null, + model: null, + reasoningEffort: null, + agents: [{ threadId: "child", status: "completed", message: "Done" }], + }, + { id: "a1", kind: "message", role: "assistant", text: "done", turnId: "turn", markdown: true }, + ], + openDetails: new Set(["turn:turn:activity", "hook-1:details"]), + onDetailsToggle, + loadOlderTurns: vi.fn(), + renderMarkdown: (element, text) => element.createDiv({ text }), + renderTextWithWikiLinks: (element, text) => element.createDiv({ text }), + }), + ); + + const group = expectPresent(parent.querySelector('[data-codex-panel-block-key="activity:turn-turn-activity"]')); + expect(group.querySelector(":scope > .codex-panel__activity-group > summary")?.textContent).toBe("Work details: agent, hook"); + expect(group.querySelector(".codex-panel__tool-result .codex-panel__tool-result-header")?.textContent).toBe("hook"); + expect(group.querySelector(".codex-panel__tool-result > .codex-panel__tool-summary")?.textContent).toBe( + "postToolUse: Formatted 1 file.", + ); + expect(group.querySelector(".codex-panel__agent-activity .codex-panel__tool-summary")?.textContent).toBe("spawn child (completed)"); + + const details = expectPresent(group.querySelector(".codex-panel__activity-group")); + details.open = false; + details.dispatchEvent(new Event("toggle", { bubbles: false })); + + expect(onDetailsToggle).toHaveBeenCalledWith("turn:turn:activity", false); + unmountReactRoot(parent); + }); + + it("renders task progress items as a dedicated task list", () => { + const block = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: runningTurnLifecycle("turn"), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { + id: "plan-progress-turn", + kind: "taskProgress", + role: "tool", + text: "Plan\n[>] Patch UI", + turnId: "turn", + explanation: "Plan", + steps: [ + { step: "Inspect code", status: "completed" }, + { step: "Patch UI", status: "inProgress" }, + ], + status: "inProgress", + }, + ], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + })[0]; + + const element = renderMessageBlockElement(block); + + expect(element.classList.contains("codex-panel__work-message")).toBe(true); + expect(element.classList.contains("codex-panel__task-progress")).toBe(true); + expect(element.querySelector(".codex-panel__message-role")?.textContent).toBe("tasks"); + expect(element.textContent).toContain("[x]Inspect code"); + expect(element.textContent).toContain("[>]Patch UI"); + }); + + it("keeps task progress React items mounted in the message stream host", () => { + const parent = document.createElement("div"); + + renderMessageStreamBlocks( + parent, + messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: runningTurnLifecycle("turn"), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { + id: "plan-progress-turn", + kind: "taskProgress", + role: "tool", + text: "Plan\n[>] Patch UI", + turnId: "turn", + explanation: "Plan", + steps: [ + { step: "Inspect code", status: "completed" }, + { step: "Patch UI", status: "inProgress" }, + ], + status: "inProgress", + }, + ], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (element, text) => element.createDiv({ text }), + renderTextWithWikiLinks: (element, text) => element.createDiv({ text }), + }), + ); + + const block = expectPresent(parent.querySelector('[data-codex-panel-block-key="live-task:plan-progress-turn"]')); + expect(block.querySelector(".codex-panel__task-progress .codex-panel__message-role")?.textContent).toBe("tasks"); + expect(block.textContent).toContain("[x]Inspect code"); + expect(block.textContent).toContain("[>]Patch UI"); + unmountReactRoot(parent); + }); + + it("renders active task progress with the shared bottom live blocks", () => { + const blocks = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: runningTurnLifecycle("turn"), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { id: "u1", kind: "message", role: "user", text: "Do it", turnId: "turn", markdown: true }, + { + 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: "message", role: "assistant", text: "Working", turnId: "turn", markdown: true }, + { + 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", message: "Inspecting renderer" }], + }, + ], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + pendingRequestsSignature: "request:1", + renderPendingRequests: () => "Request", + }); + + 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 = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: runningTurnLifecycle("turn"), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { id: "u1", kind: "message", role: "user", text: "Do it", turnId: "turn", markdown: true }, + { + 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", 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", + }, + ], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + }); + + 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 = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: runningTurnLifecycle("turn"), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { id: "u1", kind: "message", role: "user", text: "Do it", turnId: "turn", markdown: true }, + { + 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", 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", message: "Inspecting renderer" }], + }, + ], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + }); + + 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 = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: runningTurnLifecycle("turn"), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { + id: "agent-1", + kind: "agent", + role: "tool", + text: "Spawn agent", + turnId: "turn", + tool: "spawnAgent", + status: "completed", + senderThreadId: "parent", + receiverThreadIds: ["child"], + prompt: "Inspect the renderer.", + model: "gpt-5.5", + reasoningEffort: "high", + agents: [{ threadId: "child", status: "completed", message: "Done" }], + }, + ], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + })[0]; + + const element = renderMessageBlockElement(block); + + expect(element.classList.contains("codex-panel__work-message")).toBe(true); + expect(element.classList.contains("codex-panel__agent-activity")).toBe(true); + expect(element.querySelector(".codex-panel__message-role")?.textContent).toBe("agent"); + const summary = expectPresent(element.querySelector(".codex-panel__tool-summary")); + expect(summary.textContent).toBe("spawn child: Inspect the renderer. (completed)"); + expect(summary.classList.contains("codex-panel__agent-activity-summary")).toBe(true); + expect([...element.querySelectorAll("details summary")].map((detailsSummary) => detailsSummary.textContent)).toEqual(["Details"]); + expect(element.textContent).toContain("targetchild"); + expect(element.textContent).toContain("PromptInspect the renderer."); + expect(element.textContent).toContain("childcompleted: Done"); + }); + + it("keeps agent React details mounted in the message stream host", () => { + const parent = document.createElement("div"); + const onDetailsToggle = vi.fn(); + + renderMessageStreamBlocks( + parent, + messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: runningTurnLifecycle("turn"), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { + id: "agent-1", + kind: "agent", + role: "tool", + text: "Spawn agent", + turnId: "turn", + tool: "spawnAgent", + status: "completed", + senderThreadId: "parent", + receiverThreadIds: ["child"], + prompt: "Inspect the renderer.", + model: "gpt-5.5", + reasoningEffort: "high", + agents: [{ threadId: "child", status: "completed", message: "Done" }], + }, + ], + openDetails: new Set(), + onDetailsToggle, + loadOlderTurns: vi.fn(), + renderMarkdown: (element, text) => element.createDiv({ text }), + renderTextWithWikiLinks: (element, text) => element.createDiv({ text }), + }), + ); + + const block = expectPresent(parent.querySelector('[data-codex-panel-block-key="item:agent-1"]')); + expect(block.querySelector(".codex-panel__agent-activity .codex-panel__message-role")?.textContent).toBe("agent"); + expect(block.querySelector(".codex-panel__tool-summary")?.textContent).toBe("spawn child: Inspect the renderer. (completed)"); + expect([...block.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["Details"]); + + const details = expectPresent(block.querySelector("details")); + act(() => { + details.open = true; + details.dispatchEvent(new Event("toggle", { bubbles: false })); + }); + + expect(onDetailsToggle).toHaveBeenCalledWith("agent-1:agent-details", true); + expect(expectPresent(block.querySelector(".codex-panel__agent-activity")).classList.contains("is-open")).toBe(true); + unmountReactRoot(parent); + }); + + it("keeps agent activity prompt previews visually constrained to one line", () => { + const block = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: runningTurnLifecycle("turn"), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { + id: "agent-1", + kind: "agent", + role: "tool", + text: "Spawn agent", + turnId: "turn", + tool: "spawnAgent", + status: "running", + senderThreadId: "parent", + receiverThreadIds: ["child"], + prompt: `Inspect the renderer.\n${"a".repeat(180)}`, + model: null, + reasoningEffort: null, + agents: [], + }, + ], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + })[0]; + + const element = renderMessageBlockElement(block); + const summary = expectPresent(element.querySelector(".codex-panel__agent-activity-summary")); + + expect(summary.textContent).toBe(`spawn child: Inspect the renderer. ${"a".repeat(73)}... (running)`); + }); + + it("collapses long agent output away from the agent status row", () => { + const longMessage = `Done\n${"a".repeat(180)}`; + const threadId = "019e061e-0046-7653-a362-86de9a47cb5c"; + const onDetailsToggle = vi.fn(); + const block = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: runningTurnLifecycle("turn"), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { + id: "agent-1", + kind: "agent", + role: "tool", + text: "Wait for agent", + turnId: "turn", + tool: "wait", + status: "completed", + senderThreadId: "parent", + receiverThreadIds: [threadId], + prompt: null, + model: null, + reasoningEffort: null, + agents: [{ threadId, status: "completed", message: longMessage }], + }, + ], + openDetails: new Set(), + onDetailsToggle, + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + })[0]; + + const element = renderMessageBlockElement(block); + + expect(element.querySelector(".codex-panel__agent-thread")?.textContent).toBe("019e061e"); + expect(element.querySelector(".codex-panel__agent-status")?.textContent).toBe("completed: Done"); + expect(element.querySelector(".codex-panel__agent-status")?.textContent).not.toContain("a".repeat(180)); + expect([...element.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["Details"]); + expect(element.textContent).toContain("Agent output 019e061e"); + expect(element.textContent).toContain(longMessage); + const details = element.querySelector("details"); + expect(details?.hasAttribute("open")).toBe(false); + details?.dispatchEvent(new Event("toggle")); + expect(onDetailsToggle).toHaveBeenCalled(); + }); + + it("renders a compact live agent summary while subagents are running", () => { + const blocks = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: runningTurnLifecycle("turn"), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { + id: "agent-1", + kind: "agent", + role: "tool", + text: "Wait for agent", + turnId: "turn", + tool: "wait", + status: "running", + senderThreadId: "parent", + receiverThreadIds: ["done", "running"], + prompt: null, + model: null, + reasoningEffort: null, + agents: [ + { threadId: "done", status: "completed", message: null }, + { threadId: "running", status: "running", message: "Inspecting renderer" }, + ], + }, + ], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + }); + + const summary = renderMessageBlockElement(expectPresent(blocks.at(-1))); + + expect(summary.classList.contains("codex-panel__work-message")).toBe(true); + expect(summary.classList.contains("codex-panel__agent-summary")).toBe(true); + expect(summary.textContent).toContain("agents"); + expect(summary.textContent).toContain("Agents 1 running, 1 done"); + expect(summary.textContent).toContain("runningrunning: Inspecting renderer"); + expect(summary.textContent).not.toContain("donecompleted"); + }); + + it("renders the compact live agent summary as a React block", () => { + const parent = document.createElement("div"); + + renderMessageStreamBlocks( + parent, + messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: runningTurnLifecycle("turn"), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { + id: "agent-1", + kind: "agent", + role: "tool", + text: "Wait for agent", + turnId: "turn", + tool: "wait", + status: "running", + senderThreadId: "parent", + receiverThreadIds: ["done", "running"], + prompt: null, + model: null, + reasoningEffort: null, + agents: [ + { threadId: "done", status: "completed", message: null }, + { threadId: "running", status: "running", message: "Inspecting renderer" }, + ], + }, + ], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (element, text) => element.createDiv({ text }), + renderTextWithWikiLinks: (element, text) => element.createDiv({ text }), + }), + ); + + const summary = expectPresent(parent.querySelector('[data-codex-panel-block-key="live-agents:turn"]')); + expect(summary.querySelector(".codex-panel__agent-summary")?.textContent).toContain("Agents 1 running, 1 done"); + expect(summary.textContent).toContain("runningrunning: Inspecting renderer"); + unmountReactRoot(parent); + }); + + it("renders active reasoning as a React message stream block", () => { + const parent = document.createElement("div"); + + renderMessageStreamBlocks( + parent, + messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: runningTurnLifecycle("turn"), + historyCursor: null, + loadingHistory: false, + displayItems: [{ id: "reasoning-1", kind: "reasoning", role: "tool", text: "", turnId: "turn", status: "running" }], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (element, text) => element.createDiv({ text }), + renderTextWithWikiLinks: (element, text) => element.createDiv({ text }), + }), + ); + + const reasoning = expectPresent( + parent.querySelector('[data-codex-panel-block-key="item:reasoning-1"] .codex-panel__reasoning'), + ); + expect(reasoning.classList.contains("is-active")).toBe(true); + expect(reasoning.querySelector(".codex-panel__reasoning-role")?.textContent).toBe("reasoning"); + expect(reasoning.querySelector(".codex-panel__reasoning-content")?.textContent).toBe("Reasoning..."); + unmountReactRoot(parent); + }); + + it("hides the live agent summary once all subagents are complete", () => { + const blocks = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: runningTurnLifecycle("turn"), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { + id: "agent-1", + kind: "agent", + role: "tool", + text: "Wait for agent", + turnId: "turn", + tool: "wait", + status: "completed", + senderThreadId: "parent", + receiverThreadIds: ["done"], + prompt: null, + model: null, + reasoningEffort: null, + agents: [{ threadId: "done", status: "completed", message: null }], + }, + ], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + }); + + expect(blocks.some((block) => block.key.startsWith("live-agents:"))).toBe(false); + }); + + it("marks the live agent summary failed when any subagent fails", () => { + const blocks = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: runningTurnLifecycle("turn"), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { + id: "agent-1", + kind: "agent", + role: "tool", + text: "Wait for agent", + turnId: "turn", + tool: "wait", + status: "completed", + senderThreadId: "parent", + receiverThreadIds: ["failed", "running"], + prompt: null, + model: null, + reasoningEffort: null, + agents: [ + { threadId: "failed", status: "errored", message: "Failed" }, + { threadId: "running", status: "running", message: null }, + ], + }, + ], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + }); + + const summary = renderMessageBlockElement(expectPresent(blocks.at(-1))); + + expect(summary.classList.contains("codex-panel__execution--failed")).toBe(true); + expect(summary.textContent).toContain("Agents 1 failed, 1 running"); + expect(summary.textContent).toContain("runningrunning"); + expect(summary.textContent).not.toContain("failederrored: Failed"); + }); +}); diff --git a/tests/features/chat/ui/react-test-helpers.tsx b/tests/features/chat/ui/react-test-helpers.tsx deleted file mode 100644 index 9f8e3a07..00000000 --- a/tests/features/chat/ui/react-test-helpers.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { ReactNode } from "react"; -import { act } from "react"; -import { createRoot } from "react-dom/client"; - -export interface ReactTestRoot { - render(node: ReactNode): void; - unmount(): void; -} - -export function createReactTestRoot(container: HTMLElement): ReactTestRoot { - const root = createRoot(container); - return { - render(node) { - act(() => { - root.render(node); - }); - }, - unmount() { - act(() => { - root.unmount(); - }); - }, - }; -} diff --git a/tests/features/chat/ui/renderers/composer.test.ts b/tests/features/chat/ui/renderers/composer.test.ts new file mode 100644 index 00000000..24a70b9d --- /dev/null +++ b/tests/features/chat/ui/renderers/composer.test.ts @@ -0,0 +1,194 @@ +// @vitest-environment jsdom + +import { describe, expect, it, vi } from "vitest"; + +import { + renderComposerShell, + renderComposerSuggestions, + scrollComposerSuggestionIntoView, + syncComposerHeight, +} from "../../../../../src/features/chat/ui/composer"; +import { changeInputValue, composerSuggestionScrollFixture, installObsidianDomShims } from "../../../../support/dom"; + +installObsidianDomShims(); + +function composerCallbacks() { + return { + onInput: vi.fn(), + onComposerResize: vi.fn(), + onUpdateSuggestions: vi.fn(), + onKeydown: vi.fn(), + onNewThread: vi.fn(), + onSendOrInterrupt: vi.fn(), + onSuggestionHover: vi.fn(), + onSuggestionInsert: vi.fn(), + }; +} + +describe("composer renderer decisions", () => { + it("uses the provided composer placeholder for normal input", () => { + const parent = document.createElement("div"); + const callbacks = composerCallbacks(); + const { composer } = renderComposerShell( + parent, + "view", + "", + false, + false, + "Ask Codex to work on “Refactor terminal streaming”...", + callbacks, + ); + + expect(composer.getAttribute("placeholder")).toBe("Ask Codex to work on “Refactor terminal streaming”..."); + + renderComposerShell(parent, "view", "", false, false, "Ask Codex to work on “Renamed thread”...", callbacks); + + expect(composer.getAttribute("placeholder")).toBe("Ask Codex to work on “Renamed thread”..."); + }); + + it("renders composer suggestions outside normal input flow callbacks", () => { + const parent = document.createElement("div"); + const onSuggestionInsert = vi.fn(); + const { composer, suggestions } = renderComposerShell(parent, "view", "", false, false, "Ask Codex to work on this task...", { + onInput: vi.fn(), + onComposerResize: vi.fn(), + onUpdateSuggestions: vi.fn(), + onKeydown: vi.fn(), + onNewThread: vi.fn(), + onSendOrInterrupt: vi.fn(), + onSuggestionHover: vi.fn(), + onSuggestionInsert, + }); + + renderComposerSuggestions( + suggestions, + composer, + "view", + [{ display: "/help", detail: "Show help", replacement: "/help", start: 0 }], + 0, + { onSuggestionHover: vi.fn(), onSuggestionInsert }, + ); + + expect(suggestions.getAttribute("role")).toBe("listbox"); + expect(composer.getAttribute("aria-expanded")).toBe("true"); + suggestions + .querySelector(".codex-panel__composer-suggestion") + ?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + expect(onSuggestionInsert).toHaveBeenCalled(); + }); + + it("reports composer draft changes from the controlled input", () => { + const parent = document.createElement("div"); + const callbacks = composerCallbacks(); + const { composer } = renderComposerShell(parent, "view", "", false, false, "Ask Codex to work on this task...", callbacks); + + changeInputValue(composer, "Draft text"); + + expect(callbacks.onInput).toHaveBeenCalledWith("Draft text"); + }); + + it("reports composer resize when autogrow changes the input height", () => { + const parent = document.createElement("div"); + const callbacks = composerCallbacks(); + let scrollHeight = 56; + const descriptor = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "scrollHeight"); + Object.defineProperty(HTMLTextAreaElement.prototype, "scrollHeight", { + get: () => scrollHeight, + configurable: true, + }); + try { + const { composer } = renderComposerShell(parent, "view", "", false, false, "Ask Codex to work on this task...", callbacks); + callbacks.onComposerResize.mockClear(); + + scrollHeight = 120; + changeInputValue(composer, "line one\nline two"); + + expect(callbacks.onComposerResize).toHaveBeenCalledOnce(); + } finally { + if (descriptor) { + Object.defineProperty(HTMLTextAreaElement.prototype, "scrollHeight", descriptor); + } else { + Reflect.deleteProperty(HTMLTextAreaElement.prototype, "scrollHeight"); + } + } + }); + + it("scrolls the selected composer suggestion fully into view below the viewport", () => { + const { container, option } = composerSuggestionScrollFixture({ + clientHeight: 100, + optionHeight: 32, + optionTop: 92, + scrollTop: 0, + }); + + scrollComposerSuggestionIntoView(container, option); + + expect(container.scrollTop).toBe(24); + }); + + it("scrolls the selected composer suggestion fully into view above the viewport", () => { + const { container, option } = composerSuggestionScrollFixture({ + clientHeight: 100, + optionHeight: 32, + optionTop: 48, + scrollTop: 64, + }); + + scrollComposerSuggestionIntoView(container, option); + + expect(container.scrollTop).toBe(48); + }); + + it("keeps composer suggestion scroll position when the selected item is already visible", () => { + const { container, option } = composerSuggestionScrollFixture({ + clientHeight: 100, + optionHeight: 32, + optionTop: 72, + scrollTop: 48, + }); + + scrollComposerSuggestionIntoView(container, option); + + expect(container.scrollTop).toBe(48); + }); + + it("uses the composer action for interrupt only when a running turn has no steering text", () => { + const parent = document.createElement("div"); + const callbacks = composerCallbacks(); + const { composer } = renderComposerShell(parent, "view", "", true, true, "Ask Codex to work on this task...", callbacks); + let sendButton = parent.querySelector(".codex-panel__send"); + + expect(sendButton?.getAttribute("aria-label")).toBe("Interrupt"); + expect(composer.getAttribute("placeholder")).toBe("Add steering message..."); + expect(sendButton?.classList.contains("is-interrupt")).toBe(true); + expect(sendButton?.classList.contains("is-steer")).toBe(false); + expect(sendButton?.dataset["icon"]).toBe("square"); + + renderComposerShell(parent, "view", "adjust course", true, true, "Ask Codex to work on this task...", callbacks); + sendButton = parent.querySelector(".codex-panel__send"); + expect(sendButton?.getAttribute("aria-label")).toBe("Steer"); + expect(composer.getAttribute("placeholder")).toBe("Add steering message..."); + expect(sendButton?.classList.contains("is-interrupt")).toBe(false); + expect(sendButton?.classList.contains("is-steer")).toBe(true); + expect(sendButton?.dataset["icon"]).toBe("corner-down-right"); + }); + + it("honors the smaller viewport branch of the composer max-height CSS", () => { + const composer = document.createElement("textarea"); + const getComputedStyleMock = vi.spyOn(window, "getComputedStyle").mockReturnValue({ + minHeight: "76px", + maxHeight: "min(208px, 40vh)", + } as CSSStyleDeclaration); + Object.defineProperty(window, "innerHeight", { value: 400, configurable: true }); + Object.defineProperty(composer, "scrollHeight", { value: 280, configurable: true }); + + try { + syncComposerHeight(composer); + } finally { + getComputedStyleMock.mockRestore(); + } + + expect(composer.style.height).toBe("160px"); + expect(composer.style.overflowY).toBe("auto"); + }); +}); diff --git a/tests/features/chat/ui/renderers/toolbar.test.ts b/tests/features/chat/ui/renderers/toolbar.test.ts new file mode 100644 index 00000000..8967e3c1 --- /dev/null +++ b/tests/features/chat/ui/renderers/toolbar.test.ts @@ -0,0 +1,382 @@ +// @vitest-environment jsdom + +import { describe, expect, it, vi } from "vitest"; + +import type { ToolbarViewModel } from "../../../../../src/features/chat/toolbar-model"; +import { renderToolbar } from "../../../../../src/features/chat/ui/toolbar"; +import { changeInputValue, installObsidianDomShims } from "../../../../support/dom"; + +installObsidianDomShims(); + +function expectPresent(value: T | null | undefined): T { + if (value === null || value === undefined) throw new Error("Expected value to be present"); + return value; +} + +describe("toolbar renderer decisions", () => { + it("renders toolbar controls as buttons and updates live status state", () => { + const parent = document.createElement("div"); + const toggleHistory = vi.fn(); + const toggleAutoReview = vi.fn(); + const baseModel = toolbarModel(); + + renderToolbar(parent, baseModel, toolbarActions({ toggleHistory, toggleAutoReview })); + + const statusButton = parent.querySelector(".codex-panel__status-dot"); + expect(statusButton?.tagName).toBe("BUTTON"); + expect(statusButton?.getAttribute("role")).toBeNull(); + expect(statusButton?.getAttribute("aria-label")).toBe("Show connection status"); + expect(statusButton?.classList.contains("nav-action-button")).toBe(false); + expect(statusButton?.classList.contains("clickable-icon")).toBe(true); + const historyButton = parent.querySelector(".codex-panel__history-toggle"); + expect(historyButton?.getAttribute("aria-label")).toBe("Show thread list"); + expect(historyButton?.classList.contains("nav-action-button")).toBe(false); + expect(historyButton?.classList.contains("clickable-icon")).toBe(true); + historyButton?.click(); + expect(toggleHistory).toHaveBeenCalled(); + const autoReviewButton = parent.querySelector(".codex-panel__auto-review-toggle"); + expect(autoReviewButton?.getAttribute("aria-label")).toBe("Toggle auto-review"); + expect(autoReviewButton?.getAttribute("aria-pressed")).toBe("false"); + expect(autoReviewButton?.classList.contains("nav-action-button")).toBe(false); + expect(autoReviewButton?.classList.contains("clickable-icon")).toBe(true); + autoReviewButton?.click(); + expect(toggleAutoReview).toHaveBeenCalled(); + + parent.empty(); + renderToolbar(parent, toolbarModel({ status: "Turn running...", statusState: "running", autoReviewActive: true }), toolbarActions()); + expect(parent.querySelector(".codex-panel__status-dot")?.getAttribute("aria-label")).toBe("Show connection status"); + expect(parent.querySelector(".codex-panel__auto-review-toggle")?.getAttribute("aria-pressed")).toBe("true"); + + parent.empty(); + renderToolbar(parent, toolbarModel({ historyOpen: true, statusPanelOpen: true }), toolbarActions()); + expect(parent.querySelector(".codex-panel__history-toggle")?.getAttribute("aria-label")).toBe("Hide thread list"); + expect(parent.querySelector(".codex-panel__history-toggle")?.classList.contains("is-active")).toBe(false); + expect(parent.querySelector(".codex-panel__status-dot")?.getAttribute("aria-label")).toBe("Hide connection status"); + expect(parent.querySelector(".codex-panel__runtime-model")?.getAttribute("aria-label")).toBe("Change model and reasoning effort"); + }); + + it("keeps frequently changed effort choices first inside the runtime menu", () => { + const parent = document.createElement("div"); + + renderToolbar( + parent, + toolbarModel({ + modelChoices: [{ label: "gpt-5.5", selected: true, onClick: vi.fn() }], + effortChoices: [{ label: "high", selected: true, onClick: vi.fn() }], + }), + toolbarActions(), + ); + + expect([...parent.querySelectorAll(".codex-panel__runtime-picker-label")].map((label) => label.textContent)).toEqual([ + "Reasoning effort", + "Model", + ]); + expect([...parent.querySelectorAll(".codex-panel__runtime-choice")].map((choice) => choice.textContent)).toEqual(["high", "gpt-5.5"]); + for (const choice of parent.querySelectorAll(".codex-panel__runtime-choice")) { + expect(choice.getAttribute("role")).toBe("option"); + expect(choice.getAttribute("aria-selected")).toBe("true"); + expect(choice.querySelector(".codex-panel__toolbar-panel-check")?.dataset["icon"]).toBe("check"); + expect(choice.classList.contains("selected")).toBe(false); + expect(choice.classList.contains("is-selected")).toBe(false); + } + }); + + it("renders context as a compact meter and Codex limits only in the status menu", () => { + const parent = document.createElement("div"); + + renderToolbar( + parent, + toolbarModel({ + statusPanelOpen: true, + openPanel: "status", + context: { label: "12%", title: "Context: 12%.", percent: 12, level: "ok" }, + rateLimit: { + title: "Codex: 5h 42%, 1w 21%", + level: "ok", + rows: [ + { + label: "5h", + value: "42%", + resetLabel: "reset in 2h", + title: "Codex 5h: 42% used.", + percent: 42, + level: "ok", + }, + { + label: "1w", + value: "21%", + resetLabel: "reset in 3d 4h", + title: "Codex 1w: 21% used.", + percent: 21, + level: "ok", + }, + ], + }, + }), + toolbarActions(), + ); + + expect(parent.querySelector(".codex-panel__context-compact")?.textContent).toContain("12%"); + expect(parent.querySelector(".codex-panel__limit-compact")).toBeNull(); + expect(parent.querySelector(".codex-panel__limit-panel")?.textContent).toContain("5h"); + expect(parent.querySelector(".codex-panel__limit-panel")?.textContent).toContain("42%"); + expect(parent.querySelector(".codex-panel__limit-panel")?.textContent).toContain("reset in 2h"); + expect(parent.querySelector(".codex-panel__limit-panel")?.textContent).toContain("1w"); + expect(parent.querySelector(".codex-panel__limit-panel")?.textContent).toContain("21%"); + expect(parent.querySelector(".codex-panel__limit-panel")?.textContent).toContain("reset in 3d 4h"); + }); + + it("renders connection diagnostics in the status menu", () => { + const parent = document.createElement("div"); + const refreshStatus = vi.fn(); + + renderToolbar( + parent, + toolbarModel({ + statusPanelOpen: true, + openPanel: "status", + diagnostics: [ + { title: "Process", rows: [{ label: "Codex App Server", value: "codex-cli/1.2.3" }] }, + { title: "Capabilities", rows: [{ label: "compatibility", value: "model/list failed", level: "error" }] }, + ], + }), + toolbarActions({ refreshStatus }), + ); + + expect(parent.querySelector(".codex-panel__connection-diagnostics-title")?.textContent).toBe("Connection"); + expect(parent.textContent).toContain("Process"); + expect(parent.textContent).toContain("Capabilities"); + expect(parent.textContent).toContain("Effective Codex config"); + expect(parent.textContent).toContain("Refresh status"); + expect(parent.textContent).not.toContain("Refresh diagnostics"); + expect(parent.textContent).not.toContain("Refresh thread list"); + expect(parent.textContent).toContain("codex-cli/1.2.3"); + expect(parent.querySelector(".codex-panel__connection-diagnostics-row--error")?.textContent).toContain("model/list failed"); + const statusItems = [...parent.querySelectorAll(".codex-panel__status-panel-item")]; + expect(statusItems.map((item) => item.getAttribute("role"))).toEqual(["menuitem", "menuitem"]); + expect(statusItems.every((item) => item.getAttribute("aria-selected") === null)).toBe(true); + statusItems.find((item) => item.textContent.includes("Refresh status"))?.click(); + expect(refreshStatus).toHaveBeenCalled(); + }); + + it("renders status dot states without diagnostic overlay badges", () => { + for (const statusState of ["ready", "degraded", "blocked", "running", "offline"] as const) { + const parent = document.createElement("div"); + renderToolbar(parent, toolbarModel({ statusState }), toolbarActions()); + const status = parent.querySelector(".codex-panel__status-dot"); + expect(status?.classList.contains(`codex-panel__status-dot--${statusState}`)).toBe(true); + expect(status?.childElementCount).toBe(0); + expect(status?.getAttribute("aria-label")).toBe("Show connection status"); + } + }); + + it("renders effective config inside the status menu without a separate toggle", () => { + const parent = document.createElement("div"); + + renderToolbar( + parent, + toolbarModel({ + statusPanelOpen: true, + openPanel: "status", + configSections: [{ title: "Runtime", rows: [{ key: "model", value: "gpt-5.5" }] }], + }), + toolbarActions(), + ); + + expect(parent.querySelector(".codex-panel__slot--config")).toBeNull(); + expect(parent.querySelector(".codex-panel__toolbar-panel .codex-panel__config")?.textContent).toContain("Effective Codex config"); + expect(parent.textContent).not.toContain("Show effective config"); + expect(parent.textContent).not.toContain("Hide effective config"); + expect(parent.textContent).toContain("gpt-5.5"); + }); + + it("renders thread list rename actions and an inline rename editor", () => { + const parent = document.createElement("div"); + const startRenameThread = vi.fn(); + const updateRenameDraft = vi.fn(); + const saveRenameThread = vi.fn(); + const cancelRenameThread = vi.fn(); + const autoNameThread = vi.fn(); + const actions = toolbarActions({ startRenameThread, updateRenameDraft, saveRenameThread, cancelRenameThread, autoNameThread }); + + renderToolbar( + parent, + toolbarModel({ + historyOpen: true, + openPanel: "history", + threads: [ + { title: "Thread", threadId: "thread", selected: true, disabled: false, canArchive: true, rename: null }, + { + title: "Editing", + threadId: "editing", + selected: false, + disabled: false, + canArchive: true, + rename: { draft: "Draft title", generating: false }, + }, + ], + }), + actions, + ); + + parent.querySelector('[aria-label="Rename thread"]')?.click(); + expect(startRenameThread).toHaveBeenCalledWith("thread"); + + const input = parent.querySelector(".codex-panel__thread-rename-input"); + if (!input) throw new Error("Missing thread rename input"); + expect(input.closest(".codex-panel__thread-rename")?.querySelector(".codex-panel__toolbar-panel-check")).not.toBeNull(); + expect(input.value).toBe("Draft title"); + changeInputValue(input, "New title"); + expect(updateRenameDraft).toHaveBeenCalledWith("editing", "New title"); + + renderToolbar( + parent, + toolbarModel({ + historyOpen: true, + openPanel: "history", + threads: [ + { title: "Thread", threadId: "thread", selected: true, disabled: false, canArchive: true, rename: null }, + { + title: "Editing", + threadId: "editing", + selected: false, + disabled: false, + canArchive: true, + rename: { draft: "New title", generating: false }, + }, + ], + }), + actions, + ); + expectPresent(parent.querySelector(".codex-panel__thread-rename-input")).dispatchEvent( + new FocusEvent("focusout", { bubbles: true }), + ); + expect(saveRenameThread).toHaveBeenCalledWith("editing", "New title"); + expectPresent(parent.querySelector(".codex-panel__thread-rename-input")).dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + expect(cancelRenameThread).toHaveBeenCalledWith("editing"); + expect(parent.querySelector('[aria-label="Save thread name"]')).toBeNull(); + expect(parent.querySelector('[aria-label="Cancel rename"]')).toBeNull(); + parent.querySelector('[aria-label="Auto-name thread"]')?.click(); + expect(autoNameThread).toHaveBeenCalledWith("editing"); + }); + + it("renders auto-name loading without disabling the rename draft field", () => { + const parent = document.createElement("div"); + + renderToolbar( + parent, + toolbarModel({ + historyOpen: true, + openPanel: "history", + threads: [ + { + title: "Editing", + threadId: "editing", + selected: false, + disabled: false, + canArchive: true, + rename: { draft: "Draft title", generating: true }, + }, + ], + }), + toolbarActions(), + ); + + expect(parent.querySelector(".codex-panel__thread-rename-input")?.disabled).toBe(false); + expect(parent.querySelector('[aria-label="Save thread name"]')).toBeNull(); + expect(parent.querySelector('[aria-label="Auto-name thread"]')?.disabled).toBe(true); + expect(parent.querySelector('[aria-label="Cancel rename"]')).toBeNull(); + }); + + it("renders toolbar archive confirmation with the default action on the right", () => { + const parent = document.createElement("div"); + const startArchiveThread = vi.fn(); + const archiveThread = vi.fn(); + + renderToolbar( + parent, + toolbarModel({ + historyOpen: true, + openPanel: "history", + threads: [ + { + title: "Thread", + threadId: "thread", + selected: true, + disabled: false, + canArchive: true, + archiveConfirm: { active: true, defaultSaveMarkdown: true }, + rename: null, + }, + ], + }), + toolbarActions({ startArchiveThread, archiveThread }), + ); + + const confirm = expectPresent(parent.querySelector(".codex-panel__archive-confirm")); + const archiveButtons = [ + ...confirm.querySelectorAll(".codex-panel__archive-alternate, .codex-panel__archive-default"), + ]; + expect(archiveButtons.map((button) => button.getAttribute("aria-label"))).toEqual([ + "Archive thread without saving", + "Save and archive thread", + ]); + expect(parent.querySelector('[aria-label="Rename thread"]')).toBeNull(); + archiveButtons[0]?.click(); + expect(archiveThread).toHaveBeenCalledWith("thread", false); + archiveButtons[1]?.click(); + expect(archiveThread).toHaveBeenCalledWith("thread", true); + expect(startArchiveThread).not.toHaveBeenCalled(); + }); +}); + +function toolbarModel(overrides: Partial = {}): ToolbarViewModel { + return { + connected: true, + status: "Connected.", + statusState: "ready", + historyOpen: false, + statusPanelOpen: false, + runtimeOpen: true, + planActive: false, + autoReviewActive: false, + fastActive: false, + runtimeSummary: "5.5 high", + runtimeTitle: "Model: gpt-5.5; Effort: high", + runtimeEmphasized: false, + context: null, + rateLimit: null, + configSections: [], + openPanel: "runtime", + threads: [{ title: "Thread", threadId: "thread", selected: true, disabled: false, canArchive: true, rename: null }], + modelChoices: [{ label: "Default", selected: true, onClick: vi.fn() }], + effortChoices: [{ label: "Default", selected: true, onClick: vi.fn() }], + connectLabel: "Reconnect", + diagnostics: [{ title: "Process", rows: [{ label: "Codex App Server", value: "codex-cli/test" }] }], + ...overrides, + }; +} + +function toolbarActions(overrides: Partial[2]> = {}): Parameters[2] { + return { + toggleHistory: vi.fn(), + toggleAutoReview: vi.fn(), + toggleStatusPanel: vi.fn(), + togglePlan: vi.fn(), + toggleFast: vi.fn(), + toggleRuntime: vi.fn(), + connect: vi.fn(), + refreshStatus: vi.fn(), + resumeThread: vi.fn(), + startArchiveThread: vi.fn(), + archiveThread: vi.fn(), + startRenameThread: vi.fn(), + updateRenameDraft: vi.fn(), + saveRenameThread: vi.fn(), + cancelRenameThread: vi.fn(), + autoNameThread: vi.fn(), + ...overrides, + }; +} diff --git a/tests/features/chat/ui/renderers/turn-diff.test.ts b/tests/features/chat/ui/renderers/turn-diff.test.ts new file mode 100644 index 00000000..229deeb1 --- /dev/null +++ b/tests/features/chat/ui/renderers/turn-diff.test.ts @@ -0,0 +1,174 @@ +// @vitest-environment jsdom + +import { describe, expect, it, vi } from "vitest"; +import type { WorkspaceLeaf } from "obsidian"; + +import { CodexChatTurnDiffView } from "../../../../../src/features/chat/chat-turn-diff-view"; +import { persistedChatTurnDiffViewState, renderChatTurnDiffView } from "../../../../../src/features/chat/ui/turn-diff"; +import { installObsidianDomShims } from "../../../../support/dom"; + +installObsidianDomShims(); + +describe("chat turn diff view decisions", () => { + it("renders the turn diff view with classified unified diff lines", () => { + const parent = document.createElement("div"); + const copyDiff = vi.fn(); + + renderChatTurnDiffView( + parent, + { + threadId: "019e061e-0046-7653-a362-86de9a47cb5c", + turnId: "019e061f-0046-7653-a362-86de9a47cb5c", + cwd: "/vault/project", + files: ["src/main.ts"], + diff: "diff --git a/src/main.ts b/src/main.ts\n--- a/src/main.ts\n+++ b/src/main.ts\n@@\n-old\n+new\n context", + }, + { copyDiff }, + ); + + expect(parent.querySelector(".codex-panel-chat-turn-diff__title")?.textContent).toBe("Turn diff"); + expect(parent.querySelector(".codex-panel-chat-turn-diff__meta")?.textContent).toContain("019e061e"); + expect(parent.querySelector(".codex-panel-chat-turn-diff__files summary")?.textContent).toBe("Changed files"); + expect(parent.querySelector(".codex-panel-chat-turn-diff__files")?.textContent).toContain("src/main.ts"); + expect(parent.querySelector(".codex-panel-diff__line--file")?.textContent).toBe("src/main.ts"); + expect(parent.textContent).not.toContain("diff --git"); + expect(parent.textContent).not.toContain("+++ b/src/main.ts"); + expect(parent.textContent).not.toContain("-old"); + expect(parent.textContent).not.toContain("+new"); + expect(parent.textContent).toContain("old"); + expect(parent.textContent).toContain("new"); + expect(parent.querySelectorAll(".codex-panel-diff__line--hunk")).toHaveLength(1); + expect(parent.querySelectorAll(".codex-panel-diff__line--removed")).toHaveLength(1); + expect(parent.querySelectorAll(".codex-panel-diff__line--added")).toHaveLength(1); + parent.querySelector(".codex-panel-chat-turn-diff__copy")?.click(); + expect(copyDiff).toHaveBeenCalled(); + }); + + it("highlights changed English words inside adjacent removed and added lines", () => { + const parent = document.createElement("div"); + + renderChatTurnDiffView(parent, { + threadId: "thread", + turnId: "turn", + cwd: "/vault/project", + files: ["Note.md"], + diff: "diff --git a/Note.md b/Note.md\n@@\n-The quick brown fox\n+The quick red fox", + }); + + expect(parent.textContent).toContain("The quick brown fox"); + expect(parent.textContent).toContain("The quick red fox"); + expect(parent.querySelector(".codex-panel-diff__word--removed")?.textContent).toBe("brown"); + expect(parent.querySelector(".codex-panel-diff__word--added")?.textContent).toBe("red"); + }); + + it("highlights changed Japanese words with Intl.Segmenter", () => { + const parent = document.createElement("div"); + + renderChatTurnDiffView(parent, { + threadId: "thread", + turnId: "turn", + cwd: "/vault/project", + files: ["Note.md"], + diff: "diff --git a/Note.md b/Note.md\n@@\n-吾輩は猫である\n+吾輩は犬である", + }); + + expect(parent.textContent).toContain("吾輩は猫である"); + expect(parent.textContent).toContain("吾輩は犬である"); + expect(parent.querySelector(".codex-panel-diff__word--removed")?.textContent).toBe("猫"); + expect(parent.querySelector(".codex-panel-diff__word--added")?.textContent).toBe("犬"); + }); + + it("pairs changed words by line inside multi-line replacement blocks", () => { + const parent = document.createElement("div"); + + renderChatTurnDiffView(parent, { + threadId: "thread", + turnId: "turn", + cwd: "/vault/project", + files: ["Note.md"], + diff: [ + "diff --git a/Note.md b/Note.md", + "@@", + "-これはdiffのテストです。", + "-今日は元気です。", + "-とても元気です。", + "+これはdiffのてすとです。", + "+きょうはげんきです。", + "+とてもげんきです。", + ].join("\n"), + }); + + const removedHighlights = Array.from(parent.querySelectorAll(".codex-panel-diff__word--removed"), (element) => element.textContent); + const addedHighlights = Array.from(parent.querySelectorAll(".codex-panel-diff__word--added"), (element) => element.textContent); + + expect(removedHighlights).toEqual(["テスト", "今日", "元気", "元気"]); + expect(addedHighlights).toEqual(["てすと", "きょう", "げんき", "げんき"]); + expect(removedHighlights).not.toContain("これはdiffのテスト"); + }); + + it("falls back to line-level rendering for large intraline candidates", () => { + const parent = document.createElement("div"); + const oldText = `start ${"old ".repeat(600)}end`; + const newText = `start ${"new ".repeat(600)}end`; + + renderChatTurnDiffView(parent, { + threadId: "thread", + turnId: "turn", + cwd: "/vault/project", + files: ["Note.md"], + diff: `diff --git a/Note.md b/Note.md\n@@\n-${oldText}\n+${newText}`, + }); + + expect(parent.textContent).toContain(oldText); + expect(parent.textContent).toContain(newText); + expect(parent.querySelector(".codex-panel-diff__word")).toBeNull(); + }); + + it("keeps unified diff text out of persisted turn diff view state", () => { + const persisted = persistedChatTurnDiffViewState({ + threadId: "thread", + turnId: "turn", + cwd: "/vault/project", + files: ["src/main.ts"], + diff: "@@\n-old\n+new", + }); + + expect(persisted).toEqual({ + threadId: "thread", + turnId: "turn", + cwd: "/vault/project", + files: ["src/main.ts"], + }); + expect(persisted).not.toHaveProperty("diff"); + }); + + it("renders restored turn diff metadata without unavailable diff text", () => { + const parent = document.createElement("div"); + + renderChatTurnDiffView(parent, null, {}, { threadId: "thread", turnId: "turn", cwd: "/vault/project", files: ["src/main.ts"] }); + + expect(parent.querySelector(".codex-panel-chat-turn-diff__meta")?.textContent).toContain("thread / turn"); + expect(parent.textContent).toContain("Turn diff is no longer available."); + expect(parent.querySelector(".codex-panel-chat-turn-diff__copy")).toBeNull(); + expect(parent.querySelector(".codex-panel-chat-turn-diff__diff")).toBeNull(); + }); + + it("unmounts the turn diff React root when the view closes", async () => { + const containerEl = document.createElement("div"); + const view = new CodexChatTurnDiffView({ containerEl } as unknown as WorkspaceLeaf); + + view.setDiffPayload({ + threadId: "thread", + turnId: "turn", + cwd: "/vault/project", + files: ["src/main.ts"], + diff: "diff --git a/src/main.ts b/src/main.ts\n@@\n-old\n+new", + }); + + expect(view.contentEl.querySelector(".codex-panel-chat-turn-diff__title")?.textContent).toBe("Turn diff"); + + await view.onClose(); + + expect(view.contentEl.childElementCount).toBe(0); + }); +}); diff --git a/tests/features/chat/ui/shell.test.tsx b/tests/features/chat/ui/shell.test.tsx index 961fd433..7f04e410 100644 --- a/tests/features/chat/ui/shell.test.tsx +++ b/tests/features/chat/ui/shell.test.tsx @@ -5,7 +5,7 @@ import { act } from "react"; import { chatTurnBusy, createChatStateStore } from "../../../../src/features/chat/chat-state"; import { renderChatPanelShell, unmountChatPanelShell } from "../../../../src/features/chat/ui/shell"; -import { installObsidianDomShims } from "./dom-test-helpers"; +import { installObsidianDomShims } from "../../../support/dom"; installObsidianDomShims(); (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; diff --git a/tests/features/chat/ui/view-dom.test.ts b/tests/features/chat/ui/view-dom.test.ts index c99eeba6..55416d22 100644 --- a/tests/features/chat/ui/view-dom.test.ts +++ b/tests/features/chat/ui/view-dom.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import { renderTextWithWikiLinks } from "../../../../src/shared/ui/dom"; -import { installObsidianDomShims } from "./dom-test-helpers"; +import { installObsidianDomShims } from "../../../support/dom"; installObsidianDomShims(); diff --git a/tests/features/chat/ui/view-renderers.test.ts b/tests/features/chat/ui/view-renderers.test.ts deleted file mode 100644 index 4bfeb072..00000000 --- a/tests/features/chat/ui/view-renderers.test.ts +++ /dev/null @@ -1,1000 +0,0 @@ -// @vitest-environment jsdom - -import { describe, expect, it, vi } from "vitest"; -import type { WorkspaceLeaf } from "obsidian"; - -import { - renderComposerShell, - renderComposerSuggestions, - scrollComposerSuggestionIntoView, - syncComposerHeight, -} from "../../../../src/features/chat/ui/composer"; -import type { ToolbarViewModel } from "../../../../src/features/chat/toolbar-model"; -import { renderToolbar } from "../../../../src/features/chat/ui/toolbar"; -import { CodexChatTurnDiffView } from "../../../../src/features/chat/chat-turn-diff-view"; -import { persistedChatTurnDiffViewState, renderChatTurnDiffView } from "../../../../src/features/chat/ui/turn-diff"; -import { displayDiffLines } from "../../../../src/shared/diff/unified"; -import { changeInputValue, composerSuggestionScrollFixture, installObsidianDomShims } from "./dom-test-helpers"; -import { renderThreadsView } from "../../../../src/features/threads-view/renderer"; -import { liveStateForSnapshots, threadRows, type ThreadsRowModel } from "../../../../src/features/threads-view/state"; -import type { Thread } from "../../../../src/generated/app-server/v2/Thread"; -import type { OpenCodexPanelSnapshot } from "../../../../src/runtime/open-panel-snapshot"; - -installObsidianDomShims(); - -function expectPresent(value: T | null | undefined): T { - if (value === null || value === undefined) throw new Error("Expected value to be present"); - return value; -} - -function composerCallbacks() { - return { - onInput: vi.fn(), - onComposerResize: vi.fn(), - onUpdateSuggestions: vi.fn(), - onKeydown: vi.fn(), - onNewThread: vi.fn(), - onSendOrInterrupt: vi.fn(), - onSuggestionHover: vi.fn(), - onSuggestionInsert: vi.fn(), - }; -} - -function openPanelSnapshot( - overrides: Partial<{ - viewId: string; - threadId: string | null; - turnLifecycle: OpenCodexPanelSnapshot["turnLifecycle"]; - pendingApprovals: number; - pendingUserInputs: number; - hasComposerDraft: boolean; - connected: boolean; - }> = {}, -): OpenCodexPanelSnapshot { - return { - viewId: "view", - threadId: "thread", - turnLifecycle: { kind: "idle" }, - pendingApprovals: 0, - pendingUserInputs: 0, - hasComposerDraft: false, - connected: true, - ...overrides, - }; -} - -function threadFixture(overrides: Partial = {}): Thread { - return { - id: "thread", - sessionId: "session", - forkedFromId: null, - preview: "", - ephemeral: false, - modelProvider: "openai", - createdAt: 1, - updatedAt: 1, - status: { type: "idle" }, - path: null, - cwd: "/vault", - cliVersion: "0.0.0", - source: "appServer", - threadSource: null, - agentNickname: null, - agentRole: null, - gitInfo: null, - name: null, - turns: [], - ...overrides, - }; -} - -function threadsViewActions() { - return { - refresh: vi.fn(), - openNewPanel: vi.fn(), - openThread: vi.fn(), - startRename: vi.fn(), - updateRename: vi.fn(), - saveRename: vi.fn(), - cancelRename: vi.fn(), - autoNameThread: vi.fn(), - startArchive: vi.fn(), - archiveThread: vi.fn(), - }; -} - -describe("chat turn diff view decisions", () => { - it("renders the turn diff view with classified unified diff lines", () => { - const parent = document.createElement("div"); - const copyDiff = vi.fn(); - - renderChatTurnDiffView( - parent, - { - threadId: "019e061e-0046-7653-a362-86de9a47cb5c", - turnId: "019e061f-0046-7653-a362-86de9a47cb5c", - cwd: "/vault/project", - files: ["src/main.ts"], - diff: "diff --git a/src/main.ts b/src/main.ts\n--- a/src/main.ts\n+++ b/src/main.ts\n@@\n-old\n+new\n context", - }, - { copyDiff }, - ); - - expect(parent.querySelector(".codex-panel-chat-turn-diff__title")?.textContent).toBe("Turn diff"); - expect(parent.querySelector(".codex-panel-chat-turn-diff__meta")?.textContent).toContain("019e061e"); - expect(parent.querySelector(".codex-panel-chat-turn-diff__files summary")?.textContent).toBe("Changed files"); - expect(parent.querySelector(".codex-panel-chat-turn-diff__files")?.textContent).toContain("src/main.ts"); - expect(parent.querySelector(".codex-panel-diff__line--file")?.textContent).toBe("src/main.ts"); - expect(parent.textContent).not.toContain("diff --git"); - expect(parent.textContent).not.toContain("+++ b/src/main.ts"); - expect(parent.textContent).not.toContain("-old"); - expect(parent.textContent).not.toContain("+new"); - expect(parent.textContent).toContain("old"); - expect(parent.textContent).toContain("new"); - expect(parent.querySelectorAll(".codex-panel-diff__line--hunk")).toHaveLength(1); - expect(parent.querySelectorAll(".codex-panel-diff__line--removed")).toHaveLength(1); - expect(parent.querySelectorAll(".codex-panel-diff__line--added")).toHaveLength(1); - parent.querySelector(".codex-panel-chat-turn-diff__copy")?.click(); - expect(copyDiff).toHaveBeenCalled(); - }); - - it("highlights changed English words inside adjacent removed and added lines", () => { - const parent = document.createElement("div"); - - renderChatTurnDiffView(parent, { - threadId: "thread", - turnId: "turn", - cwd: "/vault/project", - files: ["Note.md"], - diff: "diff --git a/Note.md b/Note.md\n@@\n-The quick brown fox\n+The quick red fox", - }); - - expect(parent.textContent).toContain("The quick brown fox"); - expect(parent.textContent).toContain("The quick red fox"); - expect(parent.querySelector(".codex-panel-diff__word--removed")?.textContent).toBe("brown"); - expect(parent.querySelector(".codex-panel-diff__word--added")?.textContent).toBe("red"); - }); - - it("highlights changed Japanese words with Intl.Segmenter", () => { - const parent = document.createElement("div"); - - renderChatTurnDiffView(parent, { - threadId: "thread", - turnId: "turn", - cwd: "/vault/project", - files: ["Note.md"], - diff: "diff --git a/Note.md b/Note.md\n@@\n-吾輩は猫である\n+吾輩は犬である", - }); - - expect(parent.textContent).toContain("吾輩は猫である"); - expect(parent.textContent).toContain("吾輩は犬である"); - expect(parent.querySelector(".codex-panel-diff__word--removed")?.textContent).toBe("猫"); - expect(parent.querySelector(".codex-panel-diff__word--added")?.textContent).toBe("犬"); - }); - - it("pairs changed words by line inside multi-line replacement blocks", () => { - const parent = document.createElement("div"); - - renderChatTurnDiffView(parent, { - threadId: "thread", - turnId: "turn", - cwd: "/vault/project", - files: ["Note.md"], - diff: [ - "diff --git a/Note.md b/Note.md", - "@@", - "-これはdiffのテストです。", - "-今日は元気です。", - "-とても元気です。", - "+これはdiffのてすとです。", - "+きょうはげんきです。", - "+とてもげんきです。", - ].join("\n"), - }); - - const removedHighlights = Array.from(parent.querySelectorAll(".codex-panel-diff__word--removed"), (element) => element.textContent); - const addedHighlights = Array.from(parent.querySelectorAll(".codex-panel-diff__word--added"), (element) => element.textContent); - - expect(removedHighlights).toEqual(["テスト", "今日", "元気", "元気"]); - expect(addedHighlights).toEqual(["てすと", "きょう", "げんき", "げんき"]); - expect(removedHighlights).not.toContain("これはdiffのテスト"); - }); - - it("falls back to line-level rendering for large intraline candidates", () => { - const parent = document.createElement("div"); - const oldText = `start ${"old ".repeat(600)}end`; - const newText = `start ${"new ".repeat(600)}end`; - - renderChatTurnDiffView(parent, { - threadId: "thread", - turnId: "turn", - cwd: "/vault/project", - files: ["Note.md"], - diff: `diff --git a/Note.md b/Note.md\n@@\n-${oldText}\n+${newText}`, - }); - - expect(parent.textContent).toContain(oldText); - expect(parent.textContent).toContain(newText); - expect(parent.querySelector(".codex-panel-diff__word")).toBeNull(); - }); - - it("keeps unified diff text out of persisted turn diff view state", () => { - const persisted = persistedChatTurnDiffViewState({ - threadId: "thread", - turnId: "turn", - cwd: "/vault/project", - files: ["src/main.ts"], - diff: "@@\n-old\n+new", - }); - - expect(persisted).toEqual({ - threadId: "thread", - turnId: "turn", - cwd: "/vault/project", - files: ["src/main.ts"], - }); - expect(persisted).not.toHaveProperty("diff"); - }); - - it("renders restored turn diff metadata without unavailable diff text", () => { - const parent = document.createElement("div"); - - renderChatTurnDiffView(parent, null, {}, { threadId: "thread", turnId: "turn", cwd: "/vault/project", files: ["src/main.ts"] }); - - expect(parent.querySelector(".codex-panel-chat-turn-diff__meta")?.textContent).toContain("thread / turn"); - expect(parent.textContent).toContain("Turn diff is no longer available."); - expect(parent.querySelector(".codex-panel-chat-turn-diff__copy")).toBeNull(); - expect(parent.querySelector(".codex-panel-chat-turn-diff__diff")).toBeNull(); - }); - - it("unmounts the turn diff React root when the view closes", async () => { - const containerEl = document.createElement("div"); - const view = new CodexChatTurnDiffView({ containerEl } as unknown as WorkspaceLeaf); - - view.setDiffPayload({ - threadId: "thread", - turnId: "turn", - cwd: "/vault/project", - files: ["src/main.ts"], - diff: "diff --git a/src/main.ts b/src/main.ts\n@@\n-old\n+new", - }); - - expect(view.contentEl.querySelector(".codex-panel-chat-turn-diff__title")?.textContent).toBe("Turn diff"); - - await view.onClose(); - - expect(view.contentEl.childElementCount).toBe(0); - }); - - it("simplifies git diff file headers for turn diff display", () => { - expect( - displayDiffLines( - "diff --git a/days/2026-05-16.md b/days/2026-05-16.md\nindex 111..222\n--- a/days/2026-05-16.md\n+++ b/days/2026-05-16.md\n@@\n-old\n+new", - ), - ).toEqual([{ text: "days/2026-05-16.md", kind: "file" }, { text: "@@" }, { text: "-old" }, { text: "+new" }]); - }); - - it("keeps added-file diffs readable after simplifying headers", () => { - expect( - displayDiffLines( - "diff --git a/new-note.md b/new-note.md\nnew file mode 100644\nindex 0000000..1111111\n--- /dev/null\n+++ b/new-note.md\n@@\n+hello", - ), - ).toEqual([{ text: "new-note.md", kind: "file" }, { text: "new file mode 100644" }, { text: "@@" }, { text: "+hello" }]); - }); - - it("keeps body lines that look like file markers after the hunk starts", () => { - expect( - displayDiffLines( - "diff --git a/note.md b/note.md\nindex 111..222\n--- a/note.md\n+++ b/note.md\n@@\n+++ frontmatter\n--- removed marker", - ), - ).toEqual([{ text: "note.md", kind: "file" }, { text: "@@" }, { text: "+++ frontmatter" }, { text: "--- removed marker" }]); - }); -}); - -describe("toolbar renderer decisions", () => { - it("renders toolbar controls as buttons and updates live status state", () => { - const parent = document.createElement("div"); - const toggleHistory = vi.fn(); - const toggleAutoReview = vi.fn(); - const baseModel = toolbarModel(); - - renderToolbar(parent, baseModel, toolbarActions({ toggleHistory, toggleAutoReview })); - - const statusButton = parent.querySelector(".codex-panel__status-dot"); - expect(statusButton?.tagName).toBe("BUTTON"); - expect(statusButton?.getAttribute("role")).toBeNull(); - expect(statusButton?.getAttribute("aria-label")).toBe("Show connection status"); - expect(statusButton?.classList.contains("nav-action-button")).toBe(false); - expect(statusButton?.classList.contains("clickable-icon")).toBe(true); - const historyButton = parent.querySelector(".codex-panel__history-toggle"); - expect(historyButton?.getAttribute("aria-label")).toBe("Show thread list"); - expect(historyButton?.classList.contains("nav-action-button")).toBe(false); - expect(historyButton?.classList.contains("clickable-icon")).toBe(true); - historyButton?.click(); - expect(toggleHistory).toHaveBeenCalled(); - const autoReviewButton = parent.querySelector(".codex-panel__auto-review-toggle"); - expect(autoReviewButton?.getAttribute("aria-label")).toBe("Toggle auto-review"); - expect(autoReviewButton?.getAttribute("aria-pressed")).toBe("false"); - expect(autoReviewButton?.classList.contains("nav-action-button")).toBe(false); - expect(autoReviewButton?.classList.contains("clickable-icon")).toBe(true); - autoReviewButton?.click(); - expect(toggleAutoReview).toHaveBeenCalled(); - - parent.empty(); - renderToolbar(parent, toolbarModel({ status: "Turn running...", statusState: "running", autoReviewActive: true }), toolbarActions()); - expect(parent.querySelector(".codex-panel__status-dot")?.getAttribute("aria-label")).toBe("Show connection status"); - expect(parent.querySelector(".codex-panel__auto-review-toggle")?.getAttribute("aria-pressed")).toBe("true"); - - parent.empty(); - renderToolbar(parent, toolbarModel({ historyOpen: true, statusPanelOpen: true }), toolbarActions()); - expect(parent.querySelector(".codex-panel__history-toggle")?.getAttribute("aria-label")).toBe("Hide thread list"); - expect(parent.querySelector(".codex-panel__history-toggle")?.classList.contains("is-active")).toBe(false); - expect(parent.querySelector(".codex-panel__status-dot")?.getAttribute("aria-label")).toBe("Hide connection status"); - expect(parent.querySelector(".codex-panel__runtime-model")?.getAttribute("aria-label")).toBe("Change model and reasoning effort"); - }); - - it("keeps frequently changed effort choices first inside the runtime menu", () => { - const parent = document.createElement("div"); - - renderToolbar( - parent, - toolbarModel({ - modelChoices: [{ label: "gpt-5.5", selected: true, onClick: vi.fn() }], - effortChoices: [{ label: "high", selected: true, onClick: vi.fn() }], - }), - toolbarActions(), - ); - - expect([...parent.querySelectorAll(".codex-panel__runtime-picker-label")].map((label) => label.textContent)).toEqual([ - "Reasoning effort", - "Model", - ]); - expect([...parent.querySelectorAll(".codex-panel__runtime-choice")].map((choice) => choice.textContent)).toEqual(["high", "gpt-5.5"]); - for (const choice of parent.querySelectorAll(".codex-panel__runtime-choice")) { - expect(choice.getAttribute("role")).toBe("option"); - expect(choice.getAttribute("aria-selected")).toBe("true"); - expect(choice.querySelector(".codex-panel__toolbar-panel-check")?.dataset["icon"]).toBe("check"); - expect(choice.classList.contains("selected")).toBe(false); - expect(choice.classList.contains("is-selected")).toBe(false); - } - }); - - it("renders context as a compact meter and Codex limits only in the status menu", () => { - const parent = document.createElement("div"); - - renderToolbar( - parent, - toolbarModel({ - statusPanelOpen: true, - openPanel: "status", - context: { label: "12%", title: "Context: 12%.", percent: 12, level: "ok" }, - rateLimit: { - title: "Codex: 5h 42%, 1w 21%", - level: "ok", - rows: [ - { - label: "5h", - value: "42%", - resetLabel: "reset in 2h", - title: "Codex 5h: 42% used.", - percent: 42, - level: "ok", - }, - { - label: "1w", - value: "21%", - resetLabel: "reset in 3d 4h", - title: "Codex 1w: 21% used.", - percent: 21, - level: "ok", - }, - ], - }, - }), - toolbarActions(), - ); - - expect(parent.querySelector(".codex-panel__context-compact")?.textContent).toContain("12%"); - expect(parent.querySelector(".codex-panel__limit-compact")).toBeNull(); - expect(parent.querySelector(".codex-panel__limit-panel")?.textContent).toContain("5h"); - expect(parent.querySelector(".codex-panel__limit-panel")?.textContent).toContain("42%"); - expect(parent.querySelector(".codex-panel__limit-panel")?.textContent).toContain("reset in 2h"); - expect(parent.querySelector(".codex-panel__limit-panel")?.textContent).toContain("1w"); - expect(parent.querySelector(".codex-panel__limit-panel")?.textContent).toContain("21%"); - expect(parent.querySelector(".codex-panel__limit-panel")?.textContent).toContain("reset in 3d 4h"); - }); - - it("renders connection diagnostics in the status menu", () => { - const parent = document.createElement("div"); - const refreshStatus = vi.fn(); - - renderToolbar( - parent, - toolbarModel({ - statusPanelOpen: true, - openPanel: "status", - diagnostics: [ - { title: "Process", rows: [{ label: "Codex App Server", value: "codex-cli/1.2.3" }] }, - { title: "Capabilities", rows: [{ label: "compatibility", value: "model/list failed", level: "error" }] }, - ], - }), - toolbarActions({ refreshStatus }), - ); - - expect(parent.querySelector(".codex-panel__connection-diagnostics-title")?.textContent).toBe("Connection"); - expect(parent.textContent).toContain("Process"); - expect(parent.textContent).toContain("Capabilities"); - expect(parent.textContent).toContain("Effective Codex config"); - expect(parent.textContent).toContain("Refresh status"); - expect(parent.textContent).not.toContain("Refresh diagnostics"); - expect(parent.textContent).not.toContain("Refresh thread list"); - expect(parent.textContent).toContain("codex-cli/1.2.3"); - expect(parent.querySelector(".codex-panel__connection-diagnostics-row--error")?.textContent).toContain("model/list failed"); - const statusItems = [...parent.querySelectorAll(".codex-panel__status-panel-item")]; - expect(statusItems.map((item) => item.getAttribute("role"))).toEqual(["menuitem", "menuitem"]); - expect(statusItems.every((item) => item.getAttribute("aria-selected") === null)).toBe(true); - statusItems.find((item) => item.textContent.includes("Refresh status"))?.click(); - expect(refreshStatus).toHaveBeenCalled(); - }); - - it("renders status dot states without diagnostic overlay badges", () => { - for (const statusState of ["ready", "degraded", "blocked", "running", "offline"] as const) { - const parent = document.createElement("div"); - renderToolbar(parent, toolbarModel({ statusState }), toolbarActions()); - const status = parent.querySelector(".codex-panel__status-dot"); - expect(status?.classList.contains(`codex-panel__status-dot--${statusState}`)).toBe(true); - expect(status?.childElementCount).toBe(0); - expect(status?.getAttribute("aria-label")).toBe("Show connection status"); - } - }); - - it("renders effective config inside the status menu without a separate toggle", () => { - const parent = document.createElement("div"); - - renderToolbar( - parent, - toolbarModel({ - statusPanelOpen: true, - openPanel: "status", - configSections: [{ title: "Runtime", rows: [{ key: "model", value: "gpt-5.5" }] }], - }), - toolbarActions(), - ); - - expect(parent.querySelector(".codex-panel__slot--config")).toBeNull(); - expect(parent.querySelector(".codex-panel__toolbar-panel .codex-panel__config")?.textContent).toContain("Effective Codex config"); - expect(parent.textContent).not.toContain("Show effective config"); - expect(parent.textContent).not.toContain("Hide effective config"); - expect(parent.textContent).toContain("gpt-5.5"); - }); - - it("renders thread list rename actions and an inline rename editor", () => { - const parent = document.createElement("div"); - const startRenameThread = vi.fn(); - const updateRenameDraft = vi.fn(); - const saveRenameThread = vi.fn(); - const cancelRenameThread = vi.fn(); - const autoNameThread = vi.fn(); - const actions = toolbarActions({ startRenameThread, updateRenameDraft, saveRenameThread, cancelRenameThread, autoNameThread }); - - renderToolbar( - parent, - toolbarModel({ - historyOpen: true, - openPanel: "history", - threads: [ - { title: "Thread", threadId: "thread", selected: true, disabled: false, canArchive: true, rename: null }, - { - title: "Editing", - threadId: "editing", - selected: false, - disabled: false, - canArchive: true, - rename: { draft: "Draft title", generating: false }, - }, - ], - }), - actions, - ); - - parent.querySelector('[aria-label="Rename thread"]')?.click(); - expect(startRenameThread).toHaveBeenCalledWith("thread"); - - const input = parent.querySelector(".codex-panel__thread-rename-input"); - if (!input) throw new Error("Missing thread rename input"); - expect(input.closest(".codex-panel__thread-rename")?.querySelector(".codex-panel__toolbar-panel-check")).not.toBeNull(); - expect(input.value).toBe("Draft title"); - changeInputValue(input, "New title"); - expect(updateRenameDraft).toHaveBeenCalledWith("editing", "New title"); - - renderToolbar( - parent, - toolbarModel({ - historyOpen: true, - openPanel: "history", - threads: [ - { title: "Thread", threadId: "thread", selected: true, disabled: false, canArchive: true, rename: null }, - { - title: "Editing", - threadId: "editing", - selected: false, - disabled: false, - canArchive: true, - rename: { draft: "New title", generating: false }, - }, - ], - }), - actions, - ); - expectPresent(parent.querySelector(".codex-panel__thread-rename-input")).dispatchEvent( - new FocusEvent("focusout", { bubbles: true }), - ); - expect(saveRenameThread).toHaveBeenCalledWith("editing", "New title"); - expectPresent(parent.querySelector(".codex-panel__thread-rename-input")).dispatchEvent( - new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), - ); - expect(cancelRenameThread).toHaveBeenCalledWith("editing"); - expect(parent.querySelector('[aria-label="Save thread name"]')).toBeNull(); - expect(parent.querySelector('[aria-label="Cancel rename"]')).toBeNull(); - parent.querySelector('[aria-label="Auto-name thread"]')?.click(); - expect(autoNameThread).toHaveBeenCalledWith("editing"); - }); - - it("renders auto-name loading without disabling the rename draft field", () => { - const parent = document.createElement("div"); - - renderToolbar( - parent, - toolbarModel({ - historyOpen: true, - openPanel: "history", - threads: [ - { - title: "Editing", - threadId: "editing", - selected: false, - disabled: false, - canArchive: true, - rename: { draft: "Draft title", generating: true }, - }, - ], - }), - toolbarActions(), - ); - - expect(parent.querySelector(".codex-panel__thread-rename-input")?.disabled).toBe(false); - expect(parent.querySelector('[aria-label="Save thread name"]')).toBeNull(); - expect(parent.querySelector('[aria-label="Auto-name thread"]')?.disabled).toBe(true); - expect(parent.querySelector('[aria-label="Cancel rename"]')).toBeNull(); - }); - - it("renders toolbar archive confirmation with the default action on the right", () => { - const parent = document.createElement("div"); - const startArchiveThread = vi.fn(); - const archiveThread = vi.fn(); - - renderToolbar( - parent, - toolbarModel({ - historyOpen: true, - openPanel: "history", - threads: [ - { - title: "Thread", - threadId: "thread", - selected: true, - disabled: false, - canArchive: true, - archiveConfirm: { active: true, defaultSaveMarkdown: true }, - rename: null, - }, - ], - }), - toolbarActions({ startArchiveThread, archiveThread }), - ); - - const confirm = expectPresent(parent.querySelector(".codex-panel__archive-confirm")); - const archiveButtons = [ - ...confirm.querySelectorAll(".codex-panel__archive-alternate, .codex-panel__archive-default"), - ]; - expect(archiveButtons.map((button) => button.getAttribute("aria-label"))).toEqual([ - "Archive thread without saving", - "Save and archive thread", - ]); - expect(parent.querySelector('[aria-label="Rename thread"]')).toBeNull(); - archiveButtons[0]?.click(); - expect(archiveThread).toHaveBeenCalledWith("thread", false); - archiveButtons[1]?.click(); - expect(archiveThread).toHaveBeenCalledWith("thread", true); - expect(startArchiveThread).not.toHaveBeenCalled(); - }); -}); - -describe("threads view renderer decisions", () => { - it("prioritizes open panel live state per thread", () => { - expect( - liveStateForSnapshots([ - openPanelSnapshot({ viewId: "open", threadId: "thread" }), - openPanelSnapshot({ viewId: "running", threadId: "thread", turnLifecycle: { kind: "running", turnId: "turn" } }), - openPanelSnapshot({ viewId: "approval", threadId: "thread", pendingApprovals: 1 }), - openPanelSnapshot({ viewId: "input", threadId: "thread", pendingUserInputs: 1 }), - ]), - ).toMatchObject({ status: "needs-input", label: "Needs input", viewId: "input", openPanels: 4 }); - - expect(liveStateForSnapshots([openPanelSnapshot({ viewId: "draft", threadId: "thread", hasComposerDraft: true })])).toMatchObject({ - status: "draft", - label: "Draft", - }); - expect(liveStateForSnapshots([openPanelSnapshot({ viewId: "offline", threadId: "thread", connected: false })])).toMatchObject({ - status: "offline", - label: "Offline", - }); - expect( - liveStateForSnapshots([openPanelSnapshot({ viewId: "none", threadId: null, turnLifecycle: { kind: "running", turnId: "turn" } })]), - ).toBeNull(); - }); - - it("renders thread rows with live state and routes open actions", () => { - const parent = document.createElement("div"); - const actions = threadsViewActions(); - const rows = threadRows( - [threadFixture({ id: "closed", preview: "Closed thread" }), threadFixture({ id: "open", preview: "Open thread", updatedAt: 2 })], - [openPanelSnapshot({ viewId: "view-open", threadId: "open", pendingApprovals: 1 })], - new Map(), - ); - - renderThreadsView(parent, { status: "2 threads", loading: false, rows }, actions); - - expect(parent.querySelector(".codex-panel-threads__badge")).toBeNull(); - const row = expectPresent(parent.querySelector(".codex-panel-threads__row--approval")); - expect(row.getAttribute("title")).toBeNull(); - 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("codex-panel-threads__toolbar-button")).toBe(true); - refresh.click(); - expect(actions.refresh).toHaveBeenCalledOnce(); - const openNewPanel = expectPresent(parent.querySelector('[aria-label="Open new panel"]')); - expect(openNewPanel.classList.contains("codex-panel-threads__toolbar-button")).toBe(true); - expect(openNewPanel.classList.contains("codex-panel-threads__row-button")).toBe(false); - openNewPanel.click(); - expect(actions.openNewPanel).toHaveBeenCalledOnce(); - row.click(); - expect(actions.openThread).toHaveBeenCalledWith("open"); - row.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); - expect(actions.openThread).toHaveBeenCalledTimes(2); - expect(parent.querySelector('[aria-label="Focus open panel"]')).toBeNull(); - expect(parent.querySelector('[aria-label="Open in new panel"]')).toBeNull(); - }); - - it("renders threads view archive confirmation with the default action on the right", () => { - const parent = document.createElement("div"); - const actions = threadsViewActions(); - const row: ThreadsRowModel = { - thread: threadFixture({ id: "thread", name: "Thread" }), - title: "Thread", - live: null, - rename: { active: false, draft: "Thread", generating: false }, - archiveConfirm: { active: true, defaultSaveMarkdown: false }, - }; - - renderThreadsView(parent, { status: "1 thread", loading: false, rows: [row] }, actions); - - const confirm = expectPresent(parent.querySelector(".codex-panel-threads__archive-confirm")); - const archiveButtons = [ - ...confirm.querySelectorAll(".codex-panel-threads__archive-alternate, .codex-panel-threads__archive-default"), - ]; - expect(archiveButtons.map((button) => button.getAttribute("aria-label"))).toEqual([ - "Save and archive thread", - "Archive thread without saving", - ]); - expect(parent.querySelector('[aria-label="Rename thread"]')).toBeNull(); - archiveButtons[0]?.click(); - expect(actions.archiveThread).toHaveBeenCalledWith("thread", true); - archiveButtons[1]?.click(); - expect(actions.archiveThread).toHaveBeenCalledWith("thread", false); - }); - - it("starts threads view archive confirmation before archiving", () => { - const parent = document.createElement("div"); - const actions = threadsViewActions(); - const row: ThreadsRowModel = { - thread: threadFixture({ id: "thread", name: "Thread" }), - title: "Thread", - live: null, - rename: { active: false, draft: "Thread", generating: false }, - }; - - renderThreadsView(parent, { status: "1 thread", loading: false, rows: [row] }, actions); - parent.querySelector('[aria-label="Archive thread"]')?.click(); - - expect(actions.startArchive).toHaveBeenCalledWith("thread"); - expect(actions.archiveThread).not.toHaveBeenCalled(); - }); - - it("renders rename rows and saves entered values", () => { - const parent = document.createElement("div"); - const actions = threadsViewActions(); - const row: ThreadsRowModel = { - thread: threadFixture({ id: "thread", name: "Old name" }), - title: "Old name", - live: null, - rename: { active: true, draft: "Old name", generating: false }, - }; - - renderThreadsView(parent, { status: "1 thread", loading: false, rows: [row] }, actions); - - const input = expectPresent(parent.querySelector(".codex-panel-threads__rename-input")); - changeInputValue(input, "New name"); - expect(actions.updateRename).toHaveBeenCalledWith("thread", "New name"); - - renderThreadsView( - parent, - { 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("focusout", { bubbles: true }), - ); - expect(actions.saveRename).toHaveBeenCalledWith("thread", "New name"); - - expectPresent(parent.querySelector(".codex-panel-threads__row")).click(); - expect(actions.openThread).not.toHaveBeenCalled(); - }); - - it("renders threads view rename actions inline with auto-name", () => { - const parent = document.createElement("div"); - const actions = threadsViewActions(); - const row: ThreadsRowModel = { - thread: threadFixture({ id: "thread", name: "Old name" }), - title: "Old name", - live: null, - rename: { active: true, draft: "Old name", generating: false }, - }; - - renderThreadsView(parent, { status: "1 thread", loading: false, rows: [row] }, actions); - - expect(parent.querySelector(".codex-panel-threads__rename-form")).toBeTruthy(); - const actionsGroup = expectPresent(parent.querySelector(".codex-panel-threads__rename-actions")); - expect(actionsGroup.querySelectorAll(".codex-panel-threads__row-button")).toHaveLength(1); - expect(parent.querySelector('[aria-label="Save thread name"]')).toBeNull(); - expect(parent.querySelector('[aria-label="Cancel rename"]')).toBeNull(); - parent.querySelector('[aria-label="Auto-name thread"]')?.click(); - - expect(actions.autoNameThread).toHaveBeenCalledWith("thread"); - }); - - it("renders threads view rename auto-name loading state", () => { - const parent = document.createElement("div"); - const row: ThreadsRowModel = { - thread: threadFixture({ id: "thread", name: "Old name" }), - title: "Old name", - live: null, - rename: { active: true, draft: "Old name", generating: true }, - }; - - renderThreadsView(parent, { status: "1 thread", loading: false, rows: [row] }, threadsViewActions()); - - expect(parent.querySelector(".codex-panel-threads__rename-input")?.disabled).toBe(false); - expect(parent.querySelector('[aria-label="Save thread name"]')).toBeNull(); - expect(parent.querySelector('[aria-label="Auto-name thread"]')?.disabled).toBe(true); - expect(parent.querySelector('[aria-label="Cancel rename"]')).toBeNull(); - }); -}); - -describe("composer renderer decisions", () => { - it("uses the provided composer placeholder for normal input", () => { - const parent = document.createElement("div"); - const callbacks = composerCallbacks(); - const { composer } = renderComposerShell( - parent, - "view", - "", - false, - false, - "Ask Codex to work on “Refactor terminal streaming”...", - callbacks, - ); - - expect(composer.getAttribute("placeholder")).toBe("Ask Codex to work on “Refactor terminal streaming”..."); - - renderComposerShell(parent, "view", "", false, false, "Ask Codex to work on “Renamed thread”...", callbacks); - - expect(composer.getAttribute("placeholder")).toBe("Ask Codex to work on “Renamed thread”..."); - }); - - it("renders composer suggestions outside normal input flow callbacks", () => { - const parent = document.createElement("div"); - const onSuggestionInsert = vi.fn(); - const { composer, suggestions } = renderComposerShell(parent, "view", "", false, false, "Ask Codex to work on this task...", { - onInput: vi.fn(), - onComposerResize: vi.fn(), - onUpdateSuggestions: vi.fn(), - onKeydown: vi.fn(), - onNewThread: vi.fn(), - onSendOrInterrupt: vi.fn(), - onSuggestionHover: vi.fn(), - onSuggestionInsert, - }); - - renderComposerSuggestions( - suggestions, - composer, - "view", - [{ display: "/help", detail: "Show help", replacement: "/help", start: 0 }], - 0, - { onSuggestionHover: vi.fn(), onSuggestionInsert }, - ); - - expect(suggestions.getAttribute("role")).toBe("listbox"); - expect(composer.getAttribute("aria-expanded")).toBe("true"); - suggestions - .querySelector(".codex-panel__composer-suggestion") - ?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); - expect(onSuggestionInsert).toHaveBeenCalled(); - }); - - it("reports composer draft changes from the controlled input", () => { - const parent = document.createElement("div"); - const callbacks = composerCallbacks(); - const { composer } = renderComposerShell(parent, "view", "", false, false, "Ask Codex to work on this task...", callbacks); - - changeInputValue(composer, "Draft text"); - - expect(callbacks.onInput).toHaveBeenCalledWith("Draft text"); - }); - - it("reports composer resize when autogrow changes the input height", () => { - const parent = document.createElement("div"); - const callbacks = composerCallbacks(); - let scrollHeight = 56; - const descriptor = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "scrollHeight"); - Object.defineProperty(HTMLTextAreaElement.prototype, "scrollHeight", { - get: () => scrollHeight, - configurable: true, - }); - try { - const { composer } = renderComposerShell(parent, "view", "", false, false, "Ask Codex to work on this task...", callbacks); - callbacks.onComposerResize.mockClear(); - - scrollHeight = 120; - changeInputValue(composer, "line one\nline two"); - - expect(callbacks.onComposerResize).toHaveBeenCalledOnce(); - } finally { - if (descriptor) { - Object.defineProperty(HTMLTextAreaElement.prototype, "scrollHeight", descriptor); - } else { - Reflect.deleteProperty(HTMLTextAreaElement.prototype, "scrollHeight"); - } - } - }); - - it("scrolls the selected composer suggestion fully into view below the viewport", () => { - const { container, option } = composerSuggestionScrollFixture({ - clientHeight: 100, - optionHeight: 32, - optionTop: 92, - scrollTop: 0, - }); - - scrollComposerSuggestionIntoView(container, option); - - expect(container.scrollTop).toBe(24); - }); - - it("scrolls the selected composer suggestion fully into view above the viewport", () => { - const { container, option } = composerSuggestionScrollFixture({ - clientHeight: 100, - optionHeight: 32, - optionTop: 48, - scrollTop: 64, - }); - - scrollComposerSuggestionIntoView(container, option); - - expect(container.scrollTop).toBe(48); - }); - - it("keeps composer suggestion scroll position when the selected item is already visible", () => { - const { container, option } = composerSuggestionScrollFixture({ - clientHeight: 100, - optionHeight: 32, - optionTop: 72, - scrollTop: 48, - }); - - scrollComposerSuggestionIntoView(container, option); - - expect(container.scrollTop).toBe(48); - }); - - it("uses the composer action for interrupt only when a running turn has no steering text", () => { - const parent = document.createElement("div"); - const callbacks = composerCallbacks(); - const { composer } = renderComposerShell(parent, "view", "", true, true, "Ask Codex to work on this task...", callbacks); - let sendButton = parent.querySelector(".codex-panel__send"); - - expect(sendButton?.getAttribute("aria-label")).toBe("Interrupt"); - expect(composer.getAttribute("placeholder")).toBe("Add steering message..."); - expect(sendButton?.classList.contains("is-interrupt")).toBe(true); - expect(sendButton?.classList.contains("is-steer")).toBe(false); - expect(sendButton?.dataset["icon"]).toBe("square"); - - renderComposerShell(parent, "view", "adjust course", true, true, "Ask Codex to work on this task...", callbacks); - sendButton = parent.querySelector(".codex-panel__send"); - expect(sendButton?.getAttribute("aria-label")).toBe("Steer"); - expect(composer.getAttribute("placeholder")).toBe("Add steering message..."); - expect(sendButton?.classList.contains("is-interrupt")).toBe(false); - expect(sendButton?.classList.contains("is-steer")).toBe(true); - expect(sendButton?.dataset["icon"]).toBe("corner-down-right"); - }); - - it("honors the smaller viewport branch of the composer max-height CSS", () => { - const composer = document.createElement("textarea"); - const getComputedStyleMock = vi.spyOn(window, "getComputedStyle").mockReturnValue({ - minHeight: "76px", - maxHeight: "min(208px, 40vh)", - } as CSSStyleDeclaration); - Object.defineProperty(window, "innerHeight", { value: 400, configurable: true }); - Object.defineProperty(composer, "scrollHeight", { value: 280, configurable: true }); - - try { - syncComposerHeight(composer); - } finally { - getComputedStyleMock.mockRestore(); - } - - expect(composer.style.height).toBe("160px"); - expect(composer.style.overflowY).toBe("auto"); - }); -}); - -function toolbarModel(overrides: Partial = {}): ToolbarViewModel { - return { - connected: true, - status: "Connected.", - statusState: "ready", - historyOpen: false, - statusPanelOpen: false, - runtimeOpen: true, - planActive: false, - autoReviewActive: false, - fastActive: false, - runtimeSummary: "5.5 high", - runtimeTitle: "Model: gpt-5.5; Effort: high", - runtimeEmphasized: false, - context: null, - rateLimit: null, - configSections: [], - openPanel: "runtime", - threads: [{ title: "Thread", threadId: "thread", selected: true, disabled: false, canArchive: true, rename: null }], - modelChoices: [{ label: "Default", selected: true, onClick: vi.fn() }], - effortChoices: [{ label: "Default", selected: true, onClick: vi.fn() }], - connectLabel: "Reconnect", - diagnostics: [{ title: "Process", rows: [{ label: "Codex App Server", value: "codex-cli/test" }] }], - ...overrides, - }; -} - -function toolbarActions(overrides: Partial[2]> = {}): Parameters[2] { - return { - toggleHistory: vi.fn(), - toggleAutoReview: vi.fn(), - toggleStatusPanel: vi.fn(), - togglePlan: vi.fn(), - toggleFast: vi.fn(), - toggleRuntime: vi.fn(), - connect: vi.fn(), - refreshStatus: vi.fn(), - resumeThread: vi.fn(), - startArchiveThread: vi.fn(), - archiveThread: vi.fn(), - startRenameThread: vi.fn(), - updateRenameDraft: vi.fn(), - saveRenameThread: vi.fn(), - cancelRenameThread: vi.fn(), - autoNameThread: vi.fn(), - ...overrides, - }; -} diff --git a/tests/features/chat/ui/view-scroll.test.ts b/tests/features/chat/ui/view-scroll.test.ts index 50fd6bcb..eb8e1913 100644 --- a/tests/features/chat/ui/view-scroll.test.ts +++ b/tests/features/chat/ui/view-scroll.test.ts @@ -9,7 +9,7 @@ import { MessageScrollController, restoreScrollAnchor, } from "../../../../src/features/chat/ui/scroll"; -import { installObsidianDomShims } from "./dom-test-helpers"; +import { installObsidianDomShims } from "../../../support/dom"; installObsidianDomShims(); diff --git a/tests/features/chat/view-connection.test.ts b/tests/features/chat/view-connection.test.ts index b956150c..5d96d311 100644 --- a/tests/features/chat/view-connection.test.ts +++ b/tests/features/chat/view-connection.test.ts @@ -9,7 +9,7 @@ import { createChatState, type ChatState } from "../../../src/features/chat/chat import { composerSlotSnapshot } from "../../../src/features/chat/view-snapshot"; import type { ServerNotification } from "../../../src/generated/app-server/ServerNotification"; import { notices } from "../../mocks/obsidian"; -import { installObsidianDomShims } from "./ui/dom-test-helpers"; +import { installObsidianDomShims } from "../../support/dom"; const connectionMock = vi.hoisted(() => { const state = { diff --git a/tests/features/selection-rewrite/selection-rewrite.test.ts b/tests/features/selection-rewrite/selection-rewrite.test.ts index 7e709951..e99c884b 100644 --- a/tests/features/selection-rewrite/selection-rewrite.test.ts +++ b/tests/features/selection-rewrite/selection-rewrite.test.ts @@ -39,7 +39,7 @@ import type { ThreadItem } from "../../../src/generated/app-server/v2/ThreadItem import type { ThreadStartResponse } from "../../../src/generated/app-server/v2/ThreadStartResponse"; import type { Turn } from "../../../src/generated/app-server/v2/Turn"; import type { TurnStartResponse } from "../../../src/generated/app-server/v2/TurnStartResponse"; -import { installObsidianDomShims } from "../chat/ui/dom-test-helpers"; +import { installObsidianDomShims } from "../../support/dom"; installObsidianDomShims(); diff --git a/tests/features/threads-view/renderer.test.ts b/tests/features/threads-view/renderer.test.ts new file mode 100644 index 00000000..4d196be8 --- /dev/null +++ b/tests/features/threads-view/renderer.test.ts @@ -0,0 +1,251 @@ +// @vitest-environment jsdom + +import { describe, expect, it, vi } from "vitest"; + +import { renderThreadsView } from "../../../src/features/threads-view/renderer"; +import { liveStateForSnapshots, threadRows, type ThreadsRowModel } from "../../../src/features/threads-view/state"; +import type { Thread } from "../../../src/generated/app-server/v2/Thread"; +import type { OpenCodexPanelSnapshot } from "../../../src/runtime/open-panel-snapshot"; +import { changeInputValue, installObsidianDomShims } from "../../support/dom"; + +installObsidianDomShims(); + +function expectPresent(value: T | null | undefined): T { + if (value === null || value === undefined) throw new Error("Expected value to be present"); + return value; +} + +function openPanelSnapshot( + overrides: Partial<{ + viewId: string; + threadId: string | null; + turnLifecycle: OpenCodexPanelSnapshot["turnLifecycle"]; + pendingApprovals: number; + pendingUserInputs: number; + hasComposerDraft: boolean; + connected: boolean; + }> = {}, +): OpenCodexPanelSnapshot { + return { + viewId: "view", + threadId: "thread", + turnLifecycle: { kind: "idle" }, + pendingApprovals: 0, + pendingUserInputs: 0, + hasComposerDraft: false, + connected: true, + ...overrides, + }; +} + +function threadFixture(overrides: Partial = {}): Thread { + return { + id: "thread", + sessionId: "session", + forkedFromId: null, + preview: "", + ephemeral: false, + modelProvider: "openai", + createdAt: 1, + updatedAt: 1, + status: { type: "idle" }, + path: null, + cwd: "/vault", + cliVersion: "0.0.0", + source: "appServer", + threadSource: null, + agentNickname: null, + agentRole: null, + gitInfo: null, + name: null, + turns: [], + ...overrides, + }; +} + +function threadsViewActions() { + return { + refresh: vi.fn(), + openNewPanel: vi.fn(), + openThread: vi.fn(), + startRename: vi.fn(), + updateRename: vi.fn(), + saveRename: vi.fn(), + cancelRename: vi.fn(), + autoNameThread: vi.fn(), + startArchive: vi.fn(), + archiveThread: vi.fn(), + }; +} + +describe("threads view renderer decisions", () => { + it("prioritizes open panel live state per thread", () => { + expect( + liveStateForSnapshots([ + openPanelSnapshot({ viewId: "open", threadId: "thread" }), + openPanelSnapshot({ viewId: "running", threadId: "thread", turnLifecycle: { kind: "running", turnId: "turn" } }), + openPanelSnapshot({ viewId: "approval", threadId: "thread", pendingApprovals: 1 }), + openPanelSnapshot({ viewId: "input", threadId: "thread", pendingUserInputs: 1 }), + ]), + ).toMatchObject({ status: "needs-input", label: "Needs input", viewId: "input", openPanels: 4 }); + + expect(liveStateForSnapshots([openPanelSnapshot({ viewId: "draft", threadId: "thread", hasComposerDraft: true })])).toMatchObject({ + status: "draft", + label: "Draft", + }); + expect(liveStateForSnapshots([openPanelSnapshot({ viewId: "offline", threadId: "thread", connected: false })])).toMatchObject({ + status: "offline", + label: "Offline", + }); + expect( + liveStateForSnapshots([openPanelSnapshot({ viewId: "none", threadId: null, turnLifecycle: { kind: "running", turnId: "turn" } })]), + ).toBeNull(); + }); + + it("renders thread rows with live state and routes open actions", () => { + const parent = document.createElement("div"); + const actions = threadsViewActions(); + const rows = threadRows( + [threadFixture({ id: "closed", preview: "Closed thread" }), threadFixture({ id: "open", preview: "Open thread", updatedAt: 2 })], + [openPanelSnapshot({ viewId: "view-open", threadId: "open", pendingApprovals: 1 })], + new Map(), + ); + + renderThreadsView(parent, { status: "2 threads", loading: false, rows }, actions); + + expect(parent.querySelector(".codex-panel-threads__badge")).toBeNull(); + const row = expectPresent(parent.querySelector(".codex-panel-threads__row--approval")); + expect(row.getAttribute("title")).toBeNull(); + 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("codex-panel-threads__toolbar-button")).toBe(true); + refresh.click(); + expect(actions.refresh).toHaveBeenCalledOnce(); + const openNewPanel = expectPresent(parent.querySelector('[aria-label="Open new panel"]')); + expect(openNewPanel.classList.contains("codex-panel-threads__toolbar-button")).toBe(true); + expect(openNewPanel.classList.contains("codex-panel-threads__row-button")).toBe(false); + openNewPanel.click(); + expect(actions.openNewPanel).toHaveBeenCalledOnce(); + row.click(); + expect(actions.openThread).toHaveBeenCalledWith("open"); + row.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + expect(actions.openThread).toHaveBeenCalledTimes(2); + expect(parent.querySelector('[aria-label="Focus open panel"]')).toBeNull(); + expect(parent.querySelector('[aria-label="Open in new panel"]')).toBeNull(); + }); + + it("renders threads view archive confirmation with the default action on the right", () => { + const parent = document.createElement("div"); + const actions = threadsViewActions(); + const row: ThreadsRowModel = { + thread: threadFixture({ id: "thread", name: "Thread" }), + title: "Thread", + live: null, + rename: { active: false, draft: "Thread", generating: false }, + archiveConfirm: { active: true, defaultSaveMarkdown: false }, + }; + + renderThreadsView(parent, { status: "1 thread", loading: false, rows: [row] }, actions); + + const confirm = expectPresent(parent.querySelector(".codex-panel-threads__archive-confirm")); + const archiveButtons = [ + ...confirm.querySelectorAll(".codex-panel-threads__archive-alternate, .codex-panel-threads__archive-default"), + ]; + expect(archiveButtons.map((button) => button.getAttribute("aria-label"))).toEqual([ + "Save and archive thread", + "Archive thread without saving", + ]); + expect(parent.querySelector('[aria-label="Rename thread"]')).toBeNull(); + archiveButtons[0]?.click(); + expect(actions.archiveThread).toHaveBeenCalledWith("thread", true); + archiveButtons[1]?.click(); + expect(actions.archiveThread).toHaveBeenCalledWith("thread", false); + }); + + it("starts threads view archive confirmation before archiving", () => { + const parent = document.createElement("div"); + const actions = threadsViewActions(); + const row: ThreadsRowModel = { + thread: threadFixture({ id: "thread", name: "Thread" }), + title: "Thread", + live: null, + rename: { active: false, draft: "Thread", generating: false }, + }; + + renderThreadsView(parent, { status: "1 thread", loading: false, rows: [row] }, actions); + parent.querySelector('[aria-label="Archive thread"]')?.click(); + + expect(actions.startArchive).toHaveBeenCalledWith("thread"); + expect(actions.archiveThread).not.toHaveBeenCalled(); + }); + + it("renders rename rows and saves entered values", () => { + const parent = document.createElement("div"); + const actions = threadsViewActions(); + const row: ThreadsRowModel = { + thread: threadFixture({ id: "thread", name: "Old name" }), + title: "Old name", + live: null, + rename: { active: true, draft: "Old name", generating: false }, + }; + + renderThreadsView(parent, { status: "1 thread", loading: false, rows: [row] }, actions); + + const input = expectPresent(parent.querySelector(".codex-panel-threads__rename-input")); + changeInputValue(input, "New name"); + expect(actions.updateRename).toHaveBeenCalledWith("thread", "New name"); + + renderThreadsView( + parent, + { 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("focusout", { bubbles: true }), + ); + expect(actions.saveRename).toHaveBeenCalledWith("thread", "New name"); + + expectPresent(parent.querySelector(".codex-panel-threads__row")).click(); + expect(actions.openThread).not.toHaveBeenCalled(); + }); + + it("renders threads view rename actions inline with auto-name", () => { + const parent = document.createElement("div"); + const actions = threadsViewActions(); + const row: ThreadsRowModel = { + thread: threadFixture({ id: "thread", name: "Old name" }), + title: "Old name", + live: null, + rename: { active: true, draft: "Old name", generating: false }, + }; + + renderThreadsView(parent, { status: "1 thread", loading: false, rows: [row] }, actions); + + expect(parent.querySelector(".codex-panel-threads__rename-form")).toBeTruthy(); + const actionsGroup = expectPresent(parent.querySelector(".codex-panel-threads__rename-actions")); + expect(actionsGroup.querySelectorAll(".codex-panel-threads__row-button")).toHaveLength(1); + expect(parent.querySelector('[aria-label="Save thread name"]')).toBeNull(); + expect(parent.querySelector('[aria-label="Cancel rename"]')).toBeNull(); + parent.querySelector('[aria-label="Auto-name thread"]')?.click(); + + expect(actions.autoNameThread).toHaveBeenCalledWith("thread"); + }); + + it("renders threads view rename auto-name loading state", () => { + const parent = document.createElement("div"); + const row: ThreadsRowModel = { + thread: threadFixture({ id: "thread", name: "Old name" }), + title: "Old name", + live: null, + rename: { active: true, draft: "Old name", generating: true }, + }; + + renderThreadsView(parent, { status: "1 thread", loading: false, rows: [row] }, threadsViewActions()); + + expect(parent.querySelector(".codex-panel-threads__rename-input")?.disabled).toBe(false); + expect(parent.querySelector('[aria-label="Save thread name"]')).toBeNull(); + expect(parent.querySelector('[aria-label="Auto-name thread"]')?.disabled).toBe(true); + expect(parent.querySelector('[aria-label="Cancel rename"]')).toBeNull(); + }); +}); diff --git a/tests/features/threads-view/view.test.ts b/tests/features/threads-view/view.test.ts index 94c2115d..3955860d 100644 --- a/tests/features/threads-view/view.test.ts +++ b/tests/features/threads-view/view.test.ts @@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_SETTINGS } from "../../../src/settings/model"; import type { Turn } from "../../../src/generated/app-server/v2/Turn"; import type * as ThreadNamingModule from "../../../src/app-server/thread-naming"; -import { changeInputValue, installObsidianDomShims } from "../chat/ui/dom-test-helpers"; +import { changeInputValue, installObsidianDomShims } from "../../support/dom"; const connectionMock = vi.hoisted(() => { const state = { diff --git a/tests/main.test.ts b/tests/main.test.ts index 75fae042..5533bbbe 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -7,7 +7,7 @@ import { VIEW_TYPE_CODEX_PANEL } from "../src/constants"; import { DEFAULT_SETTINGS } from "../src/settings/model"; import type { CodexChatView } from "../src/features/chat/view"; import type { Thread } from "../src/generated/app-server/v2/Thread"; -import { installObsidianDomShims } from "./features/chat/ui/dom-test-helpers"; +import { installObsidianDomShims } from "./support/dom"; installObsidianDomShims(); diff --git a/tests/settings/settings-tab.test.ts b/tests/settings/settings-tab.test.ts index 8b5b6aa2..02a6200d 100644 --- a/tests/settings/settings-tab.test.ts +++ b/tests/settings/settings-tab.test.ts @@ -1,4 +1,5 @@ -import { createRequire } from "node:module"; +// @vitest-environment jsdom + import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Thread } from "../../src/generated/app-server/v2/Thread"; @@ -9,11 +10,9 @@ import { findModelByIdOrName, sortedAvailableModels, supportedEffortsForModel } import { CodexPanelSettingTab } from "../../src/settings/tab"; import { archivedThreadDisplayTitle } from "../../src/domain/threads/model"; import { notices } from "../mocks/obsidian"; +import { installObsidianDomShims } from "../support/dom"; -const require = createRequire(import.meta.url); -const { JSDOM } = require("jsdom") as { - JSDOM: new (html: string) => { window: Window & typeof globalThis }; -}; +installObsidianDomShims(); const { withShortLivedAppServerClientMock } = vi.hoisted(() => ({ withShortLivedAppServerClientMock: vi.fn(), @@ -25,14 +24,6 @@ vi.mock("../../src/app-server/short-lived-client", () => ({ describe("settings tab", () => { beforeEach(() => { - const dom = new JSDOM(""); - vi.stubGlobal("document", dom.window.document); - vi.stubGlobal("HTMLElement", dom.window.HTMLElement); - vi.stubGlobal("HTMLButtonElement", dom.window.HTMLButtonElement); - vi.stubGlobal("HTMLDivElement", dom.window.HTMLDivElement); - vi.stubGlobal("HTMLInputElement", dom.window.HTMLInputElement); - vi.stubGlobal("HTMLSelectElement", dom.window.HTMLSelectElement); - vi.stubGlobal("Event", dom.window.Event); withShortLivedAppServerClientMock.mockReset(); notices.length = 0; }); diff --git a/tests/shared/diff/unified.test.ts b/tests/shared/diff/unified.test.ts new file mode 100644 index 00000000..817a3bde --- /dev/null +++ b/tests/shared/diff/unified.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { displayDiffLines } from "../../../src/shared/diff/unified"; + +describe("unified diff display lines", () => { + it("simplifies git diff file headers for turn diff display", () => { + expect( + displayDiffLines( + "diff --git a/days/2026-05-16.md b/days/2026-05-16.md\nindex 111..222\n--- a/days/2026-05-16.md\n+++ b/days/2026-05-16.md\n@@\n-old\n+new", + ), + ).toEqual([{ text: "days/2026-05-16.md", kind: "file" }, { text: "@@" }, { text: "-old" }, { text: "+new" }]); + }); + + it("keeps added-file diffs readable after simplifying headers", () => { + expect( + displayDiffLines( + "diff --git a/new-note.md b/new-note.md\nnew file mode 100644\nindex 0000000..1111111\n--- /dev/null\n+++ b/new-note.md\n@@\n+hello", + ), + ).toEqual([{ text: "new-note.md", kind: "file" }, { text: "new file mode 100644" }, { text: "@@" }, { text: "+hello" }]); + }); + + it("keeps body lines that look like file markers after the hunk starts", () => { + expect( + displayDiffLines( + "diff --git a/note.md b/note.md\nindex 111..222\n--- a/note.md\n+++ b/note.md\n@@\n+++ frontmatter\n--- removed marker", + ), + ).toEqual([{ text: "note.md", kind: "file" }, { text: "@@" }, { text: "+++ frontmatter" }, { text: "--- removed marker" }]); + }); +}); diff --git a/tests/features/chat/ui/dom-test-helpers.ts b/tests/support/dom.ts similarity index 100% rename from tests/features/chat/ui/dom-test-helpers.ts rename to tests/support/dom.ts