mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 17:30:31 +00:00
614 lines
24 KiB
TypeScript
614 lines
24 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { h } from "preact";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import type { ComposerMetaViewModel } from "../../../../src/features/chat/ui/composer";
|
|
import { type ComposerCallbacks, ComposerShell, type ComposerSuggestion } from "../../../../src/features/chat/ui/composer";
|
|
import { scrollComposerSuggestionIntoView, syncComposerHeight } from "../../../../src/features/chat/ui/composer.dom";
|
|
import { renderUiRoot } from "../../../../src/shared/dom/preact-root.dom";
|
|
import { waitForAsyncWork } from "../../../support/async";
|
|
import { changeInputValue, composerSuggestionScrollFixture, installObsidianDomShims } from "../../../support/dom";
|
|
|
|
installObsidianDomShims();
|
|
|
|
function mountComposerShell(
|
|
parent: HTMLElement,
|
|
viewId: string,
|
|
draft: string,
|
|
busy: boolean,
|
|
canInterrupt: boolean,
|
|
normalPlaceholder: string,
|
|
suggestions: readonly ComposerSuggestion[],
|
|
selectedSuggestionIndex: number,
|
|
callbacks: ComposerCallbacks,
|
|
meta?: ComposerMetaViewModel,
|
|
webSubmissionPending = false,
|
|
webSubmissionCancellable = webSubmissionPending,
|
|
sendDisabled = false,
|
|
): { composer: HTMLTextAreaElement } {
|
|
const elements: { composer: HTMLTextAreaElement | null } = { composer: null };
|
|
renderUiRoot(
|
|
parent,
|
|
h(ComposerShell, {
|
|
viewId,
|
|
draft,
|
|
busy,
|
|
canInterrupt,
|
|
submissionDisabled: webSubmissionPending,
|
|
sendDisabled,
|
|
webSubmissionCancellable,
|
|
normalPlaceholder,
|
|
suggestions,
|
|
selectedSuggestionIndex,
|
|
callbacks,
|
|
meta:
|
|
meta ??
|
|
({
|
|
fatal: null,
|
|
context: {
|
|
cells: [
|
|
{ text: "⣀", placeholder: true },
|
|
{ text: "⣀", placeholder: true },
|
|
{ text: "⣀", placeholder: true },
|
|
{ text: "⣀", placeholder: true },
|
|
],
|
|
percent: "--%",
|
|
},
|
|
statusSummary: "Context unavailable, plan off, auto-review off, fast off, model default, reasoning effort default",
|
|
model: "default",
|
|
effort: null,
|
|
planActive: false,
|
|
autoReviewActive: false,
|
|
fastActive: false,
|
|
modelChoices: [],
|
|
effortChoices: [],
|
|
} satisfies ComposerMetaViewModel),
|
|
onComposer: (composer) => {
|
|
if (composer) elements.composer = composer;
|
|
},
|
|
}),
|
|
);
|
|
if (!elements.composer) throw new Error("Expected composer shell elements to mount.");
|
|
return { composer: elements.composer };
|
|
}
|
|
|
|
function composerCallbacks() {
|
|
return {
|
|
onInput: vi.fn(),
|
|
onUpdateSuggestions: vi.fn(),
|
|
onKeydown: vi.fn(),
|
|
onSendOrInterrupt: vi.fn(),
|
|
onHeightChange: vi.fn(),
|
|
onTogglePlan: vi.fn(),
|
|
onToggleAutoReview: vi.fn(),
|
|
onToggleFast: vi.fn(),
|
|
onSuggestionHover: vi.fn(),
|
|
onSuggestionInsert: vi.fn(),
|
|
};
|
|
}
|
|
|
|
describe("ComposerShell decisions", () => {
|
|
it("renders composer meta as interactive context and runtime text without changing normal text", () => {
|
|
const parent = document.createElement("div");
|
|
|
|
mountComposerShell(parent, "view", "", false, false, "Ask Codex...", [], 0, composerCallbacks(), {
|
|
fatal: null,
|
|
context: {
|
|
cells: [
|
|
{ text: "⣿", placeholder: false },
|
|
{ text: "⣶", placeholder: false },
|
|
{ text: "⣀", placeholder: true },
|
|
{ text: "⣀", placeholder: true },
|
|
],
|
|
percent: "42%",
|
|
},
|
|
statusSummary: "Context 42%, plan on, auto-review off, fast on, model gpt-5.5, reasoning effort high",
|
|
model: "gpt-5.5",
|
|
effort: "high",
|
|
planActive: true,
|
|
autoReviewActive: false,
|
|
fastActive: true,
|
|
modelChoices: [
|
|
{ label: "gpt-5.5", selected: true, onClick: vi.fn() },
|
|
{ label: "gpt-5.4", onClick: vi.fn() },
|
|
],
|
|
effortChoices: [
|
|
{ label: "medium", onClick: vi.fn() },
|
|
{ label: "high", selected: true, onClick: vi.fn() },
|
|
],
|
|
});
|
|
|
|
const contextDots = Array.from(parent.querySelectorAll<HTMLElement>(".codex-panel__composer-meta-context-dot"));
|
|
const modeIcons = Array.from(parent.querySelectorAll<HTMLElement>(".codex-panel__composer-meta-icon"));
|
|
const statusSummary = parent.querySelector<HTMLElement>(".codex-panel__composer-meta-summary");
|
|
const statusVisual = parent.querySelector<HTMLElement>(".codex-panel__composer-meta-status-visual");
|
|
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-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]);
|
|
expect(parent.querySelector(".codex-panel__composer-meta-model")?.textContent).toBe("gpt-5.5");
|
|
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.getAttribute("aria-hidden"))).toEqual(["true", "true", "true"]);
|
|
expect(parent.querySelector(".codex-panel__composer-action.codex-panel__send")).not.toBeNull();
|
|
expect(parent.querySelector(".codex-panel__new-chat")).toBeNull();
|
|
});
|
|
|
|
it("toggles composer runtime controls and opens separate lightweight pickers", async () => {
|
|
const parent = document.createElement("div");
|
|
const callbacks = composerCallbacks();
|
|
const selectModel = vi.fn();
|
|
const selectEffort = vi.fn();
|
|
|
|
mountComposerShell(parent, "view", "", false, false, "Ask Codex...", [], 0, callbacks, {
|
|
fatal: null,
|
|
context: {
|
|
cells: [
|
|
{ text: "⣿", placeholder: false },
|
|
{ text: "⣶", placeholder: false },
|
|
{ text: "⣀", placeholder: true },
|
|
{ text: "⣀", placeholder: true },
|
|
],
|
|
percent: "42%",
|
|
},
|
|
statusSummary: "Context 42%, plan on, auto-review off, fast on, model gpt-5.5, reasoning effort high",
|
|
model: "gpt-5.5",
|
|
effort: "high",
|
|
planActive: true,
|
|
autoReviewActive: false,
|
|
fastActive: true,
|
|
modelChoices: [
|
|
{ label: "gpt-5.5", selected: true, onClick: vi.fn() },
|
|
{ label: "gpt-5.4", onClick: selectModel },
|
|
],
|
|
effortChoices: [
|
|
{ label: "medium", onClick: selectEffort },
|
|
{ label: "high", selected: true, onClick: vi.fn() },
|
|
],
|
|
});
|
|
|
|
const [planButton, reviewButton, fastButton] = Array.from(parent.querySelectorAll<HTMLElement>(".codex-panel__composer-meta-icon"));
|
|
planButton?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
|
reviewButton?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
|
fastButton?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
|
expect(callbacks.onTogglePlan).toHaveBeenCalledOnce();
|
|
expect(callbacks.onToggleAutoReview).toHaveBeenCalledOnce();
|
|
expect(callbacks.onToggleFast).toHaveBeenCalledOnce();
|
|
|
|
parent.querySelector<HTMLElement>(".codex-panel__composer-meta-model")?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
|
await waitForAsyncWork(() => {
|
|
expect(parent.querySelector('[data-codex-panel-composer-meta-kind="model"]')?.textContent).toBe("gpt-5.5gpt-5.4");
|
|
});
|
|
const metaOptions = Array.from(parent.querySelectorAll<HTMLElement>(".codex-panel__composer-meta-option"));
|
|
expect(metaOptions.map((option) => option.textContent)).toEqual(["gpt-5.5", "gpt-5.4"]);
|
|
|
|
parent.querySelector<HTMLElement>(".codex-panel__composer-meta-effort")?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
|
await waitForAsyncWork(() => {
|
|
expect(parent.querySelector('[data-codex-panel-composer-meta-kind="model"]')).toBeNull();
|
|
expect(parent.querySelector('[data-codex-panel-composer-meta-kind="effort"]')?.textContent).toBe("mediumhigh");
|
|
});
|
|
|
|
parent.querySelector<HTMLElement>(".codex-panel__composer-meta-option")?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
|
expect(selectEffort).toHaveBeenCalledOnce();
|
|
await waitForAsyncWork(() => {
|
|
expect(parent.querySelector(".codex-panel__composer-meta-popover")).toBeNull();
|
|
});
|
|
|
|
parent.querySelector<HTMLElement>(".codex-panel__composer-meta-model")?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
|
await waitForAsyncWork(() => {
|
|
expect(parent.querySelector('[data-codex-panel-composer-meta-kind="model"]')).not.toBeNull();
|
|
});
|
|
parent
|
|
.querySelectorAll<HTMLElement>(".codex-panel__composer-meta-option")[1]
|
|
?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
|
expect(selectModel).toHaveBeenCalledOnce();
|
|
await waitForAsyncWork(() => {
|
|
expect(parent.querySelector(".codex-panel__composer-meta-popover")).toBeNull();
|
|
});
|
|
|
|
parent.querySelector<HTMLElement>(".codex-panel__composer-meta-model")?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
|
await waitForAsyncWork(() => {
|
|
expect(parent.querySelector('[data-codex-panel-composer-meta-kind="model"]')).not.toBeNull();
|
|
});
|
|
document.dispatchEvent(new Event("mousedown", { bubbles: true }));
|
|
await waitForAsyncWork(() => {
|
|
expect(parent.querySelector(".codex-panel__composer-meta-popover")).toBeNull();
|
|
});
|
|
});
|
|
|
|
it("hides composer meta fields only after measured overflow", async () => {
|
|
const parent = document.createElement("div");
|
|
|
|
mountComposerShell(parent, "view", "", false, false, "Ask Codex...", [], 0, composerCallbacks(), {
|
|
fatal: null,
|
|
context: {
|
|
cells: [
|
|
{ text: "⣿", placeholder: false },
|
|
{ text: "⣶", placeholder: false },
|
|
{ text: "⣀", placeholder: true },
|
|
{ text: "⣀", placeholder: true },
|
|
],
|
|
percent: "42%",
|
|
},
|
|
statusSummary: "Context 42%, plan on, auto-review off, fast on, model gpt-5.5, reasoning effort high",
|
|
model: "gpt-5.5",
|
|
effort: "high",
|
|
planActive: true,
|
|
autoReviewActive: false,
|
|
fastActive: true,
|
|
});
|
|
|
|
const status = parent.querySelector<HTMLElement>(".codex-panel__composer-meta-status");
|
|
if (!status) throw new Error("Expected composer meta status to render.");
|
|
Object.defineProperty(status, "clientWidth", { configurable: true, value: 100 });
|
|
Object.defineProperty(status, "scrollWidth", {
|
|
configurable: true,
|
|
get: () => (status.classList.contains("is-model-hidden") ? 80 : status.classList.contains("is-effort-hidden") ? 120 : 140),
|
|
});
|
|
|
|
window.dispatchEvent(new Event("resize"));
|
|
await new Promise<void>((resolve) => {
|
|
window.requestAnimationFrame(() => {
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
await waitForAsyncWork(() => {
|
|
expect(status.classList.contains("is-effort-hidden")).toBe(true);
|
|
expect(status.classList.contains("is-model-hidden")).toBe(true);
|
|
});
|
|
});
|
|
|
|
it("replaces composer meta with fatal status text", () => {
|
|
const parent = document.createElement("div");
|
|
|
|
mountComposerShell(parent, "view", "", false, false, "Ask Codex...", [], 0, composerCallbacks(), {
|
|
fatal: "Codex app-server disconnected",
|
|
context: {
|
|
cells: [
|
|
{ text: "⣀", placeholder: true },
|
|
{ text: "⣀", placeholder: true },
|
|
{ text: "⣀", placeholder: true },
|
|
{ text: "⣀", placeholder: true },
|
|
],
|
|
percent: "--%",
|
|
},
|
|
statusSummary: "Codex app-server disconnected",
|
|
model: "",
|
|
effort: null,
|
|
planActive: false,
|
|
autoReviewActive: false,
|
|
fastActive: false,
|
|
});
|
|
|
|
expect(parent.querySelector(".codex-panel__composer-meta-fatal")?.textContent).toBe("Codex app-server disconnected");
|
|
expect(parent.querySelector(".codex-panel__composer-meta-context")).toBeNull();
|
|
expect(parent.querySelector(".codex-panel__composer-action.codex-panel__send")).not.toBeNull();
|
|
});
|
|
|
|
it("renders composer suggestions inside the composer root", () => {
|
|
const parent = document.createElement("div");
|
|
const onSuggestionInsert = vi.fn();
|
|
const { composer } = mountComposerShell(
|
|
parent,
|
|
"view",
|
|
"",
|
|
false,
|
|
false,
|
|
"Ask Codex...",
|
|
[{ display: "/help", detail: "Show help", replacement: "/help", start: 0 }],
|
|
0,
|
|
{
|
|
onInput: vi.fn(),
|
|
onUpdateSuggestions: vi.fn(),
|
|
onKeydown: vi.fn(),
|
|
onSendOrInterrupt: vi.fn(),
|
|
onHeightChange: vi.fn(),
|
|
onSuggestionHover: vi.fn(),
|
|
onSuggestionInsert,
|
|
},
|
|
);
|
|
|
|
const suggestions = parent.querySelector<HTMLElement>(".codex-panel__composer-suggestions");
|
|
if (!suggestions) throw new Error("Expected composer suggestions to render.");
|
|
expect(suggestions.getAttribute("role")).toBe("listbox");
|
|
expect(composer.getAttribute("aria-expanded")).toBe("true");
|
|
expect(composer.getAttribute("aria-activedescendant")).toBe("view-composer-suggestion-0");
|
|
suggestions
|
|
.querySelector<HTMLElement>(".codex-panel__composer-suggestion")
|
|
?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
|
expect(onSuggestionInsert).toHaveBeenCalled();
|
|
});
|
|
|
|
it("clears composer suggestion accessibility state on rerender", () => {
|
|
const parent = document.createElement("div");
|
|
const callbacks = composerCallbacks();
|
|
const { composer } = mountComposerShell(
|
|
parent,
|
|
"view",
|
|
"",
|
|
false,
|
|
false,
|
|
"Ask Codex...",
|
|
[{ display: "/help", detail: "Show help", replacement: "/help", start: 0 }],
|
|
0,
|
|
callbacks,
|
|
);
|
|
|
|
mountComposerShell(parent, "view", "", false, false, "Ask Codex...", [], 0, callbacks);
|
|
|
|
const suggestions = parent.querySelector<HTMLElement>(".codex-panel__composer-suggestions");
|
|
expect(composer.getAttribute("aria-expanded")).toBe("false");
|
|
expect(composer.hasAttribute("aria-activedescendant")).toBe(false);
|
|
expect(suggestions?.hidden).toBe(true);
|
|
});
|
|
|
|
it("reports composer draft changes from the controlled input", () => {
|
|
const parent = document.createElement("div");
|
|
const callbacks = composerCallbacks();
|
|
const { composer } = mountComposerShell(parent, "view", "", false, false, "Ask Codex...", [], 0, callbacks);
|
|
|
|
changeInputValue(composer, "Draft text");
|
|
|
|
expect(callbacks.onInput).toHaveBeenCalledWith("Draft text");
|
|
});
|
|
|
|
it("syncs composer autogrow when the controlled input changes height", () => {
|
|
const parent = document.createElement("div");
|
|
const callbacks = composerCallbacks();
|
|
let scrollHeight = 56;
|
|
const descriptor = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "scrollHeight");
|
|
Object.defineProperty(HTMLTextAreaElement.prototype, "scrollHeight", {
|
|
get: () => scrollHeight,
|
|
configurable: true,
|
|
});
|
|
try {
|
|
const { composer } = mountComposerShell(parent, "view", "", false, false, "Ask Codex...", [], 0, callbacks);
|
|
|
|
scrollHeight = 120;
|
|
changeInputValue(composer, "line one\nline two");
|
|
|
|
expect(composer.style.height).toBe("120px");
|
|
expect(composer.style.overflowY).toBe("hidden");
|
|
} finally {
|
|
if (descriptor) {
|
|
Object.defineProperty(HTMLTextAreaElement.prototype, "scrollHeight", descriptor);
|
|
} else {
|
|
Reflect.deleteProperty(HTMLTextAreaElement.prototype, "scrollHeight");
|
|
}
|
|
}
|
|
});
|
|
|
|
it("shrinks composer autogrow when the controlled draft clears after send", () => {
|
|
const parent = document.createElement("div");
|
|
const callbacks = composerCallbacks();
|
|
const onComposer = vi.fn();
|
|
let scrollHeight = 120;
|
|
const descriptor = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "scrollHeight");
|
|
Object.defineProperty(HTMLTextAreaElement.prototype, "scrollHeight", {
|
|
get: () => scrollHeight,
|
|
configurable: true,
|
|
});
|
|
|
|
try {
|
|
renderUiRoot(
|
|
parent,
|
|
h(ComposerShell, {
|
|
viewId: "view",
|
|
draft: "line one\nline two",
|
|
busy: false,
|
|
canInterrupt: false,
|
|
submissionDisabled: false,
|
|
sendDisabled: false,
|
|
webSubmissionCancellable: false,
|
|
normalPlaceholder: "Ask Codex...",
|
|
suggestions: [],
|
|
selectedSuggestionIndex: 0,
|
|
callbacks,
|
|
meta: {
|
|
fatal: null,
|
|
context: {
|
|
cells: [
|
|
{ text: "⣀", placeholder: true },
|
|
{ text: "⣀", placeholder: true },
|
|
{ text: "⣀", placeholder: true },
|
|
{ text: "⣀", placeholder: true },
|
|
],
|
|
percent: "--%",
|
|
},
|
|
statusSummary: "Context unavailable, plan off, auto-review off, fast off, model default, reasoning effort default",
|
|
model: "default",
|
|
effort: null,
|
|
planActive: false,
|
|
autoReviewActive: false,
|
|
fastActive: false,
|
|
},
|
|
onComposer,
|
|
}),
|
|
);
|
|
const composer = parent.querySelector<HTMLTextAreaElement>(".codex-panel__composer-input");
|
|
if (!composer) throw new Error("Expected composer shell elements to mount.");
|
|
expect(composer.style.height).toBe("120px");
|
|
|
|
scrollHeight = 56;
|
|
renderUiRoot(
|
|
parent,
|
|
h(ComposerShell, {
|
|
viewId: "view",
|
|
draft: "",
|
|
busy: false,
|
|
canInterrupt: false,
|
|
submissionDisabled: false,
|
|
sendDisabled: false,
|
|
webSubmissionCancellable: false,
|
|
normalPlaceholder: "Ask Codex...",
|
|
suggestions: [],
|
|
selectedSuggestionIndex: 0,
|
|
callbacks,
|
|
meta: {
|
|
fatal: null,
|
|
context: {
|
|
cells: [
|
|
{ text: "⣀", placeholder: true },
|
|
{ text: "⣀", placeholder: true },
|
|
{ text: "⣀", placeholder: true },
|
|
{ text: "⣀", placeholder: true },
|
|
],
|
|
percent: "--%",
|
|
},
|
|
statusSummary: "Context unavailable, plan off, auto-review off, fast off, model default, reasoning effort default",
|
|
model: "default",
|
|
effort: null,
|
|
planActive: false,
|
|
autoReviewActive: false,
|
|
fastActive: false,
|
|
},
|
|
onComposer,
|
|
}),
|
|
);
|
|
|
|
expect(composer.style.height).toBe("56px");
|
|
expect(callbacks.onHeightChange).toHaveBeenCalled();
|
|
} finally {
|
|
if (descriptor) {
|
|
Object.defineProperty(HTMLTextAreaElement.prototype, "scrollHeight", descriptor);
|
|
} else {
|
|
Reflect.deleteProperty(HTMLTextAreaElement.prototype, "scrollHeight");
|
|
}
|
|
}
|
|
});
|
|
|
|
it("scrolls the selected composer suggestion fully into view below the viewport", () => {
|
|
const { container, option } = composerSuggestionScrollFixture({
|
|
clientHeight: 100,
|
|
optionHeight: 32,
|
|
optionTop: 92,
|
|
scrollTop: 0,
|
|
});
|
|
|
|
scrollComposerSuggestionIntoView(container, option);
|
|
|
|
expect(container.scrollTop).toBe(24);
|
|
});
|
|
|
|
it("scrolls the selected composer suggestion fully into view above the viewport", () => {
|
|
const { container, option } = composerSuggestionScrollFixture({
|
|
clientHeight: 100,
|
|
optionHeight: 32,
|
|
optionTop: 48,
|
|
scrollTop: 64,
|
|
});
|
|
|
|
scrollComposerSuggestionIntoView(container, option);
|
|
|
|
expect(container.scrollTop).toBe(48);
|
|
});
|
|
|
|
it("keeps composer suggestion scroll position when the selected item is already visible", () => {
|
|
const { container, option } = composerSuggestionScrollFixture({
|
|
clientHeight: 100,
|
|
optionHeight: 32,
|
|
optionTop: 72,
|
|
scrollTop: 48,
|
|
});
|
|
|
|
scrollComposerSuggestionIntoView(container, option);
|
|
|
|
expect(container.scrollTop).toBe(48);
|
|
});
|
|
|
|
it("uses the composer action for interrupt only when a running turn has no steering text", () => {
|
|
const parent = document.createElement("div");
|
|
const callbacks = composerCallbacks();
|
|
const { composer } = mountComposerShell(parent, "view", "", true, true, "Ask Codex...", [], 0, callbacks);
|
|
let sendButton = parent.querySelector<HTMLButtonElement>(".codex-panel__send");
|
|
|
|
expect(sendButton?.getAttribute("aria-label")).toBe("Interrupt");
|
|
expect(composer.getAttribute("placeholder")).toBe("Steer the current turn...");
|
|
expect(composer.getAttribute("aria-label")).toBeNull();
|
|
expect(sendButton?.classList.contains("is-interrupt")).toBe(true);
|
|
expect(sendButton?.classList.contains("is-steer")).toBe(false);
|
|
expect(sendButton?.dataset["icon"]).toBe("square");
|
|
|
|
mountComposerShell(parent, "view", "adjust course", true, true, "Ask Codex...", [], 0, callbacks);
|
|
sendButton = parent.querySelector<HTMLButtonElement>(".codex-panel__send");
|
|
expect(sendButton?.getAttribute("aria-label")).toBe("Steer");
|
|
expect(composer.getAttribute("placeholder")).toBe("Steer the current turn...");
|
|
expect(sendButton?.classList.contains("is-interrupt")).toBe(false);
|
|
expect(sendButton?.classList.contains("is-steer")).toBe(true);
|
|
expect(sendButton?.dataset["icon"]).toBe("corner-down-right");
|
|
});
|
|
|
|
it("keeps interrupt enabled while a send-only attachment barrier is active", () => {
|
|
const parent = document.createElement("div");
|
|
const callbacks = composerCallbacks();
|
|
mountComposerShell(parent, "view", "", true, true, "Ask Codex...", [], 0, callbacks, undefined, false, false, true);
|
|
let sendButton = parent.querySelector<HTMLButtonElement>(".codex-panel__send");
|
|
|
|
expect(sendButton?.getAttribute("aria-label")).toBe("Interrupt");
|
|
expect(sendButton?.disabled).toBe(false);
|
|
|
|
mountComposerShell(parent, "view", "steer later", true, true, "Ask Codex...", [], 0, callbacks, undefined, false, false, true);
|
|
sendButton = parent.querySelector<HTMLButtonElement>(".codex-panel__send");
|
|
expect(sendButton?.getAttribute("aria-label")).toBe("Steer");
|
|
expect(sendButton?.disabled).toBe(true);
|
|
});
|
|
|
|
it("renders an enabled cancel control while a web import locks composer input", () => {
|
|
const parent = document.createElement("div");
|
|
const callbacks = composerCallbacks();
|
|
const { composer } = mountComposerShell(parent, "view", "", false, false, "Ask Codex...", [], 0, callbacks, undefined, true);
|
|
const sendButton = parent.querySelector<HTMLButtonElement>(".codex-panel__send");
|
|
|
|
expect(composer.readOnly).toBe(true);
|
|
expect(sendButton?.getAttribute("aria-label")).toBe("Cancel web import");
|
|
expect(sendButton?.disabled).toBe(false);
|
|
sendButton?.click();
|
|
expect(callbacks.onSendOrInterrupt).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("keeps composer locked without offering cancel after a web import commits", () => {
|
|
const parent = document.createElement("div");
|
|
const callbacks = composerCallbacks();
|
|
const { composer } = mountComposerShell(parent, "view", "", false, false, "Ask Codex...", [], 0, callbacks, undefined, true, false);
|
|
const sendButton = parent.querySelector<HTMLButtonElement>(".codex-panel__send");
|
|
|
|
expect(composer.readOnly).toBe(true);
|
|
expect(sendButton?.getAttribute("aria-label")).toBe("Send");
|
|
expect(sendButton?.disabled).toBe(true);
|
|
});
|
|
|
|
it("honors the smaller viewport branch of the composer max-height CSS", () => {
|
|
const composer = document.createElement("textarea");
|
|
const getComputedStyleMock = vi.spyOn(window, "getComputedStyle").mockReturnValue({
|
|
minHeight: "76px",
|
|
maxHeight: "min(208px, 40vh)",
|
|
boxSizing: "border-box",
|
|
width: "240px",
|
|
getPropertyValue: () => "",
|
|
} as unknown as CSSStyleDeclaration);
|
|
Object.defineProperty(window, "innerHeight", { value: 400, configurable: true });
|
|
const descriptor = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "scrollHeight");
|
|
Object.defineProperty(HTMLTextAreaElement.prototype, "scrollHeight", {
|
|
get: () => 280,
|
|
configurable: true,
|
|
});
|
|
|
|
try {
|
|
syncComposerHeight(composer);
|
|
} finally {
|
|
getComputedStyleMock.mockRestore();
|
|
if (descriptor) {
|
|
Object.defineProperty(HTMLTextAreaElement.prototype, "scrollHeight", descriptor);
|
|
} else {
|
|
Reflect.deleteProperty(HTMLTextAreaElement.prototype, "scrollHeight");
|
|
}
|
|
}
|
|
|
|
expect(composer.style.height).toBe("160px");
|
|
expect(composer.style.overflowY).toBe("auto");
|
|
});
|
|
});
|