mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Organize test structure
This commit is contained in:
parent
9e67471279
commit
47c5ce8e0d
23 changed files with 3980 additions and 3926 deletions
|
|
@ -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();
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
1242
tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx
Normal file
1242
tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx
Normal file
File diff suppressed because it is too large
Load diff
530
tests/features/chat/ui/message-stream/pending-requests.test.tsx
Normal file
530
tests/features/chat/ui/message-stream/pending-requests.test.tsx
Normal file
|
|
@ -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<string, string>();
|
||||
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<HTMLButtonElement>(".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<HTMLInputElement>(".codex-panel__user-input-radio")?.checked).toBe(true);
|
||||
expect(parent.querySelector<HTMLInputElement>(".codex-panel__user-input-radio")?.type).toBe("radio");
|
||||
expect(parent.querySelector(".codex-panel__user-input-marker")).toBeNull();
|
||||
parent.querySelector<HTMLButtonElement>(".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<string, string>();
|
||||
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<HTMLInputElement>(".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<HTMLInputElement>(".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<string, string>();
|
||||
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<HTMLInputElement>(".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<HTMLInputElement>(".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<string, string>();
|
||||
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<HTMLInputElement>(".codex-panel__user-input-other-text")?.tabIndex).toBe(-1);
|
||||
|
||||
expectPresent(parent.querySelectorAll<HTMLInputElement>(".codex-panel__user-input-radio").item(1)).click();
|
||||
render();
|
||||
|
||||
expect(parent.querySelector<HTMLInputElement>(".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<string, string>();
|
||||
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<HTMLInputElement>(".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<HTMLButtonElement>(".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<HTMLButtonElement>(".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<string>(),
|
||||
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: () => <button type="button">Resolve</button>,
|
||||
}),
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
5
tests/features/chat/ui/message-stream/setup.ts
Normal file
5
tests/features/chat/ui/message-stream/setup.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { installObsidianDomShims } from "../../../../support/dom";
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
installObsidianDomShims();
|
||||
167
tests/features/chat/ui/message-stream/test-helpers.tsx
Normal file
167
tests/features/chat/ui/message-stream/test-helpers.tsx
Normal file
|
|
@ -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<typeof rawMessageStreamBlocks>
|
||||
): [ReturnType<typeof rawMessageStreamBlocks>[number], ...ReturnType<typeof rawMessageStreamBlocks>] {
|
||||
const blocks = rawMessageStreamBlocks(...args);
|
||||
if (blocks.length === 0) throw new Error("Expected at least one message stream block.");
|
||||
return blocks as [ReturnType<typeof rawMessageStreamBlocks>[number], ...ReturnType<typeof rawMessageStreamBlocks>];
|
||||
}
|
||||
|
||||
export function expectPresent<T>(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<typeof rawMessageStreamBlocks>[number] {
|
||||
return { key, node };
|
||||
}
|
||||
|
||||
export function renderMessageBlockElement(block: ReturnType<typeof rawMessageStreamBlocks>[number]): HTMLElement {
|
||||
const parent = document.createElement("div");
|
||||
act(() => {
|
||||
renderMessageStreamBlocks(parent, [block]);
|
||||
});
|
||||
const host = expectPresent(parent.querySelector<HTMLElement>(`[data-codex-panel-block-key="${block.key}"]`));
|
||||
return expectPresent(host.firstElementChild as HTMLElement | null);
|
||||
}
|
||||
|
||||
export function renderPendingRequestNode(parent: HTMLElement, ...args: Parameters<typeof pendingRequestMessageNode>): void {
|
||||
renderReactRoot(parent, pendingRequestMessageNode(...args));
|
||||
}
|
||||
|
||||
export function pendingRequestActions(overrides: Partial<PendingRequestMessageActions> = {}): 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<T>(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 },
|
||||
},
|
||||
};
|
||||
}
|
||||
994
tests/features/chat/ui/message-stream/work-log.test.tsx
Normal file
994
tests/features/chat/ui/message-stream/work-log.test.tsx
Normal file
|
|
@ -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<string>(),
|
||||
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<HTMLElement>('[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<HTMLDetailsElement>(".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<HTMLElement>('[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<HTMLElement>(".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<HTMLElement>('[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<HTMLDetailsElement>("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<HTMLElement>(".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<HTMLElement>('[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<HTMLElement>('[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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
194
tests/features/chat/ui/renderers/composer.test.ts
Normal file
194
tests/features/chat/ui/renderers/composer.test.ts
Normal file
|
|
@ -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<HTMLElement>(".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<HTMLButtonElement>(".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<HTMLButtonElement>(".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");
|
||||
});
|
||||
});
|
||||
382
tests/features/chat/ui/renderers/toolbar.test.ts
Normal file
382
tests/features/chat/ui/renderers/toolbar.test.ts
Normal file
|
|
@ -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<T>(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<HTMLButtonElement>(".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<HTMLButtonElement>(".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<HTMLElement>(".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<HTMLElement>(".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<HTMLButtonElement>('[aria-label="Rename thread"]')?.click();
|
||||
expect(startRenameThread).toHaveBeenCalledWith("thread");
|
||||
|
||||
const input = parent.querySelector<HTMLInputElement>(".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<HTMLInputElement>(".codex-panel__thread-rename-input")).dispatchEvent(
|
||||
new FocusEvent("focusout", { bubbles: true }),
|
||||
);
|
||||
expect(saveRenameThread).toHaveBeenCalledWith("editing", "New title");
|
||||
expectPresent(parent.querySelector<HTMLInputElement>(".codex-panel__thread-rename-input")).dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
|
||||
);
|
||||
expect(cancelRenameThread).toHaveBeenCalledWith("editing");
|
||||
expect(parent.querySelector<HTMLButtonElement>('[aria-label="Save thread name"]')).toBeNull();
|
||||
expect(parent.querySelector<HTMLButtonElement>('[aria-label="Cancel rename"]')).toBeNull();
|
||||
parent.querySelector<HTMLButtonElement>('[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<HTMLInputElement>(".codex-panel__thread-rename-input")?.disabled).toBe(false);
|
||||
expect(parent.querySelector<HTMLButtonElement>('[aria-label="Save thread name"]')).toBeNull();
|
||||
expect(parent.querySelector<HTMLButtonElement>('[aria-label="Auto-name thread"]')?.disabled).toBe(true);
|
||||
expect(parent.querySelector<HTMLButtonElement>('[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<HTMLElement>(".codex-panel__archive-confirm"));
|
||||
const archiveButtons = [
|
||||
...confirm.querySelectorAll<HTMLButtonElement>(".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<HTMLButtonElement>('[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> = {}): 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<Parameters<typeof renderToolbar>[2]> = {}): Parameters<typeof renderToolbar>[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,
|
||||
};
|
||||
}
|
||||
174
tests/features/chat/ui/renderers/turn-diff.test.ts
Normal file
174
tests/features/chat/ui/renderers/turn-diff.test.ts
Normal file
|
|
@ -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<HTMLButtonElement>(".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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
251
tests/features/threads-view/renderer.test.ts
Normal file
251
tests/features/threads-view/renderer.test.ts
Normal file
|
|
@ -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<T>(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> = {}): 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<HTMLElement>(".codex-panel-threads__row--approval"));
|
||||
expect(row.getAttribute("title")).toBeNull();
|
||||
const toolbarButtons = [...parent.querySelectorAll<HTMLButtonElement>(".codex-panel-threads__toolbar-button")];
|
||||
expect(toolbarButtons.map((button) => button.getAttribute("aria-label"))).toEqual(["Open new panel", "Refresh threads"]);
|
||||
const refresh = expectPresent(parent.querySelector<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[aria-label="Focus open panel"]')).toBeNull();
|
||||
expect(parent.querySelector<HTMLButtonElement>('[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<HTMLElement>(".codex-panel-threads__archive-confirm"));
|
||||
const archiveButtons = [
|
||||
...confirm.querySelectorAll<HTMLButtonElement>(".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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLInputElement>(".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<HTMLInputElement>(".codex-panel-threads__rename-input")).dispatchEvent(
|
||||
new FocusEvent("focusout", { bubbles: true }),
|
||||
);
|
||||
expect(actions.saveRename).toHaveBeenCalledWith("thread", "New name");
|
||||
|
||||
expectPresent(parent.querySelector<HTMLElement>(".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<HTMLElement>(".codex-panel-threads__rename-form")).toBeTruthy();
|
||||
const actionsGroup = expectPresent(parent.querySelector<HTMLElement>(".codex-panel-threads__rename-actions"));
|
||||
expect(actionsGroup.querySelectorAll(".codex-panel-threads__row-button")).toHaveLength(1);
|
||||
expect(parent.querySelector<HTMLButtonElement>('[aria-label="Save thread name"]')).toBeNull();
|
||||
expect(parent.querySelector<HTMLButtonElement>('[aria-label="Cancel rename"]')).toBeNull();
|
||||
parent.querySelector<HTMLButtonElement>('[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<HTMLInputElement>(".codex-panel-threads__rename-input")?.disabled).toBe(false);
|
||||
expect(parent.querySelector<HTMLButtonElement>('[aria-label="Save thread name"]')).toBeNull();
|
||||
expect(parent.querySelector<HTMLButtonElement>('[aria-label="Auto-name thread"]')?.disabled).toBe(true);
|
||||
expect(parent.querySelector<HTMLButtonElement>('[aria-label="Cancel rename"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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("<!doctype html><html><body></body></html>");
|
||||
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;
|
||||
});
|
||||
|
|
|
|||
29
tests/shared/diff/unified.test.ts
Normal file
29
tests/shared/diff/unified.test.ts
Normal file
|
|
@ -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" }]);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue