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