mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Relax over-specified UI contracts
This commit is contained in:
parent
ba5e292487
commit
1d90eaf602
10 changed files with 119 additions and 486 deletions
|
|
@ -53,16 +53,12 @@ type AppServerMcpElicitationPrimitiveSchema =
|
|||
title?: unknown;
|
||||
description?: unknown;
|
||||
default?: unknown;
|
||||
minimum?: unknown;
|
||||
maximum?: unknown;
|
||||
}
|
||||
| {
|
||||
type: "array";
|
||||
title?: unknown;
|
||||
description?: unknown;
|
||||
default?: unknown;
|
||||
minItems?: unknown;
|
||||
maxItems?: unknown;
|
||||
items?: unknown;
|
||||
}
|
||||
| {
|
||||
|
|
@ -70,12 +66,8 @@ type AppServerMcpElicitationPrimitiveSchema =
|
|||
title?: unknown;
|
||||
description?: unknown;
|
||||
default?: unknown;
|
||||
format?: unknown;
|
||||
minLength?: unknown;
|
||||
maxLength?: unknown;
|
||||
oneOf?: unknown;
|
||||
enum?: unknown;
|
||||
enumNames?: unknown;
|
||||
};
|
||||
|
||||
interface NormalizedMcpElicitationParams {
|
||||
|
|
@ -542,6 +534,10 @@ function nullableString(value: unknown): string | null {
|
|||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
function numberOrNull(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function asRecordOrNull(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
|
|
@ -608,10 +604,6 @@ function normalizeMcpElicitationParams(params: McpElicitationParams): Normalized
|
|||
}
|
||||
}
|
||||
|
||||
function numberOrNull(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function mcpElicitationFieldsFromRequestedSchema(schema: unknown): PendingMcpElicitationField[] {
|
||||
const record = asRecordOrNull(schema);
|
||||
const properties = asRecordOrNull(record?.["properties"]);
|
||||
|
|
@ -651,17 +643,13 @@ function mcpElicitationFieldFromSchema(
|
|||
return {
|
||||
...base,
|
||||
type: schema.type,
|
||||
minimum: numberOrNull(schema.minimum),
|
||||
maximum: numberOrNull(schema.maximum),
|
||||
defaultValue: numberOrNull(schema.default),
|
||||
defaultValue: typeof schema.default === "number" && Number.isFinite(schema.default) ? schema.default : null,
|
||||
};
|
||||
case "array":
|
||||
return {
|
||||
...base,
|
||||
type: "multi-select",
|
||||
options: multiSelectOptions(schema),
|
||||
minItems: bigintToNumberOrNull(schema.minItems),
|
||||
maxItems: bigintToNumberOrNull(schema.maxItems),
|
||||
defaultValue: stringArrayOrEmpty(schema.default),
|
||||
};
|
||||
case "string": {
|
||||
|
|
@ -677,9 +665,6 @@ function mcpElicitationFieldFromSchema(
|
|||
return {
|
||||
...base,
|
||||
type: "string",
|
||||
format: nullableString(schema.format),
|
||||
minLength: numberOrNull(schema.minLength),
|
||||
maxLength: numberOrNull(schema.maxLength),
|
||||
defaultValue: typeof schema.default === "string" ? schema.default : "",
|
||||
};
|
||||
}
|
||||
|
|
@ -694,7 +679,7 @@ function singleSelectOptions(schema: Extract<AppServerMcpElicitationPrimitiveSch
|
|||
});
|
||||
if (options.length > 0) return options;
|
||||
}
|
||||
return enumOptions(schema.enum, schema.enumNames);
|
||||
return enumOptions(schema.enum);
|
||||
}
|
||||
|
||||
function multiSelectOptions(schema: Extract<AppServerMcpElicitationPrimitiveSchema, { type: "array" }>): PendingMcpElicitationOption[] {
|
||||
|
|
@ -707,7 +692,7 @@ function multiSelectOptions(schema: Extract<AppServerMcpElicitationPrimitiveSche
|
|||
});
|
||||
if (options.length > 0) return options;
|
||||
}
|
||||
return enumOptions(items?.["enum"], null);
|
||||
return enumOptions(items?.["enum"]);
|
||||
}
|
||||
|
||||
function stringArrayOrEmpty(value: unknown): readonly string[] {
|
||||
|
|
@ -720,12 +705,11 @@ function stringArrayOrEmpty(value: unknown): readonly string[] {
|
|||
return strings;
|
||||
}
|
||||
|
||||
function enumOptions(values: unknown, labels: unknown): PendingMcpElicitationOption[] {
|
||||
function enumOptions(values: unknown): PendingMcpElicitationOption[] {
|
||||
if (!Array.isArray(values)) return [];
|
||||
const labelValues = Array.isArray(labels) ? labels : [];
|
||||
return values.flatMap((value, index) => {
|
||||
return values.flatMap((value) => {
|
||||
if (typeof value !== "string") return [];
|
||||
return [{ value, label: nonEmptyString(labelValues[index]) ?? value }];
|
||||
return [{ value, label: value }];
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -736,11 +720,6 @@ function selectOption(value: unknown): PendingMcpElicitationOption | null {
|
|||
return { value: optionValue, label: nonEmptyString(record?.["title"]) ?? optionValue };
|
||||
}
|
||||
|
||||
function bigintToNumberOrNull(value: unknown): number | null {
|
||||
if (typeof value === "bigint") return Number(value);
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function toJsonContent(content: Record<string, McpElicitationContentValue> | null): unknown {
|
||||
if (!content) return null;
|
||||
return Object.fromEntries(
|
||||
|
|
|
|||
|
|
@ -89,15 +89,10 @@ interface PendingMcpElicitationFieldBase {
|
|||
export type PendingMcpElicitationField =
|
||||
| (PendingMcpElicitationFieldBase & {
|
||||
type: "string";
|
||||
format: string | null;
|
||||
minLength: number | null;
|
||||
maxLength: number | null;
|
||||
defaultValue: string;
|
||||
})
|
||||
| (PendingMcpElicitationFieldBase & {
|
||||
type: "number" | "integer";
|
||||
minimum: number | null;
|
||||
maximum: number | null;
|
||||
defaultValue: number | null;
|
||||
})
|
||||
| (PendingMcpElicitationFieldBase & {
|
||||
|
|
@ -112,8 +107,6 @@ export type PendingMcpElicitationField =
|
|||
| (PendingMcpElicitationFieldBase & {
|
||||
type: "multi-select";
|
||||
options: readonly PendingMcpElicitationOption[];
|
||||
minItems: number | null;
|
||||
maxItems: number | null;
|
||||
defaultValue: readonly string[];
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -76,13 +76,6 @@ export interface PendingMcpElicitationFieldViewModel {
|
|||
defaultDraft: string;
|
||||
draftKey: string;
|
||||
options: readonly PendingMcpElicitationOptionViewModel[] | null;
|
||||
format?: string | null;
|
||||
minimum?: number | null;
|
||||
maximum?: number | null;
|
||||
minLength?: number | null;
|
||||
maxLength?: number | null;
|
||||
minItems?: number | null;
|
||||
maxItems?: number | null;
|
||||
}
|
||||
|
||||
export interface PendingMcpElicitationViewModel {
|
||||
|
|
@ -205,22 +198,7 @@ function pendingMcpElicitationViewModel(elicitation: PendingMcpElicitation): Pen
|
|||
defaultDraft: mcpElicitationFieldDefaultDraft(field),
|
||||
draftKey: mcpElicitationDraftKey(elicitation.requestId, field.id),
|
||||
options: "options" in field ? field.options : null,
|
||||
...mcpElicitationFieldConstraints(field),
|
||||
})),
|
||||
url: null,
|
||||
};
|
||||
}
|
||||
|
||||
function mcpElicitationFieldConstraints(field: PendingMcpElicitationField): Partial<PendingMcpElicitationFieldViewModel> {
|
||||
switch (field.type) {
|
||||
case "string":
|
||||
return { format: field.format, minLength: field.minLength, maxLength: field.maxLength };
|
||||
case "number":
|
||||
case "integer":
|
||||
return { minimum: field.minimum, maximum: field.maximum };
|
||||
case "multi-select":
|
||||
return { minItems: field.minItems, maxItems: field.maxItems };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,3 @@
|
|||
export interface McpElicitationValidityMessage {
|
||||
fieldId: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function focusPendingRequestControl(container: HTMLElement | null): void {
|
||||
if (!container) return;
|
||||
for (const selector of [
|
||||
|
|
@ -22,21 +17,3 @@ export function focusPendingRequestControl(container: HTMLElement | null): void
|
|||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export function applyMcpElicitationFormValidity(form: HTMLFormElement | null, messages: readonly McpElicitationValidityMessage[]): boolean {
|
||||
if (!form) return true;
|
||||
clearMcpElicitationCustomValidity(form);
|
||||
for (const { fieldId, message } of messages) {
|
||||
const input = Array.from(form.querySelectorAll<HTMLInputElement>("input[data-mcp-elicitation-field]")).find(
|
||||
(candidate) => candidate.dataset["mcpElicitationField"] === fieldId,
|
||||
);
|
||||
input?.setCustomValidity(message);
|
||||
}
|
||||
return form.reportValidity();
|
||||
}
|
||||
|
||||
function clearMcpElicitationCustomValidity(form: HTMLFormElement): void {
|
||||
form.querySelectorAll<HTMLInputElement>(".codex-panel__mcp-elicitation-checkbox").forEach((input) => {
|
||||
input.setCustomValidity("");
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,11 +11,7 @@ import type {
|
|||
PendingUserInputViewModel,
|
||||
} from "../../presentation/pending-requests/view-model";
|
||||
import type { PendingRequestBlockActions } from "./context";
|
||||
import {
|
||||
applyMcpElicitationFormValidity,
|
||||
focusPendingRequestControl,
|
||||
type McpElicitationValidityMessage,
|
||||
} from "./pending-request-block.dom";
|
||||
import { focusPendingRequestControl } from "./pending-request-block.dom";
|
||||
import { createStatusMessageClassName } from "./status";
|
||||
|
||||
export function pendingRequestBlockNode(
|
||||
|
|
@ -109,7 +105,7 @@ function McpElicitationCard({
|
|||
}): UiNode {
|
||||
const formRef = useRef<HTMLFormElement | null>(null);
|
||||
const accept = () => {
|
||||
if (elicitation.mode === "form" && !validateMcpElicitationForm(formRef.current, elicitation, mcpElicitationDrafts)) return;
|
||||
if (elicitation.mode === "form" && formRef.current && !formRef.current.reportValidity()) return;
|
||||
actions.resolveMcpElicitation(elicitation.requestId, "accept");
|
||||
};
|
||||
return (
|
||||
|
|
@ -155,32 +151,6 @@ function McpElicitationCard({
|
|||
);
|
||||
}
|
||||
|
||||
function validateMcpElicitationForm(
|
||||
form: HTMLFormElement | null,
|
||||
elicitation: PendingMcpElicitationViewModel,
|
||||
drafts: ReadonlyMap<string, string>,
|
||||
): boolean {
|
||||
const messages: McpElicitationValidityMessage[] = [];
|
||||
for (const field of elicitation.fields) {
|
||||
if (field.type !== "multi-select") continue;
|
||||
const selectedCount = selectedMcpElicitationValues(drafts.get(field.draftKey) ?? field.defaultDraft).size;
|
||||
const message = mcpElicitationMultiSelectValidationMessage(field, selectedCount);
|
||||
if (!message) continue;
|
||||
messages.push({ fieldId: field.id, message });
|
||||
}
|
||||
return applyMcpElicitationFormValidity(form, messages);
|
||||
}
|
||||
|
||||
function mcpElicitationMultiSelectValidationMessage(field: PendingMcpElicitationFieldViewModel, selectedCount: number): string | null {
|
||||
if (typeof field.minItems === "number" && selectedCount < field.minItems) {
|
||||
return `Select at least ${String(field.minItems)} option${field.minItems === 1 ? "" : "s"}.`;
|
||||
}
|
||||
if (typeof field.maxItems === "number" && selectedCount > field.maxItems) {
|
||||
return `Select no more than ${String(field.maxItems)} option${field.maxItems === 1 ? "" : "s"}.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function ApprovalCard({
|
||||
approval,
|
||||
approvalDetails,
|
||||
|
|
@ -458,7 +428,6 @@ function McpElicitationFieldControl({
|
|||
className="codex-panel__mcp-elicitation-checkbox"
|
||||
type="checkbox"
|
||||
value={option.value}
|
||||
data-mcp-elicitation-field={field.id}
|
||||
checked={selected.has(option.value)}
|
||||
onChange={(event) => {
|
||||
const next = new Set(selected);
|
||||
|
|
@ -484,8 +453,6 @@ function McpElicitationFieldControl({
|
|||
className="codex-panel__mcp-elicitation-input"
|
||||
type="number"
|
||||
step={field.type === "integer" ? "1" : "any"}
|
||||
min={field.minimum ?? undefined}
|
||||
max={field.maximum ?? undefined}
|
||||
required={field.required}
|
||||
value={current}
|
||||
onInput={(event) => {
|
||||
|
|
@ -498,9 +465,7 @@ function McpElicitationFieldControl({
|
|||
<input
|
||||
id={controlId}
|
||||
className="codex-panel__mcp-elicitation-input"
|
||||
type={field.format === "email" ? "email" : field.format === "uri" ? "url" : field.format === "date" ? "date" : "text"}
|
||||
minLength={field.minLength ?? undefined}
|
||||
maxLength={field.maxLength ?? undefined}
|
||||
type="text"
|
||||
required={field.required}
|
||||
value={current}
|
||||
onInput={(event) => {
|
||||
|
|
|
|||
|
|
@ -42,8 +42,6 @@ describe("MCP elicitation request model", () => {
|
|||
expect.objectContaining({
|
||||
id: "labels",
|
||||
type: "multi-select",
|
||||
minItems: 1,
|
||||
maxItems: 2,
|
||||
options: [
|
||||
{ value: "bug", label: "Bug" },
|
||||
{ value: "docs", label: "Docs" },
|
||||
|
|
@ -116,14 +114,13 @@ describe("MCP elicitation request model", () => {
|
|||
});
|
||||
expect(input.params.fields.find((field) => field.id === "brokenSelect")).toMatchObject({
|
||||
type: "string",
|
||||
format: null,
|
||||
defaultValue: "",
|
||||
});
|
||||
expect(input.params.fields.find((field) => field.id === "enumSelect")).toMatchObject({
|
||||
type: "single-select",
|
||||
options: [
|
||||
{ value: "low", label: "Low" },
|
||||
{ value: "high", label: "High" },
|
||||
{ value: "low", label: "low" },
|
||||
{ value: "high", label: "high" },
|
||||
],
|
||||
});
|
||||
expect(input.params.fields.find((field) => field.id === "labels")).toMatchObject({
|
||||
|
|
@ -165,8 +162,6 @@ function formRequest(): ServerRequest {
|
|||
labels: {
|
||||
type: "array",
|
||||
title: "Labels",
|
||||
minItems: 1,
|
||||
maxItems: 2,
|
||||
items: {
|
||||
anyOf: [
|
||||
{ const: "bug", title: "Bug" },
|
||||
|
|
@ -238,8 +233,8 @@ function malformedSchemaRequest(): ServerRequest {
|
|||
properties: {
|
||||
unsupported: { type: "object", title: "Unsupported" },
|
||||
badDefault: { type: "boolean", title: "Bad default", default: "yes" },
|
||||
brokenSelect: { type: "string", title: "Broken select", oneOf: { const: "low", title: "Low" }, format: 1 },
|
||||
enumSelect: { type: "string", title: "Enum select", enum: ["low", 1, "high"], enumNames: ["Low", "One", "High"] },
|
||||
brokenSelect: { type: "string", title: "Broken select", oneOf: { const: "low", title: "Low" } },
|
||||
enumSelect: { type: "string", title: "Enum select", enum: ["low", 1, "high"] },
|
||||
labels: {
|
||||
type: "array",
|
||||
title: "Labels",
|
||||
|
|
|
|||
|
|
@ -1174,9 +1174,6 @@ describe("ChatInboundHandler", () => {
|
|||
description: null,
|
||||
type: "string",
|
||||
required: true,
|
||||
format: null,
|
||||
minLength: null,
|
||||
maxLength: null,
|
||||
defaultValue: "",
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -547,7 +547,7 @@ describe("pending request renderer decisions", () => {
|
|||
unmountUiRootInAct(parent);
|
||||
});
|
||||
|
||||
it("does not accept MCP multi-select fields below minItems", () => {
|
||||
it("accepts MCP multi-select fields without panel-side cardinality validation", () => {
|
||||
const parent = document.createElement("div");
|
||||
const resolveMcpElicitation = vi.fn();
|
||||
|
||||
|
|
@ -568,15 +568,12 @@ describe("pending request renderer decisions", () => {
|
|||
}),
|
||||
);
|
||||
|
||||
const options = expectPresent(parent.querySelector<HTMLElement>(".codex-panel__mcp-elicitation-options"));
|
||||
const label = expectPresent(parent.querySelector<HTMLElement>(".codex-panel__mcp-elicitation-label"));
|
||||
expect(options.tagName).toBe("FIELDSET");
|
||||
expect(options.getAttribute("aria-labelledby")).toBe(label.id);
|
||||
expect(parent.querySelectorAll<HTMLInputElement>(".codex-panel__mcp-elicitation-checkbox")).toHaveLength(2);
|
||||
actEvent(() => {
|
||||
expectPresent(parent.querySelector<HTMLButtonElement>(".codex-panel__pending-request-button.mod-cta")).click();
|
||||
});
|
||||
|
||||
expect(resolveMcpElicitation).not.toHaveBeenCalled();
|
||||
expect(resolveMcpElicitation).toHaveBeenCalledWith(53, "accept");
|
||||
unmountUiRootInAct(parent);
|
||||
});
|
||||
|
||||
|
|
@ -722,9 +719,6 @@ function pendingMcpElicitation({
|
|||
description: "Issue title",
|
||||
type: "string",
|
||||
required: true,
|
||||
format: null,
|
||||
minLength: null,
|
||||
maxLength: null,
|
||||
defaultValue,
|
||||
},
|
||||
],
|
||||
|
|
@ -779,8 +773,6 @@ function pendingMcpMultiSelectElicitation(): PendingRequestBlockSnapshot["pendin
|
|||
{ value: "bug", label: "Bug" },
|
||||
{ value: "docs", label: "Docs" },
|
||||
],
|
||||
minItems: 1,
|
||||
maxItems: 2,
|
||||
defaultValue: [],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -111,33 +111,21 @@ describe("threads view renderer decisions", () => {
|
|||
|
||||
renderThreadsViewShell(parent, { status: "2 threads", loading: false, rows }, actions);
|
||||
|
||||
expect(parent.querySelector(".codex-panel-threads__badge")).toBeNull();
|
||||
const main = expectPresent(parent.querySelector<HTMLElement>(".codex-panel-threads__row--pending"));
|
||||
const row = expectPresent(main.closest<HTMLElement>(".codex-panel-threads__row"));
|
||||
expect(row.classList.contains("codex-panel-ui__nav-row")).toBe(true);
|
||||
expect(main.classList.contains("codex-panel-ui__nav-item")).toBe(true);
|
||||
expect(row.classList.contains("codex-panel-threads__row--selected")).toBe(true);
|
||||
expect(row.classList.contains("is-selected")).toBe(true);
|
||||
expect(parent.querySelector(".codex-panel-threads__list")?.getAttribute("role")).toBeNull();
|
||||
expect(main.getAttribute("role")).toBeNull();
|
||||
expect(main.getAttribute("tabindex")).toBeNull();
|
||||
expect(main.getAttribute("aria-current")).toBeNull();
|
||||
expect(row.getAttribute("title")).toBeNull();
|
||||
expect(row.classList.contains("codex-panel-threads__row--selected")).toBe(true);
|
||||
expect(main.classList.contains("codex-panel-ui__nav-item")).toBe(true);
|
||||
const toolbarButtons = [...parent.querySelectorAll<HTMLElement>(".codex-panel-threads__toolbar-button")];
|
||||
expect(toolbarButtons.map((button) => button.getAttribute("aria-label"))).toEqual(["Open new panel", "Refresh threads"]);
|
||||
expect(toolbarButtons.map((button) => button.tagName)).toEqual(["DIV", "DIV"]);
|
||||
expect(toolbarButtons.map((button) => button.getAttribute("role"))).toEqual([null, null]);
|
||||
const refresh = expectPresent(parent.querySelector<HTMLElement>('[aria-label="Refresh threads"]'));
|
||||
expect(refresh.classList.contains("codex-panel-threads__toolbar-button")).toBe(true);
|
||||
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<HTMLElement>('[aria-label="Open new panel"]'));
|
||||
expect(openNewPanel.classList.contains("codex-panel-threads__toolbar-button")).toBe(true);
|
||||
expect(openNewPanel.classList.contains("codex-panel-threads__row-button")).toBe(false);
|
||||
expect(openNewPanel.dataset["icon"]).toBe("message-square-plus");
|
||||
openNewPanel.click();
|
||||
expect(actions.openNewPanel).toHaveBeenCalledOnce();
|
||||
const rename = expectPresent(parent.querySelector<HTMLButtonElement>('[aria-label="Rename thread"]'));
|
||||
|
|
@ -145,8 +133,6 @@ describe("threads view renderer decisions", () => {
|
|||
expect(rename.classList.contains("codex-panel-ui__nav-row-action")).toBe(true);
|
||||
main.click();
|
||||
expect(actions.openThread).toHaveBeenCalledWith("open");
|
||||
expect(parent.querySelector<HTMLButtonElement>('[aria-label="Focus open panel"]')).toBeNull();
|
||||
expect(parent.querySelector<HTMLButtonElement>('[aria-label="Open in new panel"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("renders threads view archive confirmation with the default action on the right", () => {
|
||||
|
|
@ -222,10 +208,6 @@ describe("threads view renderer decisions", () => {
|
|||
renderThreadsViewShell(parent, { status: "1 thread", loading: false, rows: [row] }, actions);
|
||||
|
||||
expect(parent.querySelector<HTMLElement>(".codex-panel-threads__rename-form")).toBeTruthy();
|
||||
const actionsGroup = expectPresent(parent.querySelector<HTMLElement>(".codex-panel-threads__rename-actions"));
|
||||
expect(actionsGroup.querySelectorAll(".codex-panel-threads__row-button")).toHaveLength(1);
|
||||
expect(parent.querySelector<HTMLButtonElement>('[aria-label="Save thread name"]')).toBeNull();
|
||||
expect(parent.querySelector<HTMLButtonElement>('[aria-label="Cancel rename"]')).toBeNull();
|
||||
parent.querySelector<HTMLButtonElement>('[aria-label="Auto-name thread"]')?.click();
|
||||
|
||||
expect(actions.autoNameThread).toHaveBeenCalledWith("thread");
|
||||
|
|
@ -241,8 +223,6 @@ describe("threads view renderer decisions", () => {
|
|||
renderThreadsViewShell(parent, { status: "1 thread", loading: false, rows: [row] }, threadsViewActions());
|
||||
|
||||
expect(parent.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input")?.disabled).toBe(false);
|
||||
expect(parent.querySelector<HTMLButtonElement>('[aria-label="Save thread name"]')).toBeNull();
|
||||
expect(parent.querySelector<HTMLButtonElement>('[aria-label="Auto-name thread"]')?.disabled).toBe(true);
|
||||
expect(parent.querySelector<HTMLButtonElement>('[aria-label="Cancel rename"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,7 +7,17 @@ const sourceDir = path.join("src", "styles");
|
|||
const sourceFiles = JSON.parse(readFileSync(path.join(sourceDir, "order.json"), "utf8")) as string[];
|
||||
const styles = `${sourceFiles.map((file) => readFileSync(path.join(sourceDir, file), "utf8").trimEnd()).join("\n\n")}\n`;
|
||||
|
||||
describe("panel CSS token scope", () => {
|
||||
function ruleBody(selector: string): string {
|
||||
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return new RegExp(`(?:^|\\n)${escapedSelector} \\{(?<body>[^}]+)\\}`).exec(styles)?.groups?.["body"] ?? "";
|
||||
}
|
||||
|
||||
function standaloneRuleBody(selector: string): string {
|
||||
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return new RegExp(`(?:^|\\n\\n)${escapedSelector} \\{(?<body>[^}]+)\\}`).exec(styles)?.groups?.["body"] ?? "";
|
||||
}
|
||||
|
||||
describe("panel CSS boundaries", () => {
|
||||
it("defines design tokens on every standalone UI root", () => {
|
||||
const tokenScope = /^(?<selectors>(?:\.[^{]+,\n)*\.[^{]+) \{/m.exec(styles)?.groups?.["selectors"] ?? "";
|
||||
|
||||
|
|
@ -17,34 +27,25 @@ describe("panel CSS token scope", () => {
|
|||
expect(tokenScope).toContain(".codex-panel-threads");
|
||||
expect(tokenScope).toContain(".codex-panel-selection-rewrite");
|
||||
});
|
||||
|
||||
it("keeps selectors shallow enough for Obsidian theme compatibility", () => {
|
||||
expect(styles).not.toContain(":has(");
|
||||
expect(styles).not.toMatch(/:where\([^)]*[.#[]/);
|
||||
expect(styles).not.toMatch(/(^|[\s,{])#[\w-]+/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat toolbar CSS", () => {
|
||||
it("lets the message region size come from its grid row", () => {
|
||||
const messages = /\.codex-panel__messages \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
describe("panel CSS layout invariants", () => {
|
||||
it("lets the message stream scroll inside the shell grid", () => {
|
||||
const messages = ruleBody(".codex-panel__messages");
|
||||
|
||||
expect(messages).toContain("overflow-y: auto");
|
||||
expect(messages).not.toContain("overflow-anchor: none");
|
||||
expect(messages).not.toMatch(/^\s+height:/m);
|
||||
});
|
||||
|
||||
it("keeps toolbar panels visually separated from the following body content", () => {
|
||||
const toolbarPanel = /\.codex-panel__toolbar-panel \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
|
||||
expect(toolbarPanel).toContain("margin-bottom: var(--codex-panel-panel-gap)");
|
||||
});
|
||||
|
||||
it("aligns goal banner spacing with the message rhythm", () => {
|
||||
const goalSlot = /\.codex-panel__region--goal:not\(:empty\) \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
const goal = /\.codex-panel__goal \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
|
||||
expect(goalSlot).toContain("padding-bottom: var(--codex-panel-edge-padding-x)");
|
||||
expect(goal).toContain("margin: var(--codex-panel-edge-padding-x) var(--codex-panel-edge-padding-x) 0");
|
||||
});
|
||||
|
||||
it("lets icon-only toolbar actions use Obsidian nav action geometry", () => {
|
||||
const toolbarAction = /\.codex-panel-ui__toolbar-action \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
const toolbar = /\.codex-panel__toolbar \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
it("keeps icon-only toolbar actions on Obsidian toolbar geometry", () => {
|
||||
const toolbar = ruleBody(".codex-panel__toolbar");
|
||||
const toolbarAction = ruleBody(".codex-panel-ui__toolbar-action");
|
||||
|
||||
expect(toolbarAction).toContain("--icon-size: var(--codex-panel-control-icon-size)");
|
||||
expect(toolbarAction).toContain("--icon-stroke: var(--icon-m-stroke-width, 1.75px)");
|
||||
|
|
@ -53,335 +54,111 @@ describe("chat toolbar CSS", () => {
|
|||
expect(toolbarAction).not.toContain("padding:");
|
||||
expect(toolbarAction).not.toContain("border-radius:");
|
||||
expect(toolbar).not.toContain("padding:");
|
||||
expect(styles).not.toContain(".codex-panel__runtime-area");
|
||||
expect(styles).not.toContain(".codex-panel__runtime-strip");
|
||||
});
|
||||
|
||||
it("keeps mouse-focus reset less specific than active toolbar actions", () => {
|
||||
const toolbarActionMouseFocus =
|
||||
/\.codex-panel-ui__toolbar-action:where\(:focus:not\(:hover\):not\(:focus-visible\)\) \{(?<body>[^}]+)\}/.exec(styles)?.groups?.[
|
||||
"body"
|
||||
] ?? "";
|
||||
it("keeps shared nav rows aligned with Obsidian nav item geometry", () => {
|
||||
const navItem = ruleBody(".codex-panel-ui__nav-item");
|
||||
const navRow = ruleBody(".codex-panel-ui__nav-row");
|
||||
|
||||
expect(toolbarActionMouseFocus).toContain("background: transparent");
|
||||
expect(toolbarActionMouseFocus).toContain("color: var(--icon-color)");
|
||||
expect(navItem).toContain("min-height: var(--nav-item-size");
|
||||
expect(navItem).toContain("padding: var(--nav-item-padding");
|
||||
expect(navItem).toContain("border-radius: var(--nav-item-radius");
|
||||
expect(navRow).toContain("padding-inline-end:");
|
||||
});
|
||||
|
||||
it("does not color toolbar panel items as hovered for retained click focus", () => {
|
||||
const hoverSelectors = [
|
||||
...styles.matchAll(
|
||||
/\.codex-panel__toolbar-panel-item:where\((?<states>[^)]+)\):not\(:disabled\):not\(\.is-disabled\)\s+\.codex-panel__toolbar-panel-(?:label|meta) \{/g,
|
||||
),
|
||||
].map((match) => match.groups?.["states"] ?? "");
|
||||
it("keeps selected nav items stable when hovered or focused", () => {
|
||||
const selectedNavItem = ruleBody(".codex-panel-ui__nav-item.is-selected:where(:hover, :focus, :focus-visible, :active, :focus-within)");
|
||||
const selectedNavRow = ruleBody(
|
||||
[
|
||||
".codex-panel-ui__nav-row.is-selected,",
|
||||
".codex-panel-ui__nav-row.is-selected:hover,",
|
||||
".codex-panel-ui__nav-row.is-selected:focus-within",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
expect(hoverSelectors).toEqual([":hover, :focus-visible, :active", ":hover, :focus-visible, :active"]);
|
||||
});
|
||||
|
||||
it("keeps toolbar panel rows aligned to Obsidian nav items", () => {
|
||||
const toolbarPanelNavItem =
|
||||
/\.codex-panel-ui__nav-item\.codex-panel__toolbar-panel-item \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
|
||||
expect(toolbarPanelNavItem).toContain("justify-content: flex-start");
|
||||
expect(toolbarPanelNavItem).toContain("height: auto");
|
||||
expect(toolbarPanelNavItem).toContain("background: transparent");
|
||||
expect(toolbarPanelNavItem).toContain("box-shadow: none");
|
||||
});
|
||||
|
||||
it("uses the shared active state for toolbar actions", () => {
|
||||
const toolbarActionActive =
|
||||
/\.codex-panel-ui__toolbar-action\.is-active,\n\.codex-panel-ui__toolbar-action\.is-active:hover,\n\.codex-panel-ui__toolbar-action\.is-active:focus-visible,\n\.codex-panel-ui__toolbar-action\.is-active:active \{(?<body>[^}]+)\}/.exec(
|
||||
styles,
|
||||
)?.groups?.["body"] ?? "";
|
||||
|
||||
expect(toolbarActionActive).toContain("background: var(--background-modifier-active-hover)");
|
||||
expect(toolbarActionActive).toContain("color: var(--icon-color-active)");
|
||||
expect(styles).not.toContain(".codex-panel__runtime-model.is-active");
|
||||
expect(styles).not.toContain(".codex-panel__runtime-model-value");
|
||||
});
|
||||
|
||||
it("keeps class selectors out of zero-specificity :where selectors", () => {
|
||||
expect(styles).not.toMatch(/:where\([^)]*[.#[]/);
|
||||
});
|
||||
|
||||
it("keeps selected toolbar rows stable while hovered", () => {
|
||||
const navItem = /\.codex-panel-ui__nav-item \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
const selectedNavItem =
|
||||
/\.codex-panel-ui__nav-item\.is-selected:where\(:hover, :focus, :focus-visible, :active, :focus-within\) \{(?<body>[^}]+)\}/.exec(
|
||||
styles,
|
||||
)?.groups?.["body"] ?? "";
|
||||
const selectedNavRow =
|
||||
/\.codex-panel-ui__nav-row\.is-selected,\n\.codex-panel-ui__nav-row\.is-selected:hover,\n\.codex-panel-ui__nav-row\.is-selected:focus-within \{(?<body>[^}]+)\}/.exec(
|
||||
styles,
|
||||
)?.groups?.["body"] ?? "";
|
||||
|
||||
expect(navItem).toContain("min-height: var(--nav-item-size, var(--codex-panel-size-nav-item))");
|
||||
expect(navItem).toContain("padding: var(--nav-item-padding, var(--size-4-1) var(--size-4-2))");
|
||||
expect(navItem).toContain("border-radius: var(--nav-item-radius, var(--radius-s))");
|
||||
expect(navItem).toContain("color: var(--nav-item-color, var(--text-muted))");
|
||||
expect(styles).toContain("background: var(--codex-panel-nav-item-background-hover, var(--background-modifier-hover))");
|
||||
expect(selectedNavItem).toContain("background: var(--nav-item-background-active, var(--background-modifier-active))");
|
||||
expect(selectedNavRow).toContain("background: var(--nav-item-background-active, var(--background-modifier-active))");
|
||||
expect(selectedNavItem).not.toContain("nav-item-background-active-hover");
|
||||
expect(selectedNavRow).not.toContain("nav-item-background-active-hover");
|
||||
});
|
||||
|
||||
it("keeps chat thread row actions inset from the row edge", () => {
|
||||
const threadList = /\.codex-panel__threads \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
const navRow = /\.codex-panel-ui__nav-row \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
const statusPanelItems = /\.codex-panel__status-panel-items \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
|
||||
expect(threadList).toContain("gap: var(--nav-item-margin-bottom, var(--codex-panel-panel-gap))");
|
||||
expect(statusPanelItems).toContain("gap: var(--nav-item-margin-bottom, var(--codex-panel-panel-gap))");
|
||||
expect(navRow).toContain("--codex-panel-nav-item-background-hover: transparent");
|
||||
expect(navRow).toContain("padding-inline-end: var(--size-4-2)");
|
||||
});
|
||||
|
||||
it("keeps the composer context status text fixed-width", () => {
|
||||
const contextMeter = /\.codex-panel__composer-meta-context \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
const contextDots = /\.codex-panel__composer-meta-context-dots \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
const placeholderDot =
|
||||
/\.codex-panel__composer-meta-context-dot\.is-placeholder \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
const contextPercent = /\.codex-panel__composer-meta-context-percent \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
|
||||
expect(contextMeter).toContain("font-variant-numeric: tabular-nums");
|
||||
expect(contextMeter).toContain("white-space: nowrap");
|
||||
expect(contextDots).toContain("display: inline-flex");
|
||||
expect(placeholderDot).toContain("color: var(--text-faint)");
|
||||
expect(contextPercent).toContain("white-space: pre");
|
||||
expect(styles).not.toContain(".codex-panel__composer-meta-context::before");
|
||||
expect(styles).not.toContain("conic-gradient");
|
||||
});
|
||||
|
||||
it("aligns composer status text with the input text inset", () => {
|
||||
const composerFrame = /\.codex-panel__composer-frame \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
const composerMeta = /\.codex-panel__composer-meta \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
const composerMetaStatus = /\.codex-panel__composer-meta-status \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
const composerMetaFatal = /\.codex-panel__composer-meta-fatal \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
|
||||
expect(composerFrame).toContain("background: var(--background-modifier-form-field)");
|
||||
expect(composerMeta).toContain("padding: 0 var(--size-4-2) var(--size-4-1)");
|
||||
expect(composerMetaStatus).toContain("position: relative");
|
||||
expect(composerMetaStatus).not.toContain("padding-inline-start");
|
||||
expect(composerMetaFatal).not.toContain("padding-inline-start");
|
||||
});
|
||||
|
||||
it("lets the shell provide Obsidian status bar composer clearance", () => {
|
||||
const tokenScope = /^(?<selectors>(?:\.[^{]+,\n)*\.[^{]+) \{(?<body>[^}]+)\}/m.exec(styles)?.groups?.["body"] ?? "";
|
||||
const clearanceLine = tokenScope
|
||||
.split("\n")
|
||||
.find((line) => line.trim().startsWith("--codex-panel-status-bar-clearance:"))
|
||||
?.trim();
|
||||
|
||||
expect(clearanceLine).toBe("--codex-panel-status-bar-clearance: 0px;");
|
||||
});
|
||||
|
||||
it("keeps composer status accessible summary visually hidden", () => {
|
||||
const summary = /\.codex-panel__composer-meta-summary \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
const visual = /\.codex-panel__composer-meta-status-visual \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
|
||||
expect(summary).toContain("position: absolute");
|
||||
expect(summary).toContain("width: var(--border-width)");
|
||||
expect(summary).toContain("height: var(--border-width)");
|
||||
expect(summary).toContain("pointer-events: none");
|
||||
expect(summary).toContain("opacity: 0");
|
||||
expect(summary).not.toContain("clip-path:");
|
||||
expect(summary).not.toContain("clip:");
|
||||
expect(visual).toContain("display: flex");
|
||||
expect(visual).toContain("flex: 0 0 auto");
|
||||
expect(visual).toContain("gap: var(--size-4-2)");
|
||||
});
|
||||
|
||||
it("keeps composer runtime mode icons compact and state-colored", () => {
|
||||
const modes = /\.codex-panel__composer-meta-modes \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
const field = /\.codex-panel__composer-meta-field \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
const labels =
|
||||
/\.codex-panel__composer-meta-value\.codex-panel__composer-meta-value \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
const icon = /\.codex-panel__composer-meta-icon \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
const activeIcon = /\.codex-panel__composer-meta-icon\.is-active \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
const iconSvg = /\.codex-panel__composer-meta-icon svg \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
|
||||
expect(modes).toContain("gap: var(--codex-panel-control-gap)");
|
||||
expect(field).toContain("display: inline-flex");
|
||||
expect(field).toContain("flex: 0 0 auto");
|
||||
expect(field).toContain("gap: var(--size-4-2)");
|
||||
expect(labels).toContain("white-space: nowrap");
|
||||
expect(labels).toContain("display: inline-flex");
|
||||
expect(labels).not.toContain("text-overflow");
|
||||
expect(icon).toContain("width: var(--codex-panel-size-icon-xs)");
|
||||
expect(icon).toContain("height: var(--codex-panel-size-icon-xs)");
|
||||
expect(activeIcon).toContain("color: var(--icon-color-active)");
|
||||
expect(iconSvg).toContain("width: calc(var(--codex-panel-size-icon-xs) - var(--codex-panel-rail-width) / 2)");
|
||||
expect(iconSvg).toContain("height: calc(var(--codex-panel-size-icon-xs) - var(--codex-panel-rail-width) / 2)");
|
||||
expect(styles).toContain(
|
||||
".codex-panel__composer-meta-status.is-effort-hidden .codex-panel__composer-meta-field--effort {\n display: none;",
|
||||
it("keeps nav inline inputs as native inline editors", () => {
|
||||
const navInlineInput = ruleBody(".codex-panel-ui__nav-inline-input.codex-panel-ui__nav-inline-input");
|
||||
const navInlineInputFocus = ruleBody(
|
||||
[
|
||||
".codex-panel-ui__nav-inline-input.codex-panel-ui__nav-inline-input:focus,",
|
||||
".codex-panel-ui__nav-inline-input.codex-panel-ui__nav-inline-input:focus-visible,",
|
||||
".codex-panel-ui__nav-inline-input.codex-panel-ui__nav-inline-input:hover,",
|
||||
".codex-panel-ui__nav-inline-input.codex-panel-ui__nav-inline-input:active",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(styles).toContain(
|
||||
".codex-panel__composer-meta-status.is-model-hidden .codex-panel__composer-meta-field--model {\n display: none;",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps composer status triggers visually unstyled", () => {
|
||||
const popover = /\.codex-panel__composer-meta-popover \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
const option = /\.codex-panel__composer-meta-option \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
|
||||
expect(styles).not.toContain(".codex-panel__composer-meta-trigger {");
|
||||
expect(styles).not.toContain(".codex-panel__composer-meta-trigger:hover");
|
||||
expect(styles).not.toContain("button.codex-panel__composer-meta-trigger");
|
||||
expect(popover).toContain("position: absolute");
|
||||
expect(popover).toContain("bottom: calc(100% + var(--codex-panel-panel-gap))");
|
||||
expect(popover).toContain("width: max-content");
|
||||
expect(popover).toContain("max-width: calc(100% - var(--codex-panel-composer-meta-popover-left))");
|
||||
expect(styles).not.toContain(".codex-panel__composer-meta-option.is-selected");
|
||||
expect(option).not.toContain("padding-left");
|
||||
expect(option).not.toContain("grid-template-columns");
|
||||
});
|
||||
|
||||
it("keeps nav inline input reset in the shared primitive", () => {
|
||||
const navInlineInput =
|
||||
/\.codex-panel-ui__nav-inline-input\.codex-panel-ui__nav-inline-input \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
const navInlineInputFocus =
|
||||
/\.codex-panel-ui__nav-inline-input\.codex-panel-ui__nav-inline-input:focus,\n\.codex-panel-ui__nav-inline-input\.codex-panel-ui__nav-inline-input:focus-visible,\n\.codex-panel-ui__nav-inline-input\.codex-panel-ui__nav-inline-input:hover,\n\.codex-panel-ui__nav-inline-input\.codex-panel-ui__nav-inline-input:active \{(?<body>[^}]+)\}/.exec(
|
||||
styles,
|
||||
)?.groups?.["body"] ?? "";
|
||||
const chatRenameInput = /\.codex-panel__thread-rename-input \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
const threadsRenameInput = /\.codex-panel-threads__rename-input \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
|
||||
expect(navInlineInput).toContain("appearance: none");
|
||||
expect(navInlineInput).toContain("color: var(--nav-item-color-active, var(--text-normal))");
|
||||
expect(navInlineInput).toContain("border: 0");
|
||||
expect(navInlineInput).toContain("background: transparent");
|
||||
expect(navInlineInputFocus).toContain("background: transparent");
|
||||
expect(chatRenameInput).toContain("width: 100%");
|
||||
expect(chatRenameInput).not.toContain("appearance: none");
|
||||
expect(threadsRenameInput).toContain("flex: 1 1 auto");
|
||||
expect(threadsRenameInput).not.toContain("appearance: none");
|
||||
});
|
||||
|
||||
it("lets the usage limit meter absorb panel width changes", () => {
|
||||
const limitList = /\.codex-panel__limit-panel-list \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
const limitRow = /\.codex-panel__limit-panel-row \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
const limitMeterCell = /\.codex-panel__limit-panel-meter-cell \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
const limitMeter = /\.codex-panel__limit-panel-meter \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
const limitValue = /\.codex-panel__limit-panel-value \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
it("keeps compact status rows from wrapping or stealing pointer interaction", () => {
|
||||
const contextMeter = ruleBody(".codex-panel__composer-meta-context");
|
||||
const summary = ruleBody(".codex-panel__composer-meta-summary");
|
||||
|
||||
expect(contextMeter).toContain("white-space: nowrap");
|
||||
expect(contextMeter).toContain("font-variant-numeric: tabular-nums");
|
||||
expect(summary).toContain("position: absolute");
|
||||
expect(summary).toContain("pointer-events: none");
|
||||
});
|
||||
|
||||
it("lets usage limit meters absorb available width", () => {
|
||||
const limitMeterCell = ruleBody(".codex-panel__limit-panel-meter-cell");
|
||||
const limitValue = ruleBody(".codex-panel__limit-panel-value");
|
||||
const limitReset = standaloneRuleBody(".codex-panel__limit-panel-reset");
|
||||
|
||||
expect(limitList).toContain("display: table");
|
||||
expect(limitList).toContain("width: 100%");
|
||||
expect(limitList).toContain("border-spacing: 0 var(--codex-panel-panel-gap)");
|
||||
expect(limitRow).toContain("display: table-row");
|
||||
expect(limitRow).not.toContain("display: contents");
|
||||
expect(limitMeterCell).toContain("display: table-cell");
|
||||
expect(limitMeterCell).toContain("width: 100%");
|
||||
expect(limitMeterCell).toContain("vertical-align: middle");
|
||||
expect(limitMeter).toContain("margin: 0");
|
||||
expect(limitValue).toContain("font-variant-numeric: tabular-nums");
|
||||
expect(limitValue).not.toContain("text-align: right");
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat message CSS", () => {
|
||||
it("uses hover color instead of a pointer cursor for the inline turn diff action", () => {
|
||||
const openTurnDiff = /\.codex-panel__open-turn-diff \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
const openTurnDiffSvg = /\.codex-panel__open-turn-diff svg \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
const openTurnDiffHover = /\.codex-panel__open-turn-diff:hover \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
|
||||
expect(openTurnDiff).toContain("cursor: default");
|
||||
expect(openTurnDiffSvg).toContain("width: calc(var(--codex-panel-size-icon-xs) - var(--codex-panel-rail-width) / 2)");
|
||||
expect(openTurnDiffSvg).toContain("height: calc(var(--codex-panel-size-icon-xs) - var(--codex-panel-rail-width) / 2)");
|
||||
expect(openTurnDiffHover).toContain("color: var(--nav-item-color-hover, var(--text-normal))");
|
||||
});
|
||||
});
|
||||
|
||||
describe("selection rewrite CSS", () => {
|
||||
it("uses a composer-style instruction frame without the old action stack", () => {
|
||||
const frame = /\.codex-panel-selection-rewrite__composer-frame \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
const instruction = /\.codex-panel-selection-rewrite__instruction \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
const iconButton = /\.codex-panel-selection-rewrite__icon-button \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
|
||||
expect(frame).toContain("grid-template-columns: minmax(0, 1fr) auto");
|
||||
expect(frame).toContain("background: var(--background-modifier-form-field)");
|
||||
expect(frame).toContain("border: var(--input-border-width, var(--border-width, 1px)) solid var(--background-modifier-border)");
|
||||
expect(instruction).toContain("min-height: var(--codex-panel-size-user-input-text-min-height)");
|
||||
expect(instruction).toContain("background: transparent");
|
||||
expect(instruction).toContain("border: 0");
|
||||
expect(iconButton).toContain("min-width: calc(var(--codex-panel-control-icon-size) + var(--codex-panel-panel-gap) * 2)");
|
||||
expect(iconButton).toContain("margin: var(--codex-panel-panel-gap)");
|
||||
expect(iconButton).toContain("padding: var(--codex-panel-panel-gap)");
|
||||
expect(styles).not.toContain(".codex-panel-selection-rewrite__controls");
|
||||
expect(limitReset).toContain("white-space: nowrap");
|
||||
});
|
||||
|
||||
it("keeps the apply action inside the selection rewrite result frame", () => {
|
||||
const result = /\.codex-panel-selection-rewrite__result \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
const actions = /\.codex-panel-selection-rewrite__result-actions \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
const diffBody = /\.codex-panel-selection-rewrite__diff-body \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
it("keeps threads view row titles ellipsized while actions stay collapsed until interaction", () => {
|
||||
const rowTitleBase = ruleBody(".codex-panel-threads__row-title");
|
||||
const rowTitleDisplay = standaloneRuleBody(".codex-panel-threads__row-title");
|
||||
const actions = ruleBody(".codex-panel-threads__actions");
|
||||
const actionReveal = ruleBody(
|
||||
[
|
||||
".codex-panel-threads__row:hover .codex-panel-threads__actions,",
|
||||
".codex-panel-threads__row:focus-within .codex-panel-threads__actions",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
expect(result).toContain("grid-template-columns: minmax(0, 1fr) auto");
|
||||
expect(result).toContain("border: var(--codex-panel-border)");
|
||||
expect(result).toContain("background: var(--background-modifier-form-field)");
|
||||
expect(actions).toContain("padding: 0");
|
||||
expect(diffBody).toContain("margin: 0");
|
||||
expect(diffBody).toContain("border-radius: 0");
|
||||
expect(diffBody).toContain("background: transparent");
|
||||
expect(rowTitleDisplay).toContain("display: block");
|
||||
expect(rowTitleBase).toContain("white-space: nowrap");
|
||||
expect(rowTitleBase).toContain("text-overflow: ellipsis");
|
||||
expect(actions).toContain("width: 0");
|
||||
expect(actions).toContain("pointer-events: none");
|
||||
expect(actions).toContain("opacity: 0");
|
||||
expect(actionReveal).toContain("width: auto");
|
||||
expect(actionReveal).toContain("pointer-events: auto");
|
||||
expect(actionReveal).toContain("opacity: 1");
|
||||
});
|
||||
|
||||
it("aligns generation status text with the instruction input text inset", () => {
|
||||
const status = /\.codex-panel-selection-rewrite__status \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
it("keeps threads view inline rename aligned to nav row height", () => {
|
||||
const renameForm = ruleBody(".codex-panel-threads__rename-form");
|
||||
const renameField = ruleBody(".codex-panel-threads__rename-field");
|
||||
const renameInput = ruleBody(".codex-panel-threads__rename-input");
|
||||
|
||||
expect(status).toContain("padding-inline-start: calc(var(--size-4-1) / 2)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("threads view CSS", () => {
|
||||
it("keeps long row titles clear of trailing actions", () => {
|
||||
const list = /\.codex-panel-threads__list \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
const rowMain = /\.codex-panel-threads__row-main \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
const renameForm = /\.codex-panel-threads__rename-form \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
const renameField = /\.codex-panel-threads__rename-field \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
const renameInput = /\.codex-panel-threads__rename-input \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
const title = /(?:^|\n\n)\.codex-panel-threads__row-title \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
|
||||
expect(list).toContain("gap: var(--nav-item-margin-bottom, var(--codex-panel-panel-gap))");
|
||||
expect(list).toContain("padding: var(--size-4-1) var(--size-4-3)");
|
||||
expect(rowMain).toContain("box-sizing: border-box");
|
||||
expect(rowMain).not.toContain("padding-right:");
|
||||
expect(renameForm).toContain("min-height: var(--nav-item-size, var(--codex-panel-size-nav-item))");
|
||||
expect(renameField).toContain("min-height: var(--nav-item-size, var(--codex-panel-size-nav-item))");
|
||||
expect(renameForm).toContain("min-height: var(--nav-item-size");
|
||||
expect(renameField).toContain("min-height: var(--nav-item-size");
|
||||
expect(renameInput).toContain("line-height: var(--line-height-tight)");
|
||||
expect(title).toContain("display: block");
|
||||
});
|
||||
|
||||
it("keeps toolbar action hover color separate from row action hover color", () => {
|
||||
const toolbarHover =
|
||||
/\.codex-panel-ui__toolbar-action:hover:not\(\.is-disabled\),\n\.codex-panel-ui__toolbar-action:focus-visible:not\(\.is-disabled\) \{(?<body>[^}]+)\}/.exec(
|
||||
styles,
|
||||
)?.groups?.["body"] ?? "";
|
||||
const toolbarMouseFocus =
|
||||
/\.codex-panel-ui__toolbar-action:where\(:focus:not\(:hover\):not\(:focus-visible\)\) \{(?<body>[^}]+)\}/.exec(styles)?.groups?.[
|
||||
"body"
|
||||
] ?? "";
|
||||
const rowHover =
|
||||
/\.codex-panel-ui__nav-row-action:hover,\n\.codex-panel-ui__nav-row-action:focus,\n\.codex-panel-ui__nav-row-action:focus-visible,\n\.codex-panel-ui__nav-row-action:active \{(?<body>[^}]+)\}/.exec(
|
||||
styles,
|
||||
)?.groups?.["body"] ?? "";
|
||||
it("keeps selection rewrite controls framed as a compact edit-and-review surface", () => {
|
||||
const composerFrame = ruleBody(".codex-panel-selection-rewrite__composer-frame");
|
||||
const result = ruleBody(".codex-panel-selection-rewrite__result");
|
||||
|
||||
expect(styles).not.toContain(".codex-panel-threads__toolbar-actions {");
|
||||
expect(toolbarHover).toContain("background: var(--background-modifier-hover)");
|
||||
expect(toolbarHover).toContain("color: var(--icon-color)");
|
||||
expect(toolbarHover).not.toContain("var(--icon-color-active)");
|
||||
expect(toolbarMouseFocus).toContain("background: transparent");
|
||||
expect(toolbarMouseFocus).toContain("color: var(--icon-color)");
|
||||
expect(rowHover).toContain("color: var(--icon-color-active)");
|
||||
});
|
||||
|
||||
it("does not rely on :has() to avoid hover-highlighting action rows", () => {
|
||||
const rowHover =
|
||||
/\.codex-panel-ui__nav-row:hover,\n\.codex-panel-ui__nav-row:focus-within \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
const titleHover =
|
||||
/\.codex-panel-threads__row-main:hover \.codex-panel-threads__row-title,\n\.codex-panel-threads__row-main:focus \.codex-panel-threads__row-title \{(?<body>[^}]+)\}/.exec(
|
||||
styles,
|
||||
)?.groups?.["body"] ?? "";
|
||||
const title = /(?:^|\n\n)\.codex-panel-threads__row-title \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
|
||||
|
||||
expect(styles).not.toContain(":has(");
|
||||
expect(rowHover).not.toContain("--codex-panel-threads-row-title-color");
|
||||
expect(titleHover).toContain("color: var(--nav-item-color-hover, var(--text-normal))");
|
||||
expect(title).toContain("color: var(--codex-panel-threads-row-title-color)");
|
||||
expect(composerFrame).toContain("grid-template-columns:");
|
||||
expect(composerFrame).toContain("background: var(--background-modifier-form-field)");
|
||||
expect(result).toContain("grid-template-columns:");
|
||||
expect(result).toContain("border: var(--codex-panel-border)");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue